7 min read·Run it here

Stripe: Reconcile the Ledger

Money correctness end to end: sum a day of balance transactions without losing the payout or the refunded fee, match Stripe's charges to your database gross to gross, and age the leftovers so the morning report only fires when something is actually wrong.

Pattern
Index remote charges by source id, compare gross to gross, age the unmatched
Time
O(local + remote), one retrieve per distinct dispute
Space
O(charges in the window)

Reconciliation is the most Stripe-shaped interview question there is, because it is the one where being approximately right is worthless. Every bug in it looks like a rounding error and is actually somebody's money. The good news is that the whole problem is three decisions, and each one has a single sentence you can say out loud.

The recipe

Say before you type: "Payouts are not activity. I compare gross to gross. And an unmatched row younger than the settlement window is in flight, not drift."

A balance transaction is an identity, so check it

type BalanceTransaction = {
  id: string;              // txn_...
  type: "charge" | "refund" | "adjustment" | "payout";
  amount: number;          // signed, integer minor units
  fee: number;             // Stripe's cut on THIS row
  net: number;             // amount - fee, always
  created: number;         // UNIX SECONDS
  source: string | null;   // ch_... | re_... | dp_... | po_...
};

net === amount - fee on every row. It is not a suggestion, it is the shape of the object, so check it and refuse the whole settlement when it breaks.

if (txn.net !== txn.amount - txn.fee) {
  throw new Error("inconsistent balance transaction: " + txn.id);
}

Say why you are not skipping the bad row: a ledger line that does not add up is corruption, and a settlement that quietly drops corrupt lines produces a total that reconciles against nothing. Refusing is the cheap outcome. Continuing is the expensive one.

A payout is not activity

This is the one that gets people. A payout moves money you already counted out of the Stripe balance and into a bank account. It belongs on its own line.

if (txn.type === "payout") {
  payout += txn.amount;   // negative, and it STAYS negative
  continue;
}
gross += txn.amount;
fees += txn.fee;

Fold the payout into gross and the day reads short by exactly the amount you were paid, which is the most confusing possible error because the number is real and appears somewhere else in the report. And do not Math.abs the payout: everything in this object is signed, and a payout leaves.

Read the fee off the row

A refund's amount is negative and its fee is 0, because Stripe does not return the processing fee on the original charge. So a charge of 10000 with a fee of 320, refunded in full, does not settle back to zero:

gross 0, fees 320, net -320

You are down 320 cents on a sale that never happened. That is the honest answer and it is worth saying out loud, because the interviewer is checking whether you know that a refund is not an undo.

The other half of the same rule: a fee that is returned arrives as a negative fee. That happens on a won dispute. So there is no Math.abs anywhere near a fee, and you never decide a row's fee from its type. The ledger tells you. You add.

And everything stays in integer minor units. Nothing is divided by 100 until something is printed. A round trip through dollars puts 263.99999999999994 in a report a human signs.

Match on source, and only on charge rows

Your database stores ch_.... The balance transaction's own id is txn_.... The charge it settled is in source.

const remote = new Map<string, { amount: number; created: number }>();
for (const txn of balanceTransactions) {
  if (txn.type !== "charge" || typeof txn.source !== "string") continue;
  remote.set(txn.source, { amount: txn.amount, created: txn.created });
}

Both guards are load bearing. Index every source you see and missingLocally fills with re_... and po_..., which is a reconciliation report nobody can act on and everybody learns to ignore.

Then has(), not get(), exactly as in the idempotent processor. A zero amount charge is still a charge, and if (remote.get(id)) reports it missing.

Gross to gross, and a refund is not a disagreement

if (row.amount === payment.amount) matched.push(id);
else amountMismatch.push({ id, local: payment.amount, remote: row.amount });

Two ways to get this wrong, and both of them produce a report that is 100% false positives:

  • Compare the local amount against net. net is that amount minus Stripe's fee, so every payment you have ever taken is now a mismatch.
  • Net the day's refunds off the remote side first. The charge still settled for its full gross. The refund is a separate, real, correct event. Subtract it and every partial refund becomes an alarm.

The sentence is: "the local row records what I charged, so I compare it to what Stripe says I charged. Fees and refunds are separate rows and they are both correct."

A dispute confirms a charge, it does not accuse one

A dispute is an adjustment whose source is a dispute id, and it takes the amount and a dispute fee in one row:

amount -10000, fee 1500, net -11500

Won later, a second adjustment on the same dp_ returns the amount, with the fee either kept (fee: 0) or returned (a negative fee). Read the row.

The balance transaction names the dispute, not the charge, so you resolve it:

const dispute = await client.disputes.retrieve(source);
confirmed.add(dispute.charge);

Two things to say while you type that. First, collect the distinct dp_ ids before the loop: the withdrawal and the reversal share one dispute, so a retrieve per row doubles your call count for nothing. Second, send only dp_ sources, because handing disputes.retrieve a charge id is an invalid_request_error and a retrieve per row is how you find your rate limit.

Then the point of all of it: a dispute row is proof its charge exists on Stripe. A charge that settled last month and got disputed yesterday has no charge row in today's window, so a reconciler that ignores disputes reports it as missing every single day. It is the most common false alarm this report produces.

It confirms, it never accuses. A dispute carries no gross, so dispute.amount is not the charge's amount, and a dispute for a charge you have no local row for adds nothing to missingLocally.

The settlement window is the whole report

const SETTLEMENT_WINDOW_SEC = 172800; // two days
...
if (item.windowed && nowSec - item.created < SETTLEMENT_WINDOW_SEC) continue;

A row that exists on one side only is in flight. Your payment was written a minute ago and has not settled into a balance transaction yet. Stripe's webhook is two retries deep. Neither is drift, and counting them means this report cries wolf every morning before the batch lands, which means within a week nobody opens it.

Half open, the same convention as a ttl: at exactly 172800 seconds it is drift. Say that boundary out loud so the interviewer knows you chose it.

And then the exception that proves you thought about it: the window is only for rows that exist on one side. A charge that is on both sides with two different amounts is not waiting for anything, so a mismatch is drift the moment you see it, at any age.

balanced is not drift === 0

return { balanced: oldest === null, drift, oldestUnmatchedSec: oldest };

Drift is signed, so a payment you invented (+5000) and a payment you missed (-5000) cancel to a drift of zero. That is two bugs, not zero bugs, and a balanced derived from the total says everything is fine. balanced means nothing is drifting. Count the items, not the money.

oldestUnmatchedSec is the age of the oldest drifting item, or null. In-flight rows never set it, because the whole point of the window is that they are not a finding yet.

What the interviewer is listening for

They are not checking whether you can write a Map. They are checking three things, and each is one sentence:

  • Do you know what a payout is? It is not revenue, it is revenue leaving.
  • Do you know a refund is not an undo? The fee stays.
  • Can you name the cost of your alarm? Every reconciliation report in production is tuned against false positives, and a candidate who adds a settlement window unprompted has clearly run one.