Notes · October 2026
A refund larger than the order: €500 back on a €29.99 order.
A knitwear shop was built with Claude Code from 17 messages in plain words. It has a Returns page: the owner approves a return, the pieces come back, and the owner refunds them. Card payments go back to the card through Stripe. For bank transfers, the owner sends the money from the bank and marks the return as refunded, typing the amount that was sent.
That amount was checked for one thing only: that it was a number.
What happened
A customer returned one pair of socks from an order of €29.99. The owner typed 500 in the amount field and pressed "Mark as refunded".
- It was accepted. The order showed €500 refunded.
- The month's refunds on the dashboard and in the reports grew by €500.
- The customer was emailed "Your refund of €500 is on its way".
- A recorded refund could not be corrected afterwards.
Nobody needs to type 500 on purpose. A slipped digit does the same, and so does a decimal comma in a field that quietly drops it: 23,99 typed by someone used to European numbers becomes 2399.
"But Stripe won't refund more than was paid"
That is true, and it is why this hole is easy to miss. Stripe refuses a refund larger than what is left on the charge, so the card itself is safe. Everything else is not:
- refunds recorded by hand: bank transfers, cash, a refund made in the Stripe dashboard and then marked in the app;
- the order record, the dashboard and the monthly reports;
- the books an accountant builds from those reports;
- the email that tells the customer how much is coming.
Those are exactly the places the owner and the accountant read. In this shop the card path went through Stripe and was limited by it; the bank-transfer path was only recorded, and it had no limit at all.
Two more ways into the same hole
- Two partial returns from one order. An order of two pieces came back in two returns. For each return, the page suggested the piece's price plus the delivery charge. Accept both suggestions and delivery is paid back twice: the order ends up refunded more than the customer paid, with no single typo anywhere.
- Two refunds at the same moment. The same form open in two tabs, or two people working the returns. A check that only lives in the page ("is this less than what is left?") passes for both, because each one reads the order before the other one writes to it.
The fix
The limit moved into the database, not just the page. A refund is refused when it is more than what is left of what the customer paid, that is, the order total minus everything already refunded. The order is locked while the check and the write happen, so two refunds of the same order go one after the other, and the second one sees the first. The page shows what is left before anything is sent.
The first version of the fix brought a problem of its own, which is common. Some orders had already been refunded too much before the limit existed. For those, nothing was "left to refund", so their open returns could not be closed at all, not even with €0, and they stayed open for good. The final version lets such a return close with a refund of nothing, and does not email the customer that a refund of €0 is on its way.
Each of these cases is now a test that runs against the database: a refund above what was paid, two partial refunds that add up to too much, two refunds at once, and a return closed with nothing left. They run with every change, so the hole cannot quietly come back.
Check your own app in five minutes
- Find every place a refund gets recorded: an admin page, a "mark as refunded" button, the handler for Stripe's
charge.refundedevent, an automation that writes refunds to a sheet. - Type an amount larger than the order. Is it refused before it is saved, or only by a message in the page?
- Make two partial refunds on one order that together come to more than was paid.
- Open the refund form in two tabs and submit both at once.
- After each try, look at three things: the order, the month's refund figure, and the email the customer got.
If any of these goes through, the limit lives only in the page, or nowhere. The place for it is the database, next to the write, under the same lock.