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

A refund from the terminal moves the money: a Stripe order is refunded at Stripe, or not at all #60

open not picked up yet opened this
opened this

A refund typed in the app's terminal writes a refund row with a made-up gateway reference and never asks Stripe to move the money, so the books say refunded and the customer is not.

What the code does today. src/admin.ts:263-289 refunds properly: for an order whose gateway is stripe and whose payment_ref starts with pi_ it calls gateway.refundCharge() first, and only writes the ledger row when the gateway said yes — refund-failed tells the admin nothing was recorded. refundRest() in src/tty.ts:242-256 skips all of it: it calls orders.refund() directly with gateway_ref: \tty_refund_${crypto.randomUUID()…}\`, for every order, whatever gateway took the money. The row reconciles against the order and the terminal says "refunded and restocked", while Stripe has never heard of it. src/tty.ts imports chosen from src/gateway.ts` and nothing else from it.

The terminal is the one place in this app where a person can move real money without the gateway being asked. AGENTS.md says what a person can do on the web page they should be able to do here too; the other half of that is that it has to mean the same thing.

Done looks like

  • [ ] The gateway call and the ledger write live in one place both callers use — a function in src/orders.ts (or a small src/refunds.ts if that reads better) that takes an order, an amount, a reason and a restock flag, asks the gateway when the order was paid through it, and writes the row only on success. src/admin.ts and src/tty.ts both call it, and neither builds a gateway_ref of its own for a Stripe order.
  • [ ] A refund of a Stripe order from the terminal records Stripe's own refund id as payment.gateway_ref; a refund of a mock-gateway order keeps today's behaviour and today's reference shape.
  • [ ] When the gateway refuses, nothing is written: no payment row, no stock_entry, no order_event, the order's status does not move, and the terminal says so in the yellow it already uses for a refusal, naming the order.
  • [ ] The terminal stays a terminal: the refund is awaited without the key handler blocking the draw loop, and a slow or unreachable gateway ends in a message rather than a hang — give the call the same bounded timeout src/gateway.ts already uses.
  • [ ] The idempotency key keeps its meaning across both paths: refunding twice from the terminal, or once from the terminal and once from /admin, still charges the customer's card back exactly once.
  • [ ] Tests that fail before the change: with STRIPE_SECRET_KEY set against the fake Stripe from #14, a terminal refund of a Stripe order reaches the fake and stores its id; the fake refusing leaves the database exactly as it was; a mock-gateway order still refunds without any HTTP at all.
  • [ ] The README's terminal section says that r moves real money in test mode, not just a row.

Files I expect to change. src/tty.ts, src/admin.ts, src/orders.ts (or a new src/refunds.ts), src/gateway.ts if the refund call needs a seam, src/stripe.test.ts and src/units.test.ts, README.md.

Risks. The terminal's key handler is synchronous today, so making one action asynchronous is where a bug will hide — a second r while the first is in flight must not refund twice. orders.refund() is used by the web path and by tests; changing its signature ripples, so prefer adding the gateway-aware function over rewriting the ledger writer. Any refactor here touches money: keep the existing transaction exactly as it is and change only who calls it.

Suggested crew. ledger — it is the money path and the ledger's own invariants. Worth sentinel reading the pull for the failure branch: the rule is that a refusal writes nothing.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs