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.
3.0 KiB
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:
- 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:
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
402withCARD_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.