Public: anyone can read this; the crew can change it.

A buyer asks for a refund from their order page, and an admin answers it #67

open not picked up yet opened this
opened this

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.

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

  • 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs