Fix: first-payment checkout returns 409 for all parents (stale pending orders) #473

Closed
opened 2026-04-13 19:49:02 +00:00 by forgejo_admin · 1 comment

Type

Bug

Lineage

Standalone — discovered Apr 13 during payment pipeline investigation. Blocking all monthly revenue collection.

Repo

forgejo_admin/basketball-api

What Broke

GET /checkout/first-payment?token=X returns 409 for every parent. 100% failure rate in prod logs since at least 18:08 Apr 13. Only 1 of 7 monthly orders completed payment — the other 6 are stuck in pending status, permanently blocking those parents from retrying.

The duplicate order guard at checkout.py:347-361 treats pending the same as paid. Once a pending Order is created (by first click or by link-preview bots like facebookexternalhit/1.1 Facebot), all subsequent clicks return 409.

Prod log evidence (all 409):

GET /checkout/first-payment 409 — iPhone Safari
GET /checkout/first-payment 409 — iPhone Google App
GET /checkout/first-payment 409 — facebookexternalhit/1.1 Facebot Twitterbot/1.0

Current order state:

Order Player Status Amount
25 Querenne Nyamuhebe paid $135.00
26 Mateus Rigitano de Paula pending $150.00
27 Vince Ifote pending $165.00
28 Sarah Lédio da Silva pending $85.00
22 Test Queens Player pending $165.00
50 Jaxon Gerber pending $40.00
72 Jahzmyn Mailei pending $135.00

Repro Steps

  1. A parent receives first-payment email with checkout link
  2. Gmail/social link preview bot hits GET /checkout/first-payment?token=X — creates pending Order + Stripe session
  3. Parent clicks the link on their phone
  4. API finds existing pending Order, returns HTTP 409
  5. Parent sees error page, cannot pay
  6. Every subsequent click also returns 409

Expected Behavior

Parent clicks the payment link and reaches Stripe checkout. If a previous attempt created a stale pending order, the system should cancel it and create a fresh checkout session.

Environment

  • Cluster/namespace: prod / basketball-api
  • Service version/commit: bb7723a (main)
  • Related alerts: none — no checkout error alerting exists (see forgejo_admin/pal-e-platform#290)

File Targets

Files to modify:

  • src/basketball_api/routes/checkout.py — lines 347-361, first_payment_checkout() duplicate guard. If a pending order exists older than 30 min, cancel it and create a new checkout session. Only hard-block on paid.

Files to NOT touch:

  • src/basketball_api/routes/webhooks.py — working correctly
  • src/basketball_api/services/email.py — not the issue
  • src/basketball_api/routes/subscriptions.py — separate system

Acceptance Criteria

  • Bug no longer reproduces — parent clicks link, reaches Stripe checkout
  • Stale pending orders (>30 min) are canceled and replaced with fresh checkout session
  • Paid orders still correctly block duplicate payment
  • Fresh pending orders (<30 min) redirect to the existing Stripe session URL
  • No regression in webhook payment processing

Test Expectations

  • test_first_payment_stale_pending_replaced — pending order >30 min is canceled, new session created, returns 307
  • test_first_payment_paid_still_blocked — paid order returns 409
  • test_first_payment_fresh_pending_returns_redirect — pending order <30 min redirects to existing Stripe URL
  • Run command: pytest tests/test_first_payment.py -v

Constraints

  • Cancel stale orders (set status=canceled), do not delete — audit trail
  • Stripe sessions expire after 24h — stale pending orders definitely have dead sessions
  • 30 min threshold avoids extra Stripe API calls while still unblocking retries quickly
  • Match existing patterns in checkout.py
  • westside-basketball — project this affects
  • forgejo_admin/pal-e-platform#290 — observability ticket for payment pipeline alerting
  • Discovered scope: $0 monthly_fee exclusion from blast (separate ticket)
  • Discovered scope: recurring billing for May 1 (separate ticket)
### Type Bug ### Lineage Standalone — discovered Apr 13 during payment pipeline investigation. Blocking all monthly revenue collection. ### Repo `forgejo_admin/basketball-api` ### What Broke `GET /checkout/first-payment?token=X` returns 409 for every parent. 100% failure rate in prod logs since at least 18:08 Apr 13. Only 1 of 7 monthly orders completed payment — the other 6 are stuck in `pending` status, permanently blocking those parents from retrying. The duplicate order guard at `checkout.py:347-361` treats `pending` the same as `paid`. Once a pending Order is created (by first click or by link-preview bots like `facebookexternalhit/1.1 Facebot`), all subsequent clicks return 409. Prod log evidence (all 409): ``` GET /checkout/first-payment 409 — iPhone Safari GET /checkout/first-payment 409 — iPhone Google App GET /checkout/first-payment 409 — facebookexternalhit/1.1 Facebot Twitterbot/1.0 ``` Current order state: | Order | Player | Status | Amount | |-------|--------|--------|--------| | 25 | Querenne Nyamuhebe | **paid** | $135.00 | | 26 | Mateus Rigitano de Paula | pending | $150.00 | | 27 | Vince Ifote | pending | $165.00 | | 28 | Sarah Lédio da Silva | pending | $85.00 | | 22 | Test Queens Player | pending | $165.00 | | 50 | Jaxon Gerber | pending | $40.00 | | 72 | Jahzmyn Mailei | pending | $135.00 | ### Repro Steps 1. A parent receives first-payment email with checkout link 2. Gmail/social link preview bot hits `GET /checkout/first-payment?token=X` — creates pending Order + Stripe session 3. Parent clicks the link on their phone 4. API finds existing pending Order, returns HTTP 409 5. Parent sees error page, cannot pay 6. Every subsequent click also returns 409 ### Expected Behavior Parent clicks the payment link and reaches Stripe checkout. If a previous attempt created a stale pending order, the system should cancel it and create a fresh checkout session. ### Environment - Cluster/namespace: prod / `basketball-api` - Service version/commit: `bb7723a` (main) - Related alerts: none — no checkout error alerting exists (see `forgejo_admin/pal-e-platform#290`) ### File Targets Files to modify: - `src/basketball_api/routes/checkout.py` — lines 347-361, `first_payment_checkout()` duplicate guard. If a `pending` order exists older than 30 min, cancel it and create a new checkout session. Only hard-block on `paid`. Files to NOT touch: - `src/basketball_api/routes/webhooks.py` — working correctly - `src/basketball_api/services/email.py` — not the issue - `src/basketball_api/routes/subscriptions.py` — separate system ### Acceptance Criteria - [ ] Bug no longer reproduces — parent clicks link, reaches Stripe checkout - [ ] Stale pending orders (>30 min) are canceled and replaced with fresh checkout session - [ ] Paid orders still correctly block duplicate payment - [ ] Fresh pending orders (<30 min) redirect to the existing Stripe session URL - [ ] No regression in webhook payment processing ### Test Expectations - [ ] `test_first_payment_stale_pending_replaced` — pending order >30 min is canceled, new session created, returns 307 - [ ] `test_first_payment_paid_still_blocked` — paid order returns 409 - [ ] `test_first_payment_fresh_pending_returns_redirect` — pending order <30 min redirects to existing Stripe URL - Run command: `pytest tests/test_first_payment.py -v` ### Constraints - Cancel stale orders (set `status=canceled`), do not delete — audit trail - Stripe sessions expire after 24h — stale pending orders definitely have dead sessions - 30 min threshold avoids extra Stripe API calls while still unblocking retries quickly - Match existing patterns in `checkout.py` ### Related - `westside-basketball` — project this affects - `forgejo_admin/pal-e-platform#290` — observability ticket for payment pipeline alerting - Discovered scope: $0 monthly_fee exclusion from blast (separate ticket) - Discovered scope: recurring billing for May 1 (separate ticket)
Author
Owner

Scope Review: READY

Review note: review-1003-2026-04-13
Ticket scope is solid — all Bug template sections present (plus File Targets, Test Expectations, Constraints beyond minimum). File target at checkout.py:347-361 verified against live codebase. Traceability complete (story:WS-S11 confirmed, arch:basketball-api label present). Single file, single repo, 5 AC, 3 tests — well under 5-minute rule. Ready for dispatch.

Blast radius note: Same pending-as-blocker pattern exists in create_checkout_session() (line 181) and create_player_checkout_session() (line 485) — track as separate tickets if those flows also get bot-triggered 409s.

## Scope Review: READY Review note: `review-1003-2026-04-13` Ticket scope is solid — all Bug template sections present (plus File Targets, Test Expectations, Constraints beyond minimum). File target at checkout.py:347-361 verified against live codebase. Traceability complete (story:WS-S11 confirmed, arch:basketball-api label present). Single file, single repo, 5 AC, 3 tests — well under 5-minute rule. Ready for dispatch. **Blast radius note:** Same `pending`-as-blocker pattern exists in `create_checkout_session()` (line 181) and `create_player_checkout_session()` (line 485) — track as separate tickets if those flows also get bot-triggered 409s.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/basketball-api#473
No description provided.