Refund Ledger
Charges and refunds in integer minor units: track what is still refundable rather than what the charge was, refuse an over-refund without leaving a mark, and make a retry replay on its key instead of on its amount.
- Pattern
- Check every refund against what is left, not against what the charge was
- Time
- O(1) per charge and per refund, O(charges) for balance
- Space
- O(charges + refunds + keys)
Taking money is the easy half. Giving it back is where the bugs are, because a refund is a write that has to be checked against the history of every other refund on the same charge, and because the network will send you the same one twice. Two numbers decide whether this implementation is correct, and neither of them is the charge amount.
The recipe
Say before you type: "A refund is checked against what is left, not against what the charge was. And a retry is a retry because of its key, never because of its amount."
The objects
type Charge = {
id: string;
object: "charge";
amount: number; // integer minor units, positive
amount_refunded: number; // the sum of every refund against this charge
currency: string;
refunded: boolean; // true only when amount_refunded reaches amount
};
type Refund = {
id: string;
object: "refund";
charge: string; // the charge id this refund is against
amount: number; // integer minor units, positive
currency: string;
status: "succeeded";
};
Everything is an integer count of minor units. No amount is ever divided by 100 until something is printed, and the guard on the way in says so:
if (!Number.isSafeInteger(amount) || amount <= 0) {
throw new RangeError("amount must be a positive integer number of minor units");
}
Reject zero along with the floats. A refund of nothing is not a harmless no-op, it is a caller who computed an amount wrong, and swallowing it hides the bug that produced it.
Two smaller decisions worth making out loud. First, refunded is derived, not stored: it is amount_refunded === amount, computed when you build the object. A stored boolean is a second copy of the same fact and the two drift the first time somebody updates one of them. Second, the refund takes the charge's currency, and the caller never gets to name one. Two fields that must agree should not both be inputs.
What you hand back is a snapshot
private snapshot(row: ChargeRow): Charge {
return {
id: row.id,
object: "charge",
amount: row.amount,
amount_refunded: row.refundedAmount,
currency: row.currency,
refunded: row.refundedAmount === row.amount,
};
}
Return your internal row and the caller now holds a handle on the ledger. Anyone who logs it, caches it, or passes it through a function that sets a field has edited your books from the outside. Copy on the way out. It is one line and it removes a class of bug that is close to impossible to trace once it happens.
The same applies to a list. refunds(chargeId) returns stored.map((refund) => ({ ...refund })), not stored.
The remaining refundable amount
This is the whole problem, and it fits on one line:
const remaining = row.amount - row.refundedAmount;
The plausible wrong version checks the request against row.amount. It looks right, it passes the obvious test, and it hands back more money than you took:
charge 10000
refund 6000 -> 6000 <= 10000, allowed
refund 6000 -> 6000 <= 10000, allowed
you have now refunded 12000 on a 10000 sale
Each refund was smaller than the charge. Their sum was not. The rule is about the group, not about the request, which means the only number you may compare against is the one that already accounts for everything that came before.
if (remaining === 0 || amount > remaining) {
throw new Error("refund amount exceeds the refundable balance: " + row.id);
}
Exactly remaining is allowed. It is the last legal refund, not the first illegal one, so the comparison is > and not >=. Say that boundary out loud so the interviewer knows you picked it rather than typed it.
An omitted amount means the remainder
When the caller does not name an amount, refund what is still refundable:
const amount = params.amount === undefined ? remaining : params.amount;
Not row.amount. A charge of 10000 that has already given back 4000 answers "refund the rest" with 6000. Reading the omitted form as the original total either over-refunds or, if your balance check is right, gets refused by your own guard on a request that was perfectly legal.
It is also the reason the guard above has two arms rather than one, and this is the place that trips people. On a fully refunded charge remaining is 0, so the omitted form resolves amount to 0, and 0 > 0 is false. Write the guard as if (amount > remaining) alone and a settled charge does not throw at all: it mints a zero-value refund, re_8 for nothing, against a charge that has already given back every cent. The remaining === 0 || disjunct is what refuses it, and that one arm covers both shapes of the request at once, the explicit amount because it is larger than the 0 remaining and the omitted one because there is nothing left to give.
Validate first, write second
// everything above can throw, and nothing above has written
this.refundSeq += 1;
const refund = { id: "re_" + this.refundSeq, /* ... */ };
row.refundedAmount += amount;
this.refundsFor.get(row.id).push(refund);
Order is the whole point. Put every check above the first mutation and a refusal is automatically atomic: nothing rolled back because nothing was written. Bump the counter first and a refused refund still moved amount_refunded. Mint the id first and a refused refund quietly burned re_7, so the next real refund is re_8 and the ledger has a hole in it that someone will eventually have to explain.
This is worth stating as a rule rather than as a happy accident, because it is the version that survives the next person editing the method: write only after the last thing that can throw.
The key, not the amount
Now the retry. The network resent the request, the customer double clicked, the nightly job ran twice. The caller hands you a key:
refund({ charge: "ch_1", amount: 2000 }, { idempotencyKey: "k1" })
The tempting shortcut is to notice that a duplicate refund has the same charge and the same amount as one you already have, and to deduplicate on that pair. It even passes the retry test. It is also wrong in the direction that costs a customer money:
refund 2000 on ch_1, key k1 -> re_1
refund 2000 on ch_1, key k2 -> must be re_2, a second real refund
Two refunds of the same amount against one charge is an ordinary Tuesday. Somebody returned two identical items. A ledger that collapses them into one has kept 2000 of a customer's money and left no record that it did. The amount is what the caller wants. The key is the only thing that says whether they have already asked.
Which gives you the rest of the rules at once:
- Same key, same request: replay. Return the stored refund, same id and same amount, and change nothing.
- Different key: a real second refund, with its own id, and
amount_refundedgoes up again. - No key at all: no deduplication. Two identical keyless calls are two refunds. The caller did not ask you to remember anything, so do not remember anything.
A key remembers the request that made it, so you store a fingerprint alongside the refund and refuse a reuse that does not match:
if (seen.fingerprint !== fingerprint) {
throw new Error("idempotency key reuse with different parameters: " + key);
}
return { ...seen.refund };
Keep the fingerprint at the level of what was sent: the charge id, and the amount field as it arrived, with omitted counting as its own value. "Refund the rest" and "refund 5000" are different asks even on a day when they happen to mean the same money.
And the keys belong to the ledger, not to a charge. Namespace them per charge and the same key routed to the wrong charge silently becomes a second refund instead of the loud reuse error it should be.
Read the key before you read the balance
The subtle one, and the reason to put the key lookup at the very top of the method:
// key first
if (key !== null) {
const seen = this.byKey.get(key);
if (seen !== undefined) { /* replay or reuse error */ }
}
// only now does the charge matter
const row = this.mustFind(params.charge);
const remaining = row.amount - row.refundedAmount;
Work out the balance first and you break the exact case idempotency exists for. A key that refunded the whole charge leaves remaining === 0. When that request arrives a second time, a balance-first implementation computes zero remaining and throws "exceeds the refundable balance" at a caller who is simply retrying something that already succeeded. The retry now looks like a failure, the caller retries again, and your on-call gets paged for a refund that was processed correctly the first time.
A replay is not a write. It takes no refund id, it adds nothing to the list, and it moves no money.
The mirror image is just as important: a refused request binds nothing. Store the key before you validate and a refund your own guard rejected has permanently consumed that key, so the corrected retry gets a stale replay or a reuse error instead of doing the work. Bind the key in the same place you do every other write, after the last check.
What the interviewer is listening for
- Do you know what a refund is checked against? The remaining refundable amount. Anyone can write
amount > charge.amountand it is wrong in the expensive direction. - Can you make a rejection leave no trace? Ordering, not rollback. The answer is "every check is above the first write."
- Do you know what an idempotency key is for? It identifies a request, not a result. The moment you deduplicate on the contents of the refund, you have decided that two customers cannot be owed the same amount, and they can.