Files
terminalShop-old/docs/checkout.md
marcusDenslow d5cb4259f5 fix: prevent charge-without-order by creating order before charging
When paying the application charges the card and then sends a payment
record to the database after, in a situation where something goes wrong
between those two steps, we could potentially end up where a users card
was charged but no record on what is was charged for. The fix is to
re-order ConvertCart to write a pending order record before calling
Stripe, this way a DB record always exists if anything fails
mid-operation. This implementation uses an idempotency key keyed to the
order ID to prevent double-charges on retry. Wraps the post-charge DB
updates in a transaction so marking paid andd clearing the cart are
atomic. Adds OrderStatusFailed and a CRITICAL log for the unrecoverable
case. Includes a checkout flow doc with test cards and reconcilation
steps.
2026-03-25 20:13:51 +01:00

84 lines
3.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Checkout FLow
## The Problemo
Charging a card and recording the order are two different operations accross
two seperate systems (Stripe and the database). If anything fails betweenj
them, you can end up in a state where a customer was charged but no
order exists in the database.
## How It Works
The ´ConvertCart´ handler (´api/handlers/cart.go´) avoids this by following
a specific order of operations:
1. **Create the order first** written to the DB with ´status = "pending"´
and no Stripe ID yet. This means there is always a DB record before any
money moves.
+2. **Charge Stripe** — using an idempotency key of `order-{id}`. If the
request is retried for any reason, Stripe will return the original result
instead of charging twice.
+3. **On charge failure** — the order is marked `status = "failed"` and the
error is returned to the client. The cart is left untouched.
+4. **On charge success** — a single DB transaction atomically:
- Sets `status = "paid"` and writes the Stripe PaymentIntent ID
- Deletes the cart items
- Clears the address and card from the cart
+5. **If the transaction fails after a successful charge** — this is the
worst case. The charge went through but the DB update failed. A
`[CRITICAL]` log line is emitted with the order ID and PaymentIntent ID
for manual reconciliation. See below.
## Reconciliation
If a `[CRITICAL]` log appears, find the order and patch it manually:
```sql
UPDATE orders
SET status = 'paid', stripe_payment_id = '<pi_id_from_log>'
WHERE id = <order_id_from_log>;
```
Then verify the PaymentIntent status in the Stripe dashboard matches.
A pending order with no ´stripe_payment_id´ after more than a few minutes
indicates a failed charge (status should be ´failed´). A pending order
with a ´stripe_payment_id´ indicates a reconciliation gap.
## Testing
Use Stripe´s test mode with the following card numbers (any future expiry,
any CVC, any ZIP):
the folling table is constructed by ["Card number"]["Behaviour"]
4242 4242 4242 4242 - Success
4000 0000 0000 0002 - Generic decline
4000 0000 0000 9995 - Insufficient funds
4000 0000 0000 0069 - Expired card
4000 0000 0000 0127 - Incorrect CVC
### What to verify on success
- Response is ´200´ with an order object
- Order in DB has ´status = "paid"´ and a non-empty ´stripe_payment_id´
- Cart items for that user are deleted
- Cart ´address_id´ and ´card_id´ are cleared
### What to verify on decline
- Response is `402` with `CARD_ERROR`
- Order in DB has `status = "failed"`
- Cart items are untouched
## Known Limitations
A server crash (not an error — a literal process kill) between the Stripe
charge succeeding and the DB transaction committing will leave the order as
`pending` with no `stripe_payment_id`. The `[CRITICAL]` log won't fire
because the process is dead. The fix is a reconciliation job that
periodically queries Stripe for PaymentIntents and cross-checks them
against orders — not implemented yet.