t38 walk caught the canonical TBD-V9 bug: customer redeems voucher
WALK-T38-2138 on a 50 OMR order, voucher credit is only 10 OMR, Stripe
is unconfigured in the Sovereign, Checkout returns 503 "payment processor
not configured" — but promo_codes.times_redeemed had already advanced
0→1, promo_redemptions row was inserted, and a credit_ledger grant was
written. Voucher shows "Exhausted 1/1" with no order to show for it; the
customer's one-per-customer promo is silently burned.
Root cause: store.RedeemPromoCode runs its own transaction (necessary
for the FOR UPDATE concurrency cap) and commits the three side effects
up front. The rest of the Checkout pipeline (GetCreditBalance, GetSettings,
CreditOnlyCheckout, Stripe customer + session creation) can fail without
undoing the redemption.
Fix (saga / compensating action):
- store.RollbackPromoCodeRedemption(customerID, code) — single tx that
DELETEs promo_redemptions, decrements times_redeemed (GREATEST(..,0)
underflow guarded), and DELETEs the credit_ledger redeem grant (filter
reason='promo:<code>' AND order_id IS NULL so order spend ledger rows
are not touched). Idempotent: 0-row DELETE on promo_redemptions
short-circuits the rest, so re-running a failed checkout never
double-decrements.
- handlers.Checkout tracks voucherRedeemed and calls
RollbackPromoCodeRedemption on every downstream failure: settings load,
Stripe-unconfigured 503 (the t38 walk path), CreateOrder failure,
Stripe customer rejection, Stripe session rejection, plan-price
unresolvable.
- Voucher only stays committed once (a) CreditOnlyCheckout commits the
order+spend+sub transactionally and order.placed fires, or (b) the
Stripe Checkout Session URL is handed back to the customer (canonical
abandoned-cart: credit persists on ledger for the next attempt).
Tests:
- store_test.go: three new tests cover the rollback contract — happy
path (all three side effects undone in one tx), idempotent no-op
when no redemption row exists, empty-args no-op (no DB hit).
- checkout_test.go: TestCheckout_VoucherPartialCover_StripeUnconfigured_RollsBackRedemption
is the t38 regression — full sqlmock walk asserting the rollback tx
fires before the 503 response.
bp-catalyst-platform Chart.yaml + bootstrap-kit pin bumped 1.4.214 → 1.4.215.
Co-authored-by: Claude Code (hatiyildiz) <claude@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>