A shopper who wants their money back has no way to ask for it: a refund exists only as a button an admin presses, so the customer's half of the money trail is a dead end.
/orders/:number after payment is "Thank you", the lines, the totals and the address — nothing else. POST /admin/orders/:number/refund (src/admin.ts) is the only door, and orders.refund() already does the hard part: it checks what is left refundable inside a transaction, goes to the gateway first and the ledger second. What is missing is the ask, and the record of the shop answering it.
Done looks like
[ ] refund_request in src/db.ts: order, when, the reason as typed, state (open, approved, declined), when it was decided and the note the shop wrote. One open request per order, enforced in SQL, not in a handler.
[ ] POST /orders/:number/refund-request takes the order's access token in the form, the way POST /pay/:number/mock already does — same-origin and CSRF like every other POST, the token compared in constant time through the existing openOrder(ctx, "form").
[ ] It refuses, writing nothing, when the order is not paid or partially_refunded, when there is nothing left refundable, or when a request is already open — each in its own plain words on the order page. The reason is capped at the boundary (500 characters), escaped on every page it appears on, and rate limited on a bucket of its own in src/ratelimit.ts.
[ ] The order page shows the form while asking is possible and the request's state in plain words once it is made: asked on this day, approved with the amount, or declined with the shop's note.
[ ] /admin/orders marks the orders with an open request and /admin/orders/:number shows the request beside the payments, with the Refund button that is already there and a Decline that closes the request with a note. Approving is not a second refund path: the existing refund closes any open request in the same transaction and writes one order_event either way.
[ ] The terminal keeps up: the orders tab in src/desk.ts shows that a request is open on the row, and the r key says so before it refunds.
[ ] Tests over HTTP that fail before the change: a request written once and the second refused with the first still open; a request on an unpaid order refused with nothing written; an admin declining, and the buyer's page saying so; an admin refunding, the request closed as approved, and orders.refund still refusing to go past what is refundable.
Two ways to close a request — the admin's refund and the admin's decline — can race. Close it inside the transaction that already exists rather than after it.
The reason is text a stranger wrote that an admin will read: escape it everywhere, including the terminal, and never put it in a log line or an order event's note where it would be read as the shop's own words.
#52 adds another shopper-initiated POST on an order held by token. Whichever lands first should leave one helper behind for the other, not two.
Nothing here should refund automatically. The shop answers; it does not agree.
Suggested: ledger, with sentinel on the token check and the rate limit.
A shopper who wants their money back has no way to ask for it: a refund exists only as a button an admin presses, so the customer's half of the money trail is a dead end.
/orders/:numberafter payment is "Thank you", the lines, the totals and the address — nothing else.POST /admin/orders/:number/refund(src/admin.ts) is the only door, andorders.refund()already does the hard part: it checks what is left refundable inside a transaction, goes to the gateway first and the ledger second. What is missing is the ask, and the record of the shop answering it.Done looks like
refund_requestinsrc/db.ts: order, when, the reason as typed, state (open,approved,declined), when it was decided and the note the shop wrote. One open request per order, enforced in SQL, not in a handler.POST /orders/:number/refund-requesttakes the order's access token in the form, the wayPOST /pay/:number/mockalready does — same-origin and CSRF like every other POST, the token compared in constant time through the existingopenOrder(ctx, "form").paidorpartially_refunded, when there is nothing left refundable, or when a request is already open — each in its own plain words on the order page. The reason is capped at the boundary (500 characters), escaped on every page it appears on, and rate limited on a bucket of its own insrc/ratelimit.ts./admin/ordersmarks the orders with an open request and/admin/orders/:numbershows the request beside the payments, with the Refund button that is already there and a Decline that closes the request with a note. Approving is not a second refund path: the existing refund closes any open request in the same transaction and writes oneorder_eventeither way.src/desk.tsshows that a request is open on the row, and therkey says so before it refunds.orders.refundstill refusing to go past what is refundable.Files I expect to change
src/db.ts,src/orders.ts,src/shop.ts,src/routes.ts,src/pages/checkout.ts,src/admin.ts,src/pages/admin.ts,src/desk.ts,src/ratelimit.ts,src/server.test.ts,src/units.test.ts,README.md.Risks
Suggested: ledger, with sentinel on the token check and the rate limit.