IB and partner rebates on the dealing desk
The two FX rebate conditions, what their reward numbers actually mean, why the sweep is a cron and not a hook, exactly-once accounting, and how to reconcile awards against the deals ledger.
An introducing broker sends you clients and takes a cut of what those clients generate. This addon ships that machinery: two affiliate conditions, a dedicated 10-minute sweep over the deals ledger, and exactly-once accounting so a retry cannot pay a partner twice.
All of it is off on a fresh install and on every upgrade, and it stays off until you enable a condition by hand.
Rebates are awarded through the platform's MLM/affiliate engine. If the
mlm extension is not installed and active, processRewards returns
immediately and the sweep does nothing — it will not error, it will simply never
create a reward. A partner also earns nothing unless an ACTIVE referral row
links them to the trader; a PENDING referral is skipped by both reward paths.
The two conditions
Both live on Admin → Affiliate → Conditions (mlm_referral_condition) and
both are seeded with status: false.
name |
type |
Title on the screen | Seeded reward | Seeded status |
|---|---|---|---|---|
FX_TRADE_COMMISSION |
FOREX_TRADING |
Forex IB Commission Share | 20 PERCENTAGE |
disabled |
FX_TRADE_VOLUME |
FOREX_TRADING |
Forex IB Volume Rebate | 100 PERCENTAGE |
disabled |
You opt in per condition. Editing and enabling both need
edit.affiliate.condition; the screen is the same one every other commission
rule uses, described in
Commission conditions.
What the reward number means is different for each
This is the part that costs money if you skim it.
FX_TRADE_COMMISSION — the classic IB split. The amount handed to the
reward engine is the commission the client actually paid on that trade, in the
account's currency. A PERCENTAGE reward of R pays R% of it. At the seeded
20, a client charged 5.00 in commission earns their introducer 1.00. That is
your commission line being shared, so the number is bounded by what you charge:
set commissionPerLot to 0 on a symbol group and this condition pays nothing on
that group, ever, because no COMMISSION deal is written.
FX_TRADE_VOLUME — a per-lot rebate wearing a percentage's clothes. The
amount handed to the engine is lots traded, not money. A PERCENTAGE
reward of R therefore pays lots × R/100:
reward |
Pays |
|---|---|
100 (seeded) |
1.00 per lot |
50 |
0.50 per lot |
250 |
2.50 per lot |
There is no cap and no relationship to your revenue. A client trading 500 lots of a zero-commission FX group earns their introducer 500.00 at the seeded rate while earning you only the spread markup. Decide the per-lot figure against your markup, not against the default.
The reward engine compares the condition's minAmount against the same amount
it is about to pay on. For FX_TRADE_COMMISSION that is money; for
FX_TRADE_VOLUME it is lots. Both ship at minAmount: 0. Setting the volume
condition's minimum to 10 in the belief that it means "10 dollars of activity"
silently stops paying anything under ten lots.
Which currency the partner is actually paid in
The sweep passes the trading account's currency (fx_account.accountCurrency)
to the reward engine, and that value is only used for validation — neither FX
condition restricts currency, so any account currency qualifies. The reward row
itself stores a bare number. The unit is decided at claim time from the
condition's own rewardCurrency and rewardWalletType, which seed as USDT
into a SPOT wallet.
So a EUR-denominated account paying 5.00 EUR of commission produces a reward of
1 that the partner claims as 1 USDT. If your accounts are not denominated
in the condition's reward currency, you are paying at an implied 1:1 rate. Fix
that by choosing a rebate figure that accounts for it, or by retargeting the
condition's currency before you enable it — see
Rewards and payouts.
Running both at once
Nothing stops you, and nothing warns you.
The overlap guard in admin/affiliate/condition/overlap.ts refuses to enable a
condition that competes with one already active — a 409 you must acknowledge
with acknowledgeOverlap: true. FX_TRADE_COMMISSION / FX_TRADE_VOLUME is
on that guard's TWO_SIDED_PAIRS exemption list, alongside
NFT_PURCHASE/NFT_SALE and P2P_TRADE/P2P_TRADE_COMPLETION, so enabling the
second one raises no warning at all.
The exemption is correct: the two are the two halves of one IB deal and their
reward fields cannot be summed, because 100 on the volume condition means
"1.00 per lot" and not "100%". But the consequence is yours to hold in your
head — one trade by one referred client pays the same partner twice, off two
different bases. A 1-lot EUR/USD fill on a group charging 7.00 commission pays
1.40 (commission share at 20) plus 1.00 (volume rebate at 100): 2.40 on 7.00 of
revenue.
If you only want one economics model, enable one condition.
DEMO accounts are excluded, deliberately
The sweep resolves each commission deal's owning account and keeps only
fx_account.type = 'LIVE'. A commission deal on a DEMO account is counted as
skipped and never rebated.
Demo volume is free to manufacture — the terminal auto-provisions a demo account on a customer's first visit and funds it with paper money. Paying rebates on it would be a direct payout exploit: register, self-refer through a second account, trade demo lots in a loop, claim real USDT.
Why it is a sweep and not an inline hook
Commission is booked inside the order transaction — the market fill and the
pending fill in execution.ts, and the external booking path — as a
COMMISSION deal with idempotency key fx_commission_<positionId>. Calling the
MLM engine there would put third-party writes on the hot trade path, inside a
lock-holding transaction that a client is waiting on.
Instead, processFxAffiliateRebates reads committed COMMISSION deals
afterwards. Two things follow:
- A rebate failure can never reject or slow a client order. The sweep swallows its own errors and logs them; the cron registry never sees a rejection.
- Every commission source is covered without the execution engine knowing
rebates exist — market fills, pending fills,
INTERNALB-book fills andEXTERNALA-book fills all write the sameCOMMISSIONdeal.
The trade-off is latency: a partner's reward appears up to ten minutes after the fill, not instantly.
Admin → System → Cron lists it as Fx Affiliate Rebates
(processFxAffiliateRebates), period 10 minutes. With no FX condition enabled it
costs one indexed query per run and returns.
Exactly-once, and what the Redis cursor is not
Every reward carries a sourceId:
| Condition | sourceId |
|---|---|
FX_TRADE_COMMISSION |
fxc_<dealId> |
FX_TRADE_VOLUME |
fxv_<dealId> |
mlm_referral_reward.sourceId carries a UNIQUE index
(mlmReferralRewardSourceIdUnique), and the reward writer checks it before
inserting and swallows the constraint violation if it loses a race. Under a
multi-level structure each upline level gets its own key suffixed :L1, :L2
and so on, so five sponsors are five distinct rewards rather than four blocked
duplicates.
That, and only that, is what makes payouts exactly-once.
fx:rebate:cursor (and its tie-break companion fx:rebate:cursor_id) record
how far the last run got, so a busy desk does not re-scan a day of deals every
ten minutes. Losing them — a Redis wipe, a failover, a FLUSHALL — costs one
larger scan. Every already-paid deal is rejected by the sourceId guard, so a
lost cursor cannot double-pay. Both keys carry a 30-day TTL so a quiet desk
never loses its place.
Catch-up bounds
| Bound | Value | Why |
|---|---|---|
| Cold-start lookback | 24 hours | What a run with no cursor scans |
| Hard cap | 7 days | A stale cursor from a long outage is clamped to this, so a cold start cannot trigger an unbounded scan |
| Batch limit | 500 deals per run | The cursor advances, so the next run continues where this one stopped |
| Cursor overlap | 1 second | The stored cursor is rewound one second, so a commission committed in the same second as the batch's last row is not lost |
fx_deal.createdAt is a whole-second DATETIME and the scan is
createdAt > cursor, which is why the rewind exists. The one case it cannot
cover — a full 500-row batch entirely inside a single second — is resumed by id
instead, using fx:rebate:cursor_id; without that the sweep would re-select the
same 500 rows forever and never reach row 501.
If the extension is inactive, the cron process is down, or rebate conditions are enabled late, commissions that fall out of the 7-day window are simply never swept. Enabling a condition does not backfill: it starts from the cursor, or from 24 hours ago on a cold start. If you owe a partner for an earlier period, create the reward by hand on Admin → Affiliate → Rewards.
Where the awards land
A reward is an entitlement, not money. The sweep writes a
mlm_referral_reward row with isClaimed = false; nothing moves until the
partner presses Claim in their own affiliate area, at which point the KYC gate,
the payout threshold and the wallet resolution described in
Rewards and payouts all apply.
Review them at Admin → Affiliate → Rewards, filtered to the two FX conditions. Total unclaimed across those two rows is your outstanding IB liability, and it is invisible to profit reporting until it is claimed.
The partner is notified — Referral Reward Earned, linking to
/affiliate/reward — on every reward created.
Reconciling against the deals ledger
The deals ledger is the source of truth for what was charged;
mlm_referral_reward is the record of what was rebated. They join on the deal
id embedded in sourceId.
Admin → Forex Trading → Finance → Deals Ledger, filtered to COMMISSION,
is the list of every rebatable event. To check the two sides line up:
SELECT d.id,
d.accountId,
ABS(d.pnl) AS commission,
d.createdAt
FROM fx_deal d
JOIN fx_account a ON a.id = d.accountId AND a.type = 'LIVE'
LEFT JOIN mlm_referral_reward r
ON r.sourceId LIKE CONCAT('fxc_', d.id, '%')
WHERE d.kind = 'COMMISSION'
AND d.createdAt >= NOW() - INTERVAL 2 DAY
AND r.id IS NULL;The LIKE matters: under a BINARY or UNILEVEL structure the stored key is
fxc_<dealId>:L1, not fxc_<dealId>. Swap the prefix to fxv_ for the volume
condition.
Rows returned by that query are not necessarily a fault. A commission deal legitimately produces no reward when:
- the trader has no
ACTIVEreferral, so nobody introduced them; - the deal is older than the sweep's window, or newer than the last run;
- the mlm extension is inactive, or its system type is
BINARY/UNILEVELwithout the corresponding settings configured; - the condition's
minAmountwas not met.
What is a fault is more than one reward per (deal, level) pair — that would
mean the unique index is missing. Check it:
SELECT sourceId, COUNT(*) c
FROM mlm_referral_reward
WHERE sourceId LIKE 'fx%'
GROUP BY sourceId HAVING c > 1;For the volume condition, lots are derived from the position's OPEN deal
amount divided by the instrument's metadata.contractSize — not from
fx_position.amount, which a partial close reduces and a full close zeroes.
That is why a scalping client who opens and closes inside one sweep interval
still earns their introducer the full volume.
Turning it back off
Set the condition's status to inactive on Admin → Affiliate → Conditions. Disabling is never guarded — turning payouts off cannot cost anyone money — and the sweep's first query stops matching, so it returns immediately from the next run.
Rewards already created are unaffected and remain claimable. To retract one you
have not settled, delete it on Admin → Affiliate → Rewards; the soft delete
keeps the unique sourceId, so the sweep will not recreate it.
Next
- Commission conditions — the conditions screen, the two reward engines and the overlap guard in full
- Rewards and payouts — claiming, thresholds, wallets and what a programme does to profit reporting
- Instruments, groups and sessions —
commissionPerLot, the number the commission share is a share of - Settings and reference — every scheduled job and every table