In shop.payMock every decision that is not approve — including a missing field, a truncated body or a repeated submit — writes orders.note(order.id, "declined", "the mock gateway was asked to decline") and answers 402. I hit it in a walk by posting the pay form without the button's value: the order's history now carries a decline the shopper never chose, and the admin's order page shows it as a payment event. A decline is a thing a person pressed; an unreadable form is a form error.
Done looks like
[ ] decision is validated at the boundary: approve and decline are the only values, and anything else re-renders the pay page with "That payment form went stale. Here it is again." (the checkout wording), status 400, writing no row.
[ ] decision=decline still writes the declined note with its existing wording and still answers 402 on the pay page.
[ ] Tests in src/server.test.ts: posting the pay form with no decision, with decision=nonsense, and with an over-long value leaves order_event unchanged and the order still pending_payment; decision=decline writes exactly one declined row; decision=approve settles as it does today.
[ ] The declined note's text stays a fact about the gateway, not about the form.
Filessrc/shop.ts (payMock), src/pages/checkout.ts if the error wording needs a home, src/server.test.ts.
Risks The pay page's 402 is what the tests and the mock flow expect for a real decline — do not change that path. Keep the rate limit ahead of the validation so a flood of malformed posts is still capped. Do not turn this into a second settlement path.
In
shop.payMockeverydecisionthat is notapprove— including a missing field, a truncated body or a repeated submit — writesorders.note(order.id, "declined", "the mock gateway was asked to decline")and answers 402. I hit it in a walk by posting the pay form without the button's value: the order's history now carries a decline the shopper never chose, and the admin's order page shows it as a payment event. A decline is a thing a person pressed; an unreadable form is a form error.Done looks like
decisionis validated at the boundary:approveanddeclineare the only values, and anything else re-renders the pay page with "That payment form went stale. Here it is again." (the checkout wording), status 400, writing no row.decision=declinestill writes thedeclinednote with its existing wording and still answers 402 on the pay page.src/server.test.ts: posting the pay form with nodecision, withdecision=nonsense, and with an over-long value leavesorder_eventunchanged and the order stillpending_payment;decision=declinewrites exactly onedeclinedrow;decision=approvesettles as it does today.Files
src/shop.ts(payMock),src/pages/checkout.tsif the error wording needs a home,src/server.test.ts.Risks The pay page's 402 is what the tests and the mock flow expect for a real decline — do not change that path. Keep the rate limit ahead of the validation so a flood of malformed posts is still capped. Do not turn this into a second settlement path.
Suggested ledger.