Stale pending orders permanently block checkout — need auto-expiry and cleanup #371

Open
opened 2026-04-07 16:45:34 +00:00 by forgejo_admin · 0 comments

Type

Bug

Lineage

Discovered from parent reports (Gracie Maloney-Holland, 2026-04-07). 6 stale orders manually canceled during investigation. Root cause: Stripe webhook was broken for weeks (forgejo_admin/basketball-api#350), leaving Order records permanently pending.

Repo

forgejo_admin/basketball-api

What Broke

The generic checkout endpoint POST /checkout/create-session (line 149-166 of src/basketball_api/routes/checkout.py) blocks if a player has ANY pending or paid Order for the same product. When a Stripe checkout session is created but payment never completes (user abandons, webhook fails, session expires), the Order stays pending forever. The user cannot try again — they see "Player already has an active order for this product (order #N)".

Affected users so far: Gracie Maloney-Holland (Orders #20, #21), Jacelyn Laila Bronson (#1), David Kaneko (#3), Mateus Rigitano de Paula (#5), Anaiyah Fesolai (#16). All manually canceled on 2026-04-07.

Architecture context — two checkout systems exist:

  1. Legacy /jersey/checkout — writes to Player.jersey_order_status directly, no Order table, no duplicate blocking. Called by /jersey page.
  2. Generic /checkout/create-session — writes to Order table, has duplicate blocking that treats pending as active. Called by /checkout page. This is where the bug is.

Both pages are live. Parents may land on either depending on which link they follow.

Repro Steps

  1. Open checkout page with a valid token
  2. Select a product and click Order
  3. Stripe checkout opens — close it without paying (abandon)
  4. Order record stays pending with no stripe_payment_intent_id
  5. Try to order again — get 409 "Player already has an active order"
  6. User is permanently blocked

Expected Behavior

Abandoned checkout sessions should not permanently block the user. Three fixes needed:

Fix 1: Auto-expire stale orders. Orders that are pending with no stripe_payment_intent_id for >1 hour should auto-cancel. Implement as either:

  • A check in create-session before the duplicate query (cancel stale orders first)
  • A periodic cron job

Fix 2: Don't treat pending as blocking. The duplicate check on line 149-166 should only block on paid orders, not pending. A user should be able to retry checkout even if a previous attempt is pending. The webhook handles idempotency — if the old session somehow completes, it creates/updates the order via webhook.

Fix 3: Add cancel-order endpoint. POST /checkout/cancel-order?order_id=N&token=... — allows users to explicitly cancel a pending order from the frontend. The cancel page (/checkout/cancel) currently does nothing with the Order record.

Environment

  • Cluster/namespace: prod / basketball-api
  • Endpoints: POST /checkout/create-session (blocking bug), GET /checkout/cancel (no-op)
  • Order table: 6 stale pending orders found and manually canceled
  • Stripe sessions expire after 24 hours but Order records persist forever

Acceptance Criteria

  • Stale pending orders (>1h, no payment intent) auto-canceled before duplicate check
  • Users can retry checkout after an abandoned session
  • Cancel page updates Order status to canceled
  • Test: abandon checkout, wait, retry — succeeds without manual intervention
  • No regression on paid order duplicate prevention (can't double-pay)
  • project-westside-basketball — project this affects
  • story:WS-S18 — parent jersey ordering
  • forgejo_admin/basketball-api#350 — webhook fix that caused the original stale orders
  • Key files: src/basketball_api/routes/checkout.py:149-166 (duplicate check), src/basketball_api/models.py (Order/OrderStatus)
### Type Bug ### Lineage Discovered from parent reports (Gracie Maloney-Holland, 2026-04-07). 6 stale orders manually canceled during investigation. Root cause: Stripe webhook was broken for weeks (forgejo_admin/basketball-api#350), leaving Order records permanently `pending`. ### Repo `forgejo_admin/basketball-api` ### What Broke The generic checkout endpoint `POST /checkout/create-session` (line 149-166 of `src/basketball_api/routes/checkout.py`) blocks if a player has ANY `pending` or `paid` Order for the same product. When a Stripe checkout session is created but payment never completes (user abandons, webhook fails, session expires), the Order stays `pending` forever. The user cannot try again — they see "Player already has an active order for this product (order #N)". **Affected users so far:** Gracie Maloney-Holland (Orders #20, #21), Jacelyn Laila Bronson (#1), David Kaneko (#3), Mateus Rigitano de Paula (#5), Anaiyah Fesolai (#16). All manually canceled on 2026-04-07. **Architecture context — two checkout systems exist:** 1. **Legacy** `/jersey/checkout` — writes to `Player.jersey_order_status` directly, no Order table, no duplicate blocking. Called by `/jersey` page. 2. **Generic** `/checkout/create-session` — writes to Order table, has duplicate blocking that treats `pending` as active. Called by `/checkout` page. This is where the bug is. Both pages are live. Parents may land on either depending on which link they follow. ### Repro Steps 1. Open checkout page with a valid token 2. Select a product and click Order 3. Stripe checkout opens — close it without paying (abandon) 4. Order record stays `pending` with no `stripe_payment_intent_id` 5. Try to order again — get 409 "Player already has an active order" 6. User is permanently blocked ### Expected Behavior Abandoned checkout sessions should not permanently block the user. Three fixes needed: **Fix 1: Auto-expire stale orders.** Orders that are `pending` with no `stripe_payment_intent_id` for >1 hour should auto-cancel. Implement as either: - A check in `create-session` before the duplicate query (cancel stale orders first) - A periodic cron job **Fix 2: Don't treat `pending` as blocking.** The duplicate check on line 149-166 should only block on `paid` orders, not `pending`. A user should be able to retry checkout even if a previous attempt is pending. The webhook handles idempotency — if the old session somehow completes, it creates/updates the order via webhook. **Fix 3: Add cancel-order endpoint.** `POST /checkout/cancel-order?order_id=N&token=...` — allows users to explicitly cancel a pending order from the frontend. The cancel page (`/checkout/cancel`) currently does nothing with the Order record. ### Environment - Cluster/namespace: prod / basketball-api - Endpoints: `POST /checkout/create-session` (blocking bug), `GET /checkout/cancel` (no-op) - Order table: 6 stale `pending` orders found and manually canceled - Stripe sessions expire after 24 hours but Order records persist forever ### Acceptance Criteria - [ ] Stale pending orders (>1h, no payment intent) auto-canceled before duplicate check - [ ] Users can retry checkout after an abandoned session - [ ] Cancel page updates Order status to `canceled` - [ ] Test: abandon checkout, wait, retry — succeeds without manual intervention - [ ] No regression on paid order duplicate prevention (can't double-pay) ### Related - `project-westside-basketball` — project this affects - `story:WS-S18` — parent jersey ordering - `forgejo_admin/basketball-api#350` — webhook fix that caused the original stale orders - Key files: `src/basketball_api/routes/checkout.py:149-166` (duplicate check), `src/basketball_api/models.py` (Order/OrderStatus)
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#371
No description provided.