Joseph Leporati

Designing a Commission Engine That Survives Refunds and Chargebacks

Short answer

Commission balances drift because a single mutable number can't record why it changed. Build the engine as a double-entry ledger instead. Postings are insert-only, every entry sums to zero, and refunds or chargebacks are posted as exact reversals of the original entry, never as edits. Give each party and holdback its own account, derive balances from postings, and run trial-balance and clearing-account checks on every batch so drift shows up within a day.

Abstract mirrored bars balancing on a center line, suggesting reversing ledger entries that sum to zero

Most commission systems start as a single column: a balance on the agent or producer row that goes up when a sale posts and down when a payout goes out. That holds up until refunds, carrier chargebacks, split corrections and post-closing adjustments start arriving. Then the agent statement stops matching what finance thinks is owed, and nobody can say when the two diverged, because the column only knows its current value.

My background is in commission payouts. At Plondo Network I designed and ran direct-sales platforms used by 500K+ people since 2012, with 99.9% uptime on the systems that pay out commissions. What follows is not a write-up of any one of those systems. It's the specification I'd give a team building a commission back office for an agency, brokerage or sales organization, built on the standard double-entry practices documented in the sources linked throughout.

Why does a single running balance drift?

A mutable balance has no invariant. Any value looks plausible, so a wrong value goes unnoticed until someone disputes it. These are the usual ways it drifts:

  • Refunds recomputed with today's rules. The original sale paid 70% under the Q1 plan. The refund lands in Q3 and gets reversed at the Q3 rate of 65%. The agent keeps $50 of unearned money per $1,000 of commission, and nothing ever flags it.
  • Double application on retry. A request times out and the client retries, so the decrement runs twice. This is the same failure that produces double charges, covered in designing idempotency for payments.
  • Ad hoc fixes. Without a ledger enforcing double-entry, teams get tempted to inject a balance adjustment without recording its source, and those unaccounted-for funds get harder to untangle over time.
  • Netting hides misconfiguration. When fees, splits and overrides roll into one net figure, a stale rate disappears into the total.
  • History gets rewritten. Editing a commission row changes last quarter's statement after it was sent and paid.

Each of these is small. Across thousands of transactions and a few plan changes a year, they add up. At that point the only reconciliation left is to re-derive everything from source data and hope it matches.

Edit versus reverse: the rules that keep a ledger honest

In double-entry bookkeeping, every transaction has both a debit and a credit, and total credits always equal total debits. Records are also immutable: you never change an existing posting, and to alter one you create new reversing entries. That makes a refund simply the reverse of the original entry pattern, with no special-case logic. Balances are computed at read time by aggregating postings per account rather than stored in a mutable field.

These are the rules I'd write into the spec:

  1. Postings are insert-only. The application's database role gets no UPDATE or DELETE on ledger tables.
  2. Every journal entry sums to zero, and the database enforces it, not application code.
  3. A correction is a reversal plus a new posting. The reversal references the original entry and negates every line exactly.
  4. Every entry references its source event (sale, carrier statement line, closing, payout batch) and the commission plan version it used.
  5. Balances are derived. If you cache them for speed, the cache is disposable and checked against the postings.
create table commission_plan_versions (
  id             int primary key,
  plan_id        int not null,
  effective_from date not null,
  rules          jsonb not null          -- splits, rates, vesting schedule; never updated
);

create table journal_entries (
  id              bigint generated always as identity primary key,
  source_type     text not null,         -- 'sale','carrier_stmt','closing','payout','adjustment'
  source_id       text not null,
  plan_version    int references commission_plan_versions(id),
  reverses_id     bigint unique references journal_entries(id),
  effective_on    date not null,         -- when it happened economically
  posted_at       timestamptz not null default now(),  -- when we recorded it
  idempotency_key text not null unique
);

create table postings (
  entry_id bigint not null references journal_entries(id),
  account  text   not null,              -- e.g. 'liability:producer_payable:42'
  amount   bigint not null check (amount <> 0)  -- cents; debit +, credit -
);

Add a deferred constraint trigger that rejects any entry whose postings don't sum to zero. The unique on reverses_id means an entry can be reversed only once. Plan versions are rows that never change: a mid-quarter split change creates a new version. A reversal copies the original lines with the signs flipped. It never recomputes them under the current plan, which removes the first drift mechanism above entirely.

What accounts does a commission back office need?

The rule that matters most is one named account per party and per purpose. Formance shows how giving each fee type its own account makes misconfiguration visible as a residual instead of letting it vanish into a netted total. Splits work the same way. Each participant in the waterfall (producer, team lead, referral partner, house) gets its own line in the entry.

AccountTypeWhat lands thereDrains to zero?
receivable:carrier:{id} or receivable:closing:{id}AssetCommission owed by a carrier or from a closingYes, when paid
revenue:commission_grossRevenueGross commission earned by the agency or brokerageNo
expense:producer_compExpenseTotal share paid out to peopleNo
liability:producer_payable:{id}LiabilityVested and payable nowAt each payout
liability:producer_reserve:{id}LiabilityHeld back until the chargeback window passesMoves to payable at vesting
asset:producer_recoverable:{id}AssetClawbacks larger than what is payableOffset against future earnings
clearing:carrier_statement:{id}ClearingStatement lines awaiting matchYes, on match
clearing:payout_batch:{id}ClearingACH batch in flightYes, at bank settlement

Here's an illustrative sale: a $1,000 commission, 65% to the producer with 30% of that held in reserve, 10% to a team lead, and the house keeping the rest as the residual in revenue.

Entry 9001  source=sale:P-778  plan=v14  effective_on=2026-03-02
  receivable:carrier:c12          +100000
  revenue:commission_gross        -100000
  expense:producer_comp            +75000
  liability:producer_payable:42    -45500
  liability:producer_reserve:42    -19500
  liability:producer_payable:17    -10000
                                   ------
                                        0

When the carrier charges the policy back in month 5, entry 9417 sets reverses_id = 9001 and negates all six lines. If producer 42 has already been paid the $455, their payable account goes negative. A separate, explicit entry then moves that amount into asset:producer_recoverable:42. Keeping the reversal pure and the reclassification separate means each entry has exactly one purpose, and either one can be audited without the other.

How should a clawback months after payout flow through?

In insurance, a clawback happens when an agent received commission they didn't or shouldn't have earned, most often because the policyholder canceled or stopped paying. Schedules are graduated. For annuities, a death in the first 6 months typically means a 100% chargeback, months 7 to 12 typically 50%, and after one year the agent is generally clear. Your reserve release schedule should mirror each carrier's chargeback schedule as plan version data, not hardcoded logic.

The vesting clock also needs the right start date. Consumers get at least a 15-day free-look period, extended to 20 or 30 days in some states, and it generally runs from contract delivery, not application or premium date. If your schema only stores the application date, you can't compute when a reserve is safe to release.

Carriers usually recover through offsets. In PIA's example, a $1,000 chargeback against $2,500 of new commissions becomes a $1,500 payment. Post that statement as its full lines (new commissions, chargeback reversals) through clearing:carrier_statement, and let the clearing account drain against the $1,500 deposit. Don't post a single net $1,500 entry. Apply the same discipline on the producer side: offsetting a recoverable against next month's payable is its own entry, and it appears as a line on the producer's statement.

Termination doesn't end the data stream. Louisiana law requires insurers in certain cases to keep paying commissions on renewals for up to 36 months after an agency contract ends, so don't make ingestion depend on an "active contract" flag.

Real estate has its own pressure. The Louisiana Real Estate Commission will not become involved in disputes over entitlement to or payment of commissions, so a Louisiana brokerage has to settle split disputes internally or in court, and its ledger becomes the evidence. Escrow timing is also defined: 30 days to release with a mutual written agreement, 60 days when disputed. I didn't find a Louisiana rule that specifically governs clawing back real-estate commissions after closing. Treat that as a question for your counsel and encode the policy they give you as plan data.

How do you handle late adjustments after a period closes?

Store two times on every entry: effective_on (when it happened) and posted_at (when you recorded it). Closing a period records a timestamp. It never locks rows, because rows never change anyway.

That gives you two reproducible views. As reported filters posted_at <= close_at and regenerates the exact statement you sent. As of now includes everything. The difference between them is the prior-period adjustments, and they belong as labeled lines on the current statement, each referencing the original entry.

-- Producer 42 balance exactly as reported at the March close
select sum(p.amount)
from postings p join journal_entries e on e.id = p.entry_id
where p.account = 'liability:producer_payable:42'
  and e.effective_on <= date '2026-03-31'
  and e.posted_at   <= (select closed_at from periods where id = '2026-03');

There's one trade-off to settle with finance up front. If you set effective_on to the original date, policy-year and production reports stay accurate, but closed-period totals move in the as-of-now view. If you set it to today, closed totals never move, but you lose that attribution. My default is to keep the original date and use the as-reported view for anything that has already been paid.

Reconciliation checks that catch drift early

Run these on every batch and on a nightly schedule, and alert on any non-empty result:

-- Unbalanced entries (expect zero rows)
select entry_id, sum(amount) from postings group by entry_id having sum(amount) <> 0;

-- Global trial balance (expect 0)
select sum(amount) from postings;

-- Clearing accounts that never drained (expect zero rows past the settlement window)
select account, sum(amount) from postings
where account like 'clearing:%' group by account having sum(amount) <> 0;

Where to start

  1. Inventory every way money moves today: sales, refunds, carrier statements, closings, manual credits. Each one becomes a source_type.
  2. Write the chart of accounts and the plan-version format before writing any code, and get finance to sign off on both.
  3. Enforce balance and immutability in the database first. Application rules come second.
  4. Backfill by replaying historical source events through the engine, then compare the result with your current balances. The differences are your existing drift, and finance should approve how each one is resolved.
  5. If you need someone accountable for that architecture without hiring a full-time executive, here's what a fractional CTO does.

If you're specifying or rebuilding a commission back office and want a second set of eyes, email hello@josephleporati.com.

Common questions

How should split rounding be handled so entries still sum to zero?

Compute every party's share in integer cents. Then assign the leftover cent or cents to one designated residual account, usually house revenue. Record which account absorbs rounding in the plan version so the result is deterministic and reproducible. Never round each line on its own and accept a one-cent imbalance, because the zero-sum constraint would reject the entry, and it should.

Can I cache balances for performance in a double-entry commission ledger?

Yes, as long as the postings stay the source of truth. Keep a balance snapshot per account with the last entry id it includes. Compute current balances as snapshot plus later postings, and run a nightly job that compares snapshots with full recomputation. If they disagree, throw the cache away and rebuild it. Never write corrections into it.

How do I migrate from a single-balance commission system without losing history?

Don't import current balances as opening entries and stop there. Replay historical sales, refunds and payouts through the new engine using the plan versions that applied at the time. Compare the derived balances with the old column. Each gap is real drift that finance needs to resolve, and each resolution should be posted as a documented adjustment entry.

Should producers see reserves and recoverables on their statements?

Yes. Showing payable, reserve and recoverable separately, with each chargeback or offset as its own line tied to a policy or closing, cuts down on disputes, because producers can see exactly why a check is smaller. It also makes a brokerage's or agency's records much easier to defend when a split dispute ends up outside the company.

Sources

  1. Double-Entry Accounting Guide for Software Engineers, Part One radzserg.medium.com
  2. Double-Entry Accounting Guide for Software Engineers, Part Two radzserg.medium.com
  3. Demystifying Double Entry Accounting Algorithm medium.com
  4. Building a Double-Entry Payment System in Elixir zarar.dev
  5. Double-Entry Accounting for Engineers - Formance formance.com
  6. Insurance Commission Clawbacks - AgentSync agentsync.io
  7. An Independent Insurance Agent's Ultimate Guide to Chargebacks blog.newhorizonsmktg.com
  8. Annuity Suitability and Best Interest Standard: NAIC Rules - LegalClarity legalclarity.org
  9. The Annuity Free Look Period annuityrateshq.com
  10. When carriers request commissions back - PIA blog.pia.org
  11. Louisiana Laws - RS 22:883 legis.la.gov
  12. Louisiana Real Estate Commission - License Law lrec.gov
  13. Louisiana Real Estate Commission - FAQs lrec.gov

commissions ledger design payments back office insurance