No money is ever on-chain. Funds move through Stripe. Balances live in an internal double-entry ledger and escrow tables in PostgreSQL. The smart contract only records attribution events, including a “settled” flag that means vested, not paid. See Smart contracts.
The life of a commission
1. Access and funding
Access. When billing enforcement is switched on (it is off by default), before a brand on Shopify can create or fund a campaign, its store needs an active plan through Shopify’s app pricing, or the brand can hold an active FREEQ stake instead. This is an access check only; the plan is not the campaign budget. Admins are never gated, and providers without app billing are exempt. Funding. A brand funds a campaign through Stripe Checkout (POST /api/payments/campaign/{campaignId}/fund). When Stripe confirms payment, the ledger records a CAMPAIGN_FUND entry from the platform’s Stripe holding account into the campaign’s budget account, and the campaign can activate. Some partner programs that pay creators in their own points are funded notionally instead, from a separate issuance account, so points never read as cash.
2. Commission and escrow
When an order is attributed and its conversion is recorded on-chain (see Attribution engine):- Commission = campaign rate x the order total. This holds for every campaign, single-product ones included: once the order contains the campaign product, the whole order counts (after discounts, including tax and shipping). The base is frozen on the order before anything is sent.
- Advertiser sales fee (Shopify stores only; Rush, Fern and fixture stores are exempt) =
ADVERTISER_SALES_FEE_BPSx the same order total, rounded half-up in the order currency’s minor units. It is charged only when the order is in the campaign’s booking currency, and never on a legacy matched-lines order. It is taken from the campaign budget on top of the commission (ADVERTISER_FEE_EARNED, budget →platform:advertiser-fees:<CCY>), in the same transaction as the hold, and the rate and amount are stamped on the hold. - Fee first. Under the campaign row lock: if the remaining budget cannot cover the whole fee plus at least one minor unit of commission, the order books neither and ends
BUDGET_EXHAUSTED. Otherwise the fee is booked in full and the commission is capped by what remains, so the last sale before exhaustion may earn a partial commission. A card-funded campaign cannot be topped up while it is live; only notionally funded partner campaigns accept top-ups. - The commission moves from the campaign budget into the creator’s per-campaign escrow account (
ESCROW_HOLD), and an escrow hold row records when it may be released. - Budget-exhaustion auto-end (Shopify campaigns only). When a booking takes the budget to zero, or an order cannot be booked for lack of budget (which can leave a small residual that the brand can reclaim later), the campaign is marked
end_reason = BUDGET_EXHAUSTEDand ended through the normal durable end path (themanual-end:<id>task and its on-chain end). The END flip queues the campaign-ended emails to the brand and its creators. An hourly sweep re-queues a lost end, and (whenBUDGET_EXHAUSTION_SWEEP_ENABLEDis on) ends older campaigns already at zero.
Hold duration
Each hold is assigned a risk tier. The hold lastsmax(tier days, tenant floor) from the moment it was created:
Tier inputs include Shopify’s order risk assessment, platform and creator risk signals, device matching, and manual review. When the store’s risk assessment is still pending, the tier is assigned later and release waits for it.
The risk engine and device matching run in shadow mode by default: they compute tiers and auto-pause decisions and write an audit record, but do not change the hold or pause the creator. Device matching is also disabled by default. With enforcement off, holds stay at the
CLEAR baseline (30 days, or the tenant’s minimum hold if that is longer); dispute freezes and manual admin pauses always apply.
A hold is released only when all are true: its release time has passed, its tier is assigned, it is not frozen by an open dispute, and the creator’s escrow is not paused (by an admin or by automatic fraud checks).
3. Release, fees and the reserve
A scheduler checks for matured holds every minute. Releasing one hold is a single database transaction that:- Moves the net amount from escrow to the creator’s available balance (
ESCROW_RELEASE). - Deducts any fees stamped on the hold when it was created (
PLATFORM_FEE_EARNED, andPARTNER_FEE_EARNEDfor a partner tenant that takes a share of its creators’ earnings). Stamping at hold time means a later rate change never touches existing holds. - Skims a reserve. A share of the net (20% by default) goes into the creator’s reserve pot for that campaign, until the pot reaches its cap:
max(2 x the creator's average net commission on that campaign, a per-currency floor), with the floor anchored at $50 for USD. - Enqueues the on-chain settlement record (below).
4. Settlement delivery
Release never waits on the chain. A separate worker drains the settlement queue and callsregisterSettlement on the registry for each released hold, with leases, bounded retries and strict sequencing. On-chain, settlement flips isSettled and emits ConversionSettled. It means the commission vested. It does not move money.
5. Refunds, partial refunds and chargebacks
A partial refund reduces the held commission in proportion to the order value refunded, whichever items were returned. The reduction comes out of the commission held at order time, so a later rate change does not affect it. Open disputes freeze the hold; a won dispute unfreezes it. Chargebacks are risk signals. Refunds inside the hold are not: the commission is simply reversed. Refunds after release are a realised loss, so un-waived post-release refunds feed the creator’s risk score and the late-loss guardrail.
6. Creator withdrawal
Creators are paid through Stripe Connect Express accounts.- The creator onboards (
POST /api/payments/publishers/{publisherId}/stripe/onboard) and can open Stripe’s dashboard through a login link. - The creator withdraws (
POST /api/payments/publishers/{publisherId}/withdraw). This pays the full available balance as one Stripe transfer per campaign with a balance. Only the creator can withdraw; admins cannot withdraw on their behalf. - A per-creator lock prevents two withdrawals at once (
409while one is in flight; the second request is refused, not queued, and should be retried after the first completes). The ledger recordsPAYOUT_INITIATEDandPAYOUT_COMPLETED.
7. Reclaiming unused budget
When a campaign completes, the brand can reclaim what is left (POST /api/campaigns/{id}/withdraw-funds). The platform issues a Stripe refund against the original charge and returns PENDING; the refund is confirmed asynchronously by Stripe’s webhook, which records CAMPAIGN_REFUND. The call is safe to repeat. If the original charge can no longer be refunded, a platform administrator settles the remainder out of band so the budget is zeroed and recorded.
The ledger
The ledger (LedgerEntry, table ledger_entries) is the record of truth for money.
Accounts are strings with a fixed naming scheme:
Invariants:
- Double entry. Each event is one row with a debit account, a credit account and one amount. The amount leaves one and enters the other.
- Append-only. Rows are never updated or deleted. A correction is a new reversing row.
- Idempotent. Every row has a unique idempotency key; repeating a write returns the existing row.
- Integer minor units. Amounts are integers in the currency’s smallest unit, with an explicit currency code.
- Balances are sums. A balance is the sum of credits minus debits for an account. Some tables carry cached aggregate columns; for creator money, always derive from the ledger.
LedgerEntryType enum in services/prisma/schema.prisma.
Brand spend
A campaign’s spend is derived from the ledger, not frombudgetAmount − budgetRemaining (a reclaim zeroes the cached remaining budget, which would read as everything spent). From every row whose debit or credit account is exactly campaign:<campaignId>:budget:
spent=CAMPAIGN_FUNDcredits −CAMPAIGN_REFUNDdebits − the budget account’s balanceplatformFee=ADVERTISER_FEE_EARNED−ADVERTISER_FEE_REVERSEDcommissions=spent−platformFee
metrics.spendBreakdown on GET /api/campaigns/{id} and GET /api/campaigns/{id}/metrics, and as spent / commissions / platformFee on GET /api/payments/campaign/{campaignId}/balance. It is null (fail closed) when a row is in another currency than the booking currency, a ledger type or direction not expected on a budget account appears, or a total goes negative. GET /api/pricing?storeId= returns the current fee rate for a store the calling brand is a member of.