Engineering
Engineering

Anatomy of a Commission Engine: Decimals, Idempotency, and a Ledger That Can't Half-Post

Zac Sheffer · 2026.8.28 · 11 min read

A referral commission looks like the easiest calculation in software: deal amount times a percentage. We shipped that version in a week. Everything since has been learning why nobody's commission engine is actually that calculation.

What "deal × percent" leaves out: rates that tier on deal size, rates that escalate over time, fees that recur monthly against revenue that changes every month, fees calculated on other fees (the connector gets 20% of the marketplace's fee, not of the deal), and fees that fire when a referral hits a pipeline stage but must never fire again when that stage change replays. Underneath all of it, one requirement. When finance asks why a partner was paid $612.50 in March, the system answers to the cent, from a record that hasn't changed since March.

We buy the plumbing and build the product. Stripe handles collection; Stripe Billing and the usage-billing vendors model a merchant charging its customers, and a commission engine is the opposite direction — money splitting outward from a deal to multiple parties, resolved per deal, some fees computed on other fees. That calculation is the thing we sell, so that's the thing we built.

The pipeline: FeePlanFeeRuleFeeEventInvoicePaymentLedgerEntry. Five design decisions made it trustworthy. Here they are, plus the production bug that taught us one of them and the gotchas to skip if you're building your own.

Decision 1: decimals at rest, cents at one boundary

Every amount is Postgres NUMERIC(19,4) via Prisma. Floats drift, and drift means the books stop reconciling, which is the whole job. Four decimal places carry sub-cent precision through intermediate math; rounding happens once, at the payment boundary, because Stripe takes integer cents. Math.round(amount * 100) exists in exactly one place in the codebase. In flight the math is plain JavaScript numbers, which is fine here: every fee computes in a few operations from a stored decimal, so float error never accumulates, and no float ever becomes a source of truth.

model FeeEvent {
  calculatedValue Decimal @db.Decimal(19, 4)
  currency        String  @default("USD") @db.VarChar(3)

  payerRole   String? // the role from the plan, e.g. "marketplace_owner"
  payerEntity String? // who that role resolved to, at calculation time

  applied        Boolean @default(false) // invoiced events are immutable
  idempotencyKey String? @unique
}

Decision 2: fee plans are role templates, resolved late

A fee plan doesn't say "pay Sarah 10%." It says the connector role earns 10%, paid by the marketplace_owner role, and roles resolve to actual entities at calculation time from the deal's participants. One plan works across every deal a partner touches.

The schema stores both the role and the entity it resolved to. That's the audit requirement hiding inside the design: when someone asks in March why a fee went to a particular person, the answer can't depend on re-running role resolution against today's data, because the partner may have left and the chain may have changed. The event snapshots the resolution, so the record answers for itself.

Rule conditions (tiers, escalating steps, conditional filters) are JSON columns validated with Zod at every boundary they cross. A malformed tier config fails at write time with a useful error, not at month-end inside a calculation loop.

Decision 3: fees on fees run in two passes

Waterfall plans base the connector's fee on what the marketplace earned, not on the deal. The base of one rule is the output of another, so the calculator runs twice:

// Pass 1: rules based on the deal amount run normally.
// Rules based on another party's fee get deferred.
if (feeRule.calculationBasis === 'PAYER_FEE') {
  deferredRules.push({ feeRule, feePlan, chainId })
  continue
}

// Pass 2: the deferred rule's base is what its payer earned in pass 1.
const payerFeeTotal = pass1Results
  .filter(r => r.payeeRole === feePlan.payerRole)
  .reduce((sum, r) => sum + r.calculatedValue, 0)

Two passes is a deliberately boring answer. A dependency graph between rules would handle waterfalls-on-waterfalls, and no commission agreement we've onboarded has needed one. When a third layer shows up in a contract, we'll add a third pass and this paragraph will age badly.

Decision 4: fees are events, and applied events are immutable

This is the decision that carries the most weight. Every calculation produces FeeEvent rows, discrete facts rather than balance updates: this rule, on this deal, for this period, produced this value, payable by X to Y. Events start as drafts, and drafts are disposable. The moment one lands on an invoice, applied flips to true and the event becomes untouchable.

That one flag drives the whole recalculation policy. Edit a period's revenue before invoicing and the engine deletes the period's draft events and recomputes from scratch. Replacement, not mutation, so stale math can't survive an edit. Edit it after invoicing and the recalc refuses to run:

const appliedCount = existingFeeEvents.filter(e => e.applied).length
if (appliedCount > 0) {
  // An invoice already went out based on these events.
  // The system must never diverge from a document a partner has seen.
  return { recalculated: false, skipped: 'invoiced' }
}

The correction path for an already-invoiced mistake is a new, explicit adjustment on the next statement, not a silent rewrite of history. Partners forgive an adjustment line. They don't forgive a statement that says something different than it said last week.

The other half is idempotency at creation. Fee accrual runs from queue jobs, and jobs retry. Each accrual computes a deterministic key and lets a database unique constraint be the arbiter, the same pattern Stripe uses for their API:

try {
  const event = await prisma.feeEvent.create({ data: { ...fee, idempotencyKey } })
  feeEventIds.push(event.id)
  createdCount += 1
} catch (error) {
  if (!isUniqueViolation(error)) throw error
  // A retry or a concurrent job got here first. The fee exists exactly once.
  const existing = await prisma.feeEvent.findUnique({ where: { idempotencyKey } })
  if (existing) feeEventIds.push(existing.id)
}

Create-and-catch rather than an upsert, because the caller needs to know whether this run created the fee. Run the job once, twice, or ten times: one fee event.

The bug: dedup keys need to know what period they're in

Our draft-event dedup originally keyed on (feeRuleId, payerEntity, payeeEntity, calculatedValue). Rule, parties, amount. Reasonable-looking. Then a customer set up a flat $500 monthly fee.

A flat monthly fee produces an identical value every month. Month two's event matched month one's on every field in the key, got classified as a duplicate, and was silently dropped. Month three, same. The deal page showed the right math; accounts receivable showed $0 owed for every month after the first. Nothing errored. The books were just quietly wrong, which is the failure mode this entire system exists to prevent.

The fix was one clause. Dedup within a billing period, not across the whole deal:

const isDuplicate = existingEvents.some(
  e =>
    e.feeRuleId === result.feeRuleId &&
    e.payerEntity === result.payerEntity &&
    e.payeeEntity === result.payeeEntity &&
    Number(e.calculatedValue) === result.calculatedValue &&
    // The fix: identical fees in *different* periods are not duplicates.
    e.periodAmountId === scopedPeriodAmountId
)

A dedup key is a claim about what makes two things "the same," and recurring money makes that claim subtle. Two identical amounts to the same person are duplicates within a period and legitimate across periods. If your key can't tell those apart, your idempotency layer is a bug with good intentions.

Decision 5: a ledger row that can't half-post

The ledger records what actually happened to the money, classic double-entry accounting. The textbook implementation writes two rows per transaction, one debit and one credit, then needs a transaction wrapper and a balancing check for the crash that leaves one row without its partner. We put both sides in one row instead:

await prisma.ledgerEntry.create({
  data: {
    transactionId,
    debitAccountId,  // DR: Connector Commission Expense
    creditAccountId, // CR: Connector Payables
    amount,
    entryType: 'INVOICE',
  },
})

A row exists or it doesn't, so balance-by-construction lives in the schema instead of in a nightly check job. The trade: a single row can't represent a multi-leg transaction, one debit split across three credits. Our postings are all one-to-one, so we bought the invariant and paid with flexibility we don't use. If splits ever show up, they become N rows sharing a transactionId and the balance check moves back into code like everyone else's. (Square's ledger service solves multi-leg properly, because at their scale they need it.)

Each business event posts twice, on the two bases accountants actually use. Invoice issued: expense against payable, the obligation exists. Payment landed: payable against cash, the money moved. "Fees owed" and "fees paid" are balance reads on entries that were each born balanced.

Everything accrues through a queue, and the worker assumes replays

Fees accrue when referrals hit milestones, and none of that runs in a request handler. Stage changes enqueue jobs on BullMQ, and the worker assumes any job may arrive twice, late, or malformed:

export async function processReferralFeeLifecycleJob(job: Job) {
  const parsed = ReferralFeeLifecycleJobDataSchema.safeParse(job.data)
  if (!parsed.success) {
    // Permanent no-op: retrying a malformed payload can never succeed.
    return { skipped: true, reason: 'invalid_payload' }
  }
  return accrueReferralLifecycleFees(parsed.data)
}

Payloads are Zod-validated at the consumption boundary, because a queue is an API whose clients include your own past deploys. Permanent failures return a skip instead of throwing, because throwing means retrying, and retrying a payload that can never parse burns attempts and pages someone at 3am for a job that was dead on arrival.

Gotchas we hit so you don't have to

  • Dedup keys without the period. The war story above. Any identity key over recurring money needs the period in it.
  • Calculation code with two modes will double-bill. Our calculator has a whole-deal mode (fan recurring fees out across every elapsed period) and a single-period mode (recompute one period). Before we guarded it, a single-period recalc still fanned out, pinning N identical events onto one period. Two to three times over-billed. If a code path can create money-events under more than one mode, test the cross product.
  • Replays fire rules they were never configured for. A replayed stage-change event was letting monthly recurring rules accrue. The fix is a strict trigger match during replays: the recurring schedule owns recurring fees, lifecycle events own lifecycle fees, and every fee has exactly one path that can create it. That last sentence is most of what "auditable" means in practice.
  • Summary flags stamped at the wrong grain. We stamped feesCalculated deal-wide, which flipped it on periods holding no events and made the flag meaningless per period. Stamp state at the grain you query it.
  • Average-month date math. Elapsed periods are computed with 30.44-day months. It's held up, but it's an approximation, and period boundaries near month ends are exactly where it would crack. It stays on our watch list.

The bugs in that list each shipped with a regression test that reproduces the failure first. The calculator itself is covered by fixture tests per amount type and bracket matcher.

Where to start if you're building one

For context on scale: this is SMB finance, hundreds of deals and thousands of fee events per org, not millions per second. Some of our choices (recomputed balances, in-memory pass-2 filtering) are right at this scale and wrong at a payment processor's. The invariants are the part that must be scale-independent, because invariants are brutal to retrofit and throughput isn't.

  1. Decimal money at rest. Round once, at the payment boundary.
  2. Model fees as immutable events with a draft → applied lifecycle, not as balance updates. This one decision drives recalculation, invoicing, and audit. Get it right before anything else.
  3. Deterministic idempotency keys with a unique constraint, from day one. Retries are the default in any queue-driven system, not the exception.
  4. Put the billing period in every identity and dedup key that touches recurring money.
  5. Double-entry ledger with both sides in one row, unless you genuinely need multi-leg splits.
  6. One creation path per fee type. When two paths can create the same fee, one of them eventually will.

All of it surfaces as the least dramatic screen in the product: a statement where earnings move from earned to invoiced to paid, every line tracing back to a rule, a deal, and a period. In commission systems, boring is the feature. A partner who never has to ask "is this number right?" is the entire point.

We ship in public. Come build with us.

Introzy is free to start. No card required.