RFC: Partner Rebate Payouts

Date: 2026-07-30

Status: Superseded for everything that moves money by Generic Program Payout Module. Retained as the record of what a payer must do. Its P-numbered decisions are the requirements the referral and API-broker producers hold any payer to, and they are cross-referenced by name from Referral Program. Read What survives first.

Author: Joey McCarey (with Claude)

Related: Referral Program — produces the approved partner_programs.rebate_accruals row this RFC settles. API Broker / OMS Program — produces accruals of the same shape; settlement is identical and shared. commercial-program-accruals — the producer, whose D9 restates this RFC's requirements as payer acceptance criteria. System trading accounts (shipped; their RFC is retired) — the rebate is a debit against AX_FEE_ACCOUNT_ID. Verify USDC Deposits Onchain — the treasury_engine.deposit_credit_attempts design this RFC was the first to specify an implementation of.

Concept

An approved rebate_accruals row becomes a credit to the partner's AX trading account, booked double-entry against the fee account, with an immutable authorization ledger in front of it.

The mechanics below are built once, generically, by program-payouts (A-4492), and referral is its first adapter. Read every table name here as a role, not an identifier: the leading payer names them payout_engine.programs / requests / attempts rather than the payout_program_config / program_payout_requests / payout_attempts used throughout this document. Its contract matches what is required here — a non-nullable account_id, the idempotency_key LIKE program_kind || '://%' check, a HELD state, and a 0-or-1 credited-attempt index. This document states requirements a payer must meet, so it survives the payer changing.

What survives

Absorbed by the payout module — do not build: the rebate_payouts ledger (§A) becomes the module's attempts table; the settlement step (§B), the monthly sweep (P9), one-partner-per-transaction pacing (P5), and the claim_window_periods mechanism (P8) all become module code and config.

The enqueue is not ours to design, but it is ours to call. The producer's own host task makes the call, against the contract here and against the acceptance criteria in commercial-program-accruals D9. The producer owns everything upstream of that row — which accruals get approved, and at what threshold — plus the payee-less hold, which never reaches the queue.

Settlement state is read back through a supported join. The admin surface reads the requests table joined to the attempts table on request_key WHERE outcome = 'CREDITED', with transaction_event_id on that row as the link to the transaction event. The module's owner confirmed (2026-08-05) this is a stable producer-facing contract, kept cheap and 0-or-1 by a one-credit-per-request index.

Retained, and still the producer's to build:

Sequencing. This RFC's four gates are now the module's shared gates, decided once for every producer. The one question that does not move is whether a human must authorize every sweep regardless of size: that sets referral's auto-approval threshold, which is producer-side policy gating the admin surfaces, not the cash path.

Answers needed before this ships

These four are now program-payouts gates (its O1–O4), tracked on A-4508 and answered once for all producers. They are kept here because the reasoning is referral-specific and the owners are the same people. The "Blocks" column is stale wherever it names a PR from this RFC; the SoW's Track C says what gates what now.

# Question Owner Blocks
O1 Payout currency: USDfiat vs USDC. TransactionKind::Deposit rejects Currency::USD (lib.rs:131-142); confirm against what the fee account holds. Eng + Finance §B step 3
O2 Answered by P9: a monthly sweep at period close, over approved accruals only. What remains is operational — the pacing gap between transactions and the off-peak window, both forced by the global advisory lock (P5). Ops Pacing only
O3 Answered by P8: held, not forfeited. What remains is narrower: is an indefinitely held balance acceptable to Compliance and Finance, or must claim_window_periods be finite? P8 takes either answer as a constant. Compliance + Finance The window value only
O4 Tax and withholding treatment of a rebate credited to a trading account. Finance + Legal Go-live. Not a systems question, but nothing pays out until it is settled

Until a payer ships, an approved accrual plus its CSV statement is the settlement artifact and Finance settles by hand. See Referral Program §F.

Background

The Referral Program stops at an approved accrual and a CSV statement. Finance can reconcile and settle by hand for the first periods, but hand settlement does not scale and has no idempotency story.

This RFC is split from that one because it is the part that mutates balances, its blast radius differs in kind, and it can land weeks after partners are already earning.

What exists today

Component State
AX_FEE_ACCOUNT_ID The double-entry sink for collected fees (rs/sdk-internal/src/account_id.rs:10). Its balance is live in current_balances; it is not exposed through GET /admin/accounts/{id}.
The deposit path POST /deposit (rs/api-gateway/src/utils.rs:1232-1307) is how MM stipends are already paid by hand, consistent with the dynamic-fee SoW classifying stipends as deposits rather than fees.
Double-entry precedent lending_transactions (rs/transaction-engine/src/lending.rs:23-60) books a matched credit/debit pair against a system counterparty.
Transaction-engine idempotency None. insert_transactions (rs/transaction-engine/src/lib.rs:61) does usd_balance = usd_balance + amount with no dedup, and the caller-supplied event_id is written to ClickHouse and used for nothing. A replayed payout double-credits.
The authorization-ledger shape treasury_engine.deposit_credit_attempts (db/postgres/1.sql:1464-1486) is an immutable authorization ledger with an idempotency key, actor and reason. Schema only, with zero Rust references.

Decisions

Design

A. The authorization ledger

CREATE TABLE partner_programs.rebate_payouts (
    -- 'partner-rebate://<period>/<partner_slug>/<program_kind>'. Deterministic,
    -- so a replay collides here instead of double-crediting.
    idempotency_key      TEXT PRIMARY KEY,
    account_id           CHAR(16) NOT NULL REFERENCES trading_accounts(id),
    amount               NUMERIC NOT NULL,
    outcome              TEXT NOT NULL,
    -- The transaction-engine event this credit became. NULL iff FAILED.
    transaction_event_id TEXT UNIQUE,
    actor                TEXT NOT NULL,
    reason               TEXT NOT NULL,
    attempt_ts           TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT rebate_payouts_outcome_valid
        CHECK (outcome IN ('CREDITED', 'FAILED')),
    CONSTRAINT rebate_payouts_event_id_iff_credited
        CHECK ((outcome = 'CREDITED') = (transaction_event_id IS NOT NULL))
);

The table references its accrual by that accrual's natural key (user_id, program_kind, period), which is also what the idempotency URN carries.

Immutable: a freeze trigger rejects UPDATE and DELETE. Corrections are new rows (P7).

The accrual's lifecycle extends by one terminal state, approved → paid, flipped in the same transaction that books the transfer, so the ledger and the accrual cannot disagree.

B. The settlement step

Two entry points, one code path. The monthly sweep (P9) is a scheduled task that selects approved, payable accruals for the closed period and drives them one at a time, paced, off-peak. The admin pay endpoint drives the same routine for a single accrual, admin-only, with Clerk step-up required (the POST /admin/verify-identity MultiFactor-within-1-minute pattern, admin_routes.rs:2733-2787). Both share the P4 idempotency key, so a manual payment followed by a sweep collides and no-ops.

The sweep's selection predicate is the whole of its policy: approved_at IS NOT NULL, the partner is active with a payout_account_id, not already paid, and not expired. Everything it skips stays where it was.

Per accrual:

  1. Refuse unless approved_at IS NOT NULL and the partner has a payout_account_id and is active. Refusal here is a hold, not an error (P8): the accrual stays approved and payable later. Distinguish it from a genuine failure in the admin surface, or the unpayable queue reads as a backlog of broken payouts.
  2. Open a transaction; insert the authorization row with the deterministic key. A collision ends the attempt as a no-op, which is the desired behavior on replay.
  3. Book the credit/debit pair through handle_transactions_with_txn, with reference_id set to the same URN.
  4. Flip the accrual from approved to paid.
  5. Set the authorization row's outcome and transaction_event_id, and commit. Steps 2 through 5 are one transaction.

C. Operational cautions

These belong in the runbook:

D. Observability and testing

End-to-end check: after paying, assert the authorization row's outcome is CREDITED with a transaction_event_id, the ClickHouse transactions row carries reference_id = 'partner-rebate://…', current_balances.usd_balance moved by exactly the rebate amount, and re-running the payout is a no-op.

Deliverables

See SoW: Partner Programs, Track C.

Open questions

All four are listed with their owners in Answers needed before this ships.