DEV Community

Qihu Zhang
Qihu Zhang

Posted on

Never split the money on payment success

Here's a line of code that looks completely reasonable: the moment a buyer's card is charged, credit the vendor's share to their payout balance. It's the obvious anchor — paid_at is already a timestamp sitting right there on the order, the money genuinely did just arrive, and "bill the vendor when we got paid" reads like the simplest possible rule.

It is also, in a marketplace with real buyers and real returns, the most expensive line of code you can write, because it commits you to a promise you can't always keep: that a refund is just a database correction. It isn't. I built the settlement service for a multi-vendor platform around the opposite anchor — bill a vendor only after the order is delivered and the return window has closed — and the reason isn't academic purity about escrow. It's that the alternative turns every post-payment refund into a collections problem.

What "bill on payment" actually commits you to

Walk through what a refund has to do under a paid_at anchor. The vendor's cut was billed the instant the charge cleared. In this system, "billed" is what makes a bill eligible to be locked into a payout and paid out — so by the time a buyer returns the item three days later, that money may already have left the platform and landed in the vendor's bank account. The refund can't just flip a status column anymore. Now you're chasing the vendor for money back, netting it against whatever they're owed next cycle, or eating the loss yourself. None of those are database operations. They're accounts-receivable operations, and they involve a second party who didn't do anything wrong and may not appreciate a clawback request.

The fix people reach for is "well, don't pay out until some grace period after billing." But that's just re-deriving a return-window anchor through the back door, badly — now you have two clocks (billing and payout-eligibility) that both have to track the same real-world fact (has the buyer's refund window closed), and they can drift out of sync. Easier to have one clock.

The anchor: delivered, plus the return window

So the actual anchor is delivered_at + RETURN_WINDOW. A sub-order becomes billable only once it's been marked delivered and the configured return window has elapsed with nothing excluding it. Here's the query that decides what's billable, straight from the running service:

SELECT id, sub_order_id, order_id, vendor_id, vendor_name, gross_cents, currency, lines::text AS lines
FROM pending_settlement
WHERE bill_id IS NULL AND excluded = FALSE AND delivered_at IS NOT NULL
  AND delivered_at < now() - CAST(? AS interval)
ORDER BY id LIMIT ? FOR UPDATE SKIP LOCKED
Enter fullscreen mode Exit fullscreen mode

Three conditions have to hold before a row is even a candidate: nothing has billed it yet (bill_id IS NULL), nothing has excluded it (excluded = FALSE), and the return window has actually passed since delivery. FOR UPDATE SKIP LOCKED means a second sweeper running concurrently splits the batch instead of fighting over the same rows — the same posture the inventory service uses for its own timeout scanner, because both are the same shape of problem: a scheduled job claiming a batch of rows without a coordinator arbitrating between instances.

Until all three conditions hold, the platform is just... holding the money. Not processing it, not provisionally crediting it, just holding it. That's what escrow actually means: cash in, held, released to the vendor only once the window has run out with no refund. It isn't a side effect of the data model. It's the point of the data model.

What a refund actually does under this anchor

Here's the part that makes the whole design earn its keep. A refund inside the window doesn't reach into a payout and try to reverse it. It flips one boolean on a row that was never going to be billed anyway:

public int markExcluded(long subOrderId) {
    // bill_id IS NULL guard: a refund event racing past an already-generated bill must not
    // silently exclude it — that case is reconciliation's to flag, not this consumer's to hide.
    return jdbc.update("UPDATE pending_settlement SET excluded = TRUE "
            + "WHERE sub_order_id = ? AND bill_id IS NULL AND excluded = FALSE", subOrderId);
}
Enter fullscreen mode Exit fullscreen mode

Read the WHERE clause carefully, because the guard is doing real work, not just being defensive for its own sake. bill_id IS NULL means: only exclude a sub-order from billing if it hasn't been billed yet. If a refund event somehow arrives after the sweep already generated a bill for that sub-order — a misconfigured return window, a slow consumer, some edge case nobody designed for — this update quietly does nothing. markExcluded returns 0 rows affected, and the caller only writes the REFUND_EXCLUDED ledger entry when the row count says the exclusion actually took effect. The already-generated bill sits there, untouched, and becomes reconciliation's problem to surface — not something this consumer tries to paper over by pretending it can undo a bill that already exists. A refund's job is to prevent a debt from ever being incurred. It is explicitly not the job of stopping a debt that's already been recorded; that's a different, harder problem, and the code refuses to pretend otherwise.

That's the whole trick, and it's why "refund = exclusion, not a negative bill" is the right way to describe it rather than "refund = accounting adjustment." There's no -$40 line item anywhere. There's a sub-order that simply never crosses into settlement_bill at all. The BillingSweep that turns pending rows into bills is the only writer of that table, and it only ever reads rows where excluded = FALSE — so an excluded row isn't reversed, it's never admitted into the billing story in the first place.

The cost, stated plainly

This isn't free. The vendor gets paid one full return-window later than they would under a paid_at anchor. For a marketplace, that delay is the actual price of being the party holding the bag during the window a buyer is entitled to change their mind. It's not a performance bug to be optimized away — it's what escrow costs, and pretending otherwise (by billing early and hoping refunds stay rare) just moves the cost from "vendor payout is delayed" to "vendor payout occasionally has to be un-done," which is a strictly worse failure mode because it involves a third party's bank account.

I did consider anchoring on paid_at and clawing back via a receivable on refund. It's not a strawman — it's how a lot of systems are built, often because settlement gets bolted onto an order model after the fact and paid_at is the timestamp that's already there. I rejected it here because the fix has to happen eventually anyway once someone experiences a payout that's already spent when a refund shows up, and building the receivable-clawback machinery now, only to replace it with a return-window anchor once that incident happens, is strictly more work than building the right anchor from the start.

What this bought, in numbers

The commission calculation happens once, at billing time, and gets frozen into the bill row. Change a commission rule after that and history doesn't move — I proved this in a test that mutates a category's rate mid-run and asserts already-generated bills keep their original commission amount. Against the running stack: two sub-orders get delivered, the return window (compressed to two minutes for the demo, seven days by default) passes, and the sweep produces two bills where net_cents + commission_cents == gross_cents on every row, using at least two distinct commission-rule scopes to prove the vendor/category/default priority actually resolves differently per line. A second order gets refunded inside its window, and the gate asserts three things directly: the refunded sub-order never gets a bill, its stock comes back, and the still-active pending rows are unaffected. Nothing rolled back. Nothing clawed back. There was simply nothing to bill.


This is part of a series on building a multi-vendor commerce platform. The open-source half, stallora-cloud-starter, carries the outbox library the settlement service consumes events through. Next up: which of this platform's five services are allowed to degrade gracefully when they go down, and which one has to refuse outright — and why that split isn't about which service feels the most important.

Top comments (0)