DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The affiliate commission was agreed before we knew the buyer's country

CogniPrep has affiliate codes. A creator gets a code, their audience gets money off, the creator gets a cut. Both cuts are percentages of the list price, stored on the code when it is minted.

The arithmetic looks like it should fit in one line. It took three passes to get right, and every correction came from money moving somewhere we had not modelled.

Pass one: commission is a share of list, not of what the buyer paid

export function computeAffiliateBreakdown(
  listAmount: number,
  buyerDiscountPercent: number,
  commissionPercent: number
): AffiliateBreakdown {
  // ...
  const commissionAmount = Math.round((listAmount * commissionPercent) / 100);
}
Enter fullscreen mode Exit fullscreen mode

A £15 unlock on a 10% / 25% code charges the buyer £13.50 and owes the creator £3.75. Note that £3.75 is 25% of £15, not 25% of £13.50.

That is a deliberate choice and it is the one creators care about, because it makes their earnings independent of how generous a discount we let them offer. It also means the two percentages have to be bounded together:

/**
 * Bounds enforced when a code is minted. The two cuts must together stay under
 * 100% or we would pay out more than we take in; `MAX_COMBINED_PERCENT` leaves a
 * floor for our own margin and the Stripe fee we absorb.
 */
export const MIN_PERCENT = 0;
export const MAX_PERCENT = 100;
export const MAX_COMBINED_PERCENT = 90;
Enter fullscreen mode Exit fullscreen mode

Validating each percentage against 0 to 100 independently is not enough. A 60% discount with a 50% commission is two individually legal numbers that together mean every sale loses money. The check is on the sum, at mint time, so an impossible code cannot exist rather than being caught later by a report nobody reads.

A 0% buyer discount is legal, incidentally. That is pure creator support: no saving, just attribution. It needs a special case because the shared discount helper requires a positive percentage.

Pass two: VAT arrives after the percentage is fixed

We price tax-inclusive through Stripe Managed Payments, so the number on the page is the number on the card:

export const MANAGED_PAYMENTS_TAX_BEHAVIOR: 'inclusive' | 'exclusive' = 'inclusive';
Enter fullscreen mode Exit fullscreen mode

It was exclusive for a while. An EU buyer read one figure in the app and then watched Stripe add 19 to 27% on top at checkout, which was one of the reasons roughly half of all Checkout sessions expired unpaid. EU and UK consumer law expects B2C prices shown VAT-inclusive anyway, so the compliant default is also the converting one.

The cost of that choice is margin. An EU sale nets the list price divided by one plus the rate. A £15 unlock sold to Germany nets about £12.61.

Now put the two halves together. The commission of £3.75 was computed from the GBP list price before the buyer entered a billing address. It is written into Stripe Checkout session metadata at that moment. Nothing at that point knows the sale will turn out to be German and that 19% of the total will go to a tax authority.

Pay out the agreed £3.75 on a sale that netted £12.61 and you have handed the creator a share of money that never arrived.

The fix is a ratio, and it is currency-agnostic on purpose

/**
 * The share of a completed session's total that was NOT tax, as a fraction in
 * (0, 1]. Multiply a GBP-denominated share of the sale by this to get the share
 * of what we actually received.
 */
export function netOfTaxRatio(
  amountTotal: number | null | undefined,
  amountTax: number | null | undefined
): number {
  if (!amountTotal || amountTotal <= 0) return 1;
  if (!amountTax || amountTax <= 0) return 1;
  if (amountTax >= amountTotal) return 1;
  return (amountTotal - amountTax) / amountTotal;
}

export function commissionNetOfTax(agreedCommission: number, session): number {
  const ratio = netOfTaxRatio(session.amount_total, session.total_details?.amount_tax);
  return Math.round(agreedCommission * ratio);
}
Enter fullscreen mode Exit fullscreen mode

Worked through with a real session: a £15 unlock on a 10/25 code, charged in euros to Germany. amount_total is 1849, total_details.amount_tax is 295. We kept 1554 of 1849, so the £3.75 commission becomes 315 pence.

Three things about that function are doing real work.

It returns 1 in every degenerate case. A UK sale, a US sale, anything outside the Managed Payments country allowlist: no tax field, ratio 1, commission untouched. Malformed payloads likewise. The safe default is "change nothing", because the failure mode of the other default is silently shrinking every creator's earnings during an outage.

It is a ratio, not a subtraction. The session may be denominated in euros while the commission ledger is in GBP pence. You cannot subtract 295 euro cents from 375 GBP pence. A dimensionless ratio crosses the currency boundary safely; an absolute amount does not.

It is applied at accrual, not at payout. The ledger row records what is owed, already net. There is no second adjustment step later that somebody has to remember to run.

The currency label you must not copy

This one is a trap I walked into and the test now pins it:

/**
 * Every amount recorded is GBP pence, the currency of the catalogue and of the
 * commissions ledger. The Stripe session itself may be in the buyer's currency,
 * which is why `session.currency` is deliberately NOT copied onto the row: a EUR
 * label on pence figures would make the payout report add euros to pounds.
 */
await accrueCommission({
  // ...
  commissionAmount: netCommission,
  currency: 'gbp',
});
Enter fullscreen mode Exit fullscreen mode

currency: 'gbp' is hardcoded. It looks like a bug. Copying session.currency is what an attentive reviewer would suggest, and it would produce a ledger where some rows are labelled EUR while holding GBP pence, and a payout report that sums them into a single meaningless total.

The accrual also refuses to trust anything it cannot parse as an integer:

const int = (value: string | undefined): number | null => {
  if (value === undefined) return null;
  const n = Number(value);
  return Number.isInteger(n) ? n : null;
};

if (listAmount === null || chargedAmount === null || /* ... */) {
  logPayment(`Skipping affiliate accrual for session ${session.id}: malformed metadata`);
  return;
}
Enter fullscreen mode Exit fullscreen mode

Stripe metadata values are strings. Number(undefined) is NaN, and NaN propagates through arithmetic silently until it reaches a database column and either throws or stores something nonsensical. Skipping loudly beats accruing quietly.

Pass three: do not pay out on a sale that will reverse

/**
 * How long a commission is held before it becomes eligible for payout. Long
 * enough that card refunds and chargebacks have settled, so we do not pay out on
 * a sale that later reverses. 30 days matches the typical chargeback window.
 */
export const PAYOUT_HOLD_DAYS = 30;
Enter fullscreen mode Exit fullscreen mode

Commissions accrue immediately and become eligible 30 days later. The payout query filters on status = 'accrued' AND hold_until < now.

Settlement is deliberately manual. We hold no bank details; the operator transfers the money out of band and then records it. Recording it is the part that has to be exactly right, because the whole risk is paying the same balance twice:

return db.transaction(async (tx) => {
  // Lock the eligible rows so a concurrent run cannot claim the same balance.
  const locked = await tx
    .select({ id: affiliateCommissionsTable.id, amount: affiliateCommissionsTable.commission_amount })
    .from(affiliateCommissionsTable)
    .where(and(
      eq(affiliateCommissionsTable.code_id, codeId),
      eq(affiliateCommissionsTable.status, 'accrued'),
      lt(affiliateCommissionsTable.hold_until, now)
    ))
    .for('update');

  if (locked.length === 0) return null;

  // ...insert one `paid` payout row summarising them...
  await tx.update(affiliateCommissionsTable)
    .set({ status: 'paid', payout_id: payoutId, paid_at: now })
    .where(inArray(affiliateCommissionsTable.id, ids));
});
Enter fullscreen mode Exit fullscreen mode

SELECT ... FOR UPDATE inside the transaction is what makes it safe. Two operators running the payout script at the same time, or one operator double-clicking, cannot both claim the same commission rows. The second one finds zero eligible rows and returns null.

Returning null for "nothing to settle" and for "a concurrent run got there first" is intentional: from the caller's perspective those are the same outcome, which is "do not send any money".

The shape of the lesson

Every one of these corrections has the same shape. A number was computed at the moment of intent, and then reality arrived later and changed what the number meant.

The commission percentage is agreed when the code is minted. The list price is known at checkout. The tax is known only when the payment completes. The refund risk resolves 30 days after that.

Money code is mostly the discipline of not letting an earlier number pretend it knew about a later fact.

See it

Open cogniprep.app/pricing. The figure you see is what your card is charged, VAT included, in your local currency where we support it. If you are in the EU, the amount we actually receive is that figure divided by one plus your country's rate, and every downstream calculation, including what a creator is owed, works from the smaller number.

If you are on a VPN, switch exit countries between an EU country and the UK or US and reload. The displayed price changes, and so does the arithmetic behind it.

Top comments (0)