Public: anyone can read this; the crew can change it.
A payment short of what the order owes does not mark it paid #37
Check · Review · Land · Live done — Landed and live in production.
`orders.settleFromGateway(order, { gateway, ref, amount_cents })` is now the one place a gateway's answer becomes an order's state — the Stripe return, the Stripe webhook and the mock gateway all call it, so there is one rule about what an amount means instead of three. The money that moved is written as a `payment` row whatever the amount arrives as; only `amount_cents >= dueCents(order)` marks t…
shipped production is serving it deploy ab962da
main is merged into this branch. The only conflict was src/units.test.ts, where both sides changed the same two places, and both sides are kept.
At the import block, the file now takes its own database from freshData("units") and keeps dbModule for main's isolation assertions, alongside this branch's orders import. At the end of the file, all three new tests stand: this branch's a gateway's amount is compared to what the order owes before anything is called paid, and main's a test file's database is its own, from the factory and two databases from the factory know nothing of each other. The two sides agree without either being softened — the orders this branch inserts by hand carry access_token = 'token', which is exactly what main's isolation test counts as hand-written rather than pushed through checkout.
Nothing else conflicted, and the auto-merges in src/shop.ts and src/pages/checkout.ts are both intents side by side: main's tax.HOME_COUNTRY as the checkout form's default country, and this branch's alreadyCharged guard on the three pay routes plus the Shortfall notice on the confirmation page. No prepared statement is held at module scope anywhere, so nothing breaks across main's useDatabase handle swap.
bun test is green: 91 pass, 0 fail across 5 files. The app boots and / answers in about 9 ms cold. Worth a look in the preview: pay an order short through the mock gateway and confirm the order page says it is short, the pay page will not offer the gateway a second time, and the checkout form still defaults its country from the tax table.
🤖 Generated with Claude Code
orders.settleFromGateway(order, { gateway, ref, amount_cents })is now the one place a gateway's answer becomes an order's state — the Stripe return, the Stripe webhook and the mock gateway all call it, so there is one rule about what an amount means instead of three. The money that moved is written as apaymentrow whatever the amount arrives as; onlyamount_cents >= dueCents(order)marks the orderpaid, exactly as before. Less leaves the orderpending_payment, keeps the gateway's handle so the charge can be found at Stripe, and writes ashort-paymentevent naming what was asked for and what arrived./orders/:numberthen says the payment was short and that the shop is looking at it, in place of the thank-you and the "finish paying" link;/admin/orders/:numbershows "Short by $X: $Y taken against $Z due" above the payments table, and the terminal's order detail shows the same line.Two judgement calls worth a look. The payment step is now closed for an order that already has a charge (
/payand both POSTs redirect to the order): the charge row's idempotency key is the order's, so a second attempt could take money the ledger could not record — that seemed worse than making a shopper wait for a person. And a gateway that names no amount (amount_totalnull, which used to fall back to "assume it paid in full") is treated as nothing arriving: no payment row, since the ledger has no row for no money, and one event, guarded so a repeated delivery does not repeat it. Nothing refunds or cancels automatically.To try it:
bun testcovers it end to end —src/stripe.test.tshas a short return, a short webhook (with the admin page asserted) and the exact-amount path still settling once and only once, andsrc/units.test.tsexercisessettleFromGatewaydirectly. In the preview, the mock gateway always pays what is due, so the short path is reachable through the Stripe fake in the tests rather than by clicking.🤖 Generated with Claude Code
Closes #31