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

A shopper can cancel their own unpaid order, and the stock goes back #52

open not picked up yet opened this
opened this

A shopper who changes their mind at the payment page can cancel their own unpaid order, and the stock goes straight back on the shelf.

Why. orders.cancel(order, reason) already puts the stock back, gives the discount's use back and leaves rows for all of it — but the only way to reach it is POST /admin/orders/:number/cancel. A shopper standing on /pay/BZ-… with second thoughts has one option: close the tab, and leave the last unit of a one-unit shop held by an order that will never be paid. #15 sweeps those up eventually; the shopper who is still looking at the page should not have to wait for a sweep, and the shop should not have to guess that they are gone.

Done looks like

  • [ ] POST /pay/:number/cancel — same-origin, CSRF-checked, rate limited on the payment bucket — opens the order the way every other order route does (its own access token, or an admin), and cancels it through the same cancel() an admin uses. No second path to the same state.
  • [ ] Only an order that is pending_payment can be cancelled this way; anything else answers with the order page and a sentence, never a 500 and never a second cancellation.
  • [ ] The reason on the row says the shopper did it ("cancelled by the shopper") so the ledger distinguishes it from an admin's cancel and from #15's sweep; with #12 merged it carries the actor as well.
  • [ ] The pay page offers it plainly — a button that says what it does, next to the payment button and not competing with it — and the confirmation page afterwards says the order is cancelled, that nothing was charged, and links back to the shop.
  • [ ] A cancel that races a payment does not lose money: if the gateway has already taken it, the order is not cancelled and the shopper is told to look at the order page, which is the truth of what happened.
  • [ ] The cart is not resurrected behind the shopper's back; if the items are put back in the cart, say so on the page, and do it in one place.
  • [ ] Tests: the happy cancel (status, stock back, discount use back, rows written), a cancel without the token (404, nothing changed), a cancel of a paid order (refused, still paid), a double cancel (idempotent), and the CSRF-less POST refused.
  • [ ] The README says a shopper can cancel before paying, and what that leaves behind.

Files I expect to change: src/routes.ts, src/shop.ts, src/orders.ts, src/pages/checkout.ts, src/server.test.ts, README.md.

Risks. This is a state change reachable by anyone holding a link — the token check must be the same constant-time comparison openOrder already does, and the route must not accept the token in a query string on a POST. Cancelling an order that a webhook is settling at the same moment is the case to get right: read the order's status inside the transaction, not before it. Giving stock back twice is worse than not giving it back at all.

Waits for: #15 — it introduces the expiry path through cancel(), and the shopper's button should walk the path that exists rather than a second one written first.

Whose brief fits: ledger — one more way into a state change that is already a row, with the race written down.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs