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.

8 min readUpdated 6 August 2026affiliate, ib, rebates, commission, mlm, cron

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, INTERNAL B-book fills and EXTERNAL A-book fills all write the same COMMISSION deal.

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 ACTIVE referral, 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/UNILEVEL without the corresponding settings configured;
  • the condition's minAmount was 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