Regenerate Utah Invitational orders via proper helper + validated e2e webhook round-trip #484

Closed
opened 2026-04-15 20:06:26 +00:00 by forgejo_admin · 1 comment

Type

Feature

Lineage

Discovered during Utah Invitational blast prep (2026-04-13). 21 pending Orders were created via ad-hoc Python scripts that bypassed create_player_checkout_session(), resulting in Stripe Checkout Sessions missing the order_id metadata field required by the webhook handler. Sessions have since expired. Zero phantom payments (all sessions expired/unpaid).

Repo

forgejo_admin/basketball-api

User Story

As an admin
I want tournament fee checkout links generated through the committed API path with validated webhook round-trip
So that parent payments reliably flip Orders to paid and we never ship broken links again

Context

The webhook handler at src/basketball_api/routes/webhooks.py:197 matches incoming checkout.session.completed events to Orders via the order_id field in session metadata. The proper create_player_checkout_session() helper at src/basketball_api/routes/checkout.py:529-533 sets this field correctly. Ad-hoc session creation (direct stripe.checkout.Session.create() calls in admin scripts) omits order_id and breaks the reconciliation loop — parents can pay and our Order.status stays pending forever with no indication of the payment.

Current state of 21 tournament orders (product_id 5/6/7):

  • All Order.status = pending in DB
  • All Stripe sessions expired/unpaid
  • All Stripe sessions missing order_id metadata
  • No emails have been sent to real parents
  • Zero payments collected, zero reconciliation needed

File Targets

Files the agent should modify or create:

  • scripts/regenerate_tournament_orders.py — NEW. One-shot admin script that: (1) queries stale pending Orders for a given tournament_id, (2) deletes them, (3) calls create_player_checkout_session() for each committed player through the proper helper, (4) reports new order IDs + checkout URLs. Idempotent.
  • tests/test_tournament_webhook_roundtrip.py — NEW. Integration test that creates a test-mode Order via the helper, synthesizes a checkout.session.completed event signed with the webhook secret, POSTs to /webhooks/stripe, asserts Order.status flips to paid. Must pass against TEST mode Stripe key.
  • docs/tournament-billing-runbook.md — NEW. Operator runbook: the exact sequence to create a tournament, generate checkout links, validate e2e, and blast emails. Calls out the "always use the admin endpoint, never ad-hoc sessions" rule.

Files the agent should NOT touch:

  • routes/webhooks.py — handler is correct; bug was in session creation, not in the handler
  • routes/checkout.pycreate_player_checkout_session() is correct; don't modify

Acceptance Criteria

  • Regeneration script, run against tournament_id=2, deletes all existing pending Orders for that tournament and creates new ones via the helper
  • Every new Stripe session has order_id in metadata (verified via Stripe API after script run)
  • Webhook roundtrip test passes: synthesized checkout.session.completed event → Order flips from pending to paid
  • Runbook documents the validated sequence: create tournament → generate links → e2e validate → blast
  • Runbook explicitly forbids creating Stripe sessions outside create_player_checkout_session()

Test Expectations

  • Integration test: test_tournament_webhook_roundtrip — creates Order via helper, POSTs signed test event, asserts Order.status == paid and Order.stripe_payment_intent_id populated
  • Unit test: regeneration script dry-run mode lists affected orders without deleting
  • Run command: pytest tests/ -k "tournament_webhook or regenerate"

Constraints

  • Regeneration script must be safe to re-run (idempotent)
  • Webhook roundtrip test must use test-mode Stripe webhook secret (~/secrets/stripe/test-webhook-secret)
  • Zero changes to the webhook handler — the bug was in session creation, not processing
  • Must be validated with a full e2e test (real Stripe test session + real webhook delivery) before being used on Utah Invitational data

Checklist

  • PR opened
  • Tests pass
  • No unrelated changes
  • project-westside-basketball — parent project
  • story:WS-S33 — user story (tournament billing)
  • #456 — blast system (email infrastructure)
  • #457 — tournament checkout (introduced the helper)
  • #463 — integration of checkout URLs into blast query
  • sop-email-send — updated 2026-04-13 with ONE APPROVAL = ONE SEND rule
### Type Feature ### Lineage Discovered during Utah Invitational blast prep (2026-04-13). 21 pending Orders were created via ad-hoc Python scripts that bypassed `create_player_checkout_session()`, resulting in Stripe Checkout Sessions missing the `order_id` metadata field required by the webhook handler. Sessions have since expired. Zero phantom payments (all sessions `expired/unpaid`). ### Repo `forgejo_admin/basketball-api` ### User Story As an admin I want tournament fee checkout links generated through the committed API path with validated webhook round-trip So that parent payments reliably flip Orders to paid and we never ship broken links again ### Context The webhook handler at `src/basketball_api/routes/webhooks.py:197` matches incoming `checkout.session.completed` events to Orders via the `order_id` field in session metadata. The proper `create_player_checkout_session()` helper at `src/basketball_api/routes/checkout.py:529-533` sets this field correctly. Ad-hoc session creation (direct `stripe.checkout.Session.create()` calls in admin scripts) omits `order_id` and breaks the reconciliation loop — parents can pay and our Order.status stays `pending` forever with no indication of the payment. Current state of 21 tournament orders (product_id 5/6/7): - All `Order.status = pending` in DB - All Stripe sessions `expired/unpaid` - All Stripe sessions missing `order_id` metadata - No emails have been sent to real parents - Zero payments collected, zero reconciliation needed ### File Targets Files the agent should modify or create: - `scripts/regenerate_tournament_orders.py` — NEW. One-shot admin script that: (1) queries stale pending Orders for a given tournament_id, (2) deletes them, (3) calls `create_player_checkout_session()` for each committed player through the proper helper, (4) reports new order IDs + checkout URLs. Idempotent. - `tests/test_tournament_webhook_roundtrip.py` — NEW. Integration test that creates a test-mode Order via the helper, synthesizes a `checkout.session.completed` event signed with the webhook secret, POSTs to `/webhooks/stripe`, asserts Order.status flips to `paid`. Must pass against TEST mode Stripe key. - `docs/tournament-billing-runbook.md` — NEW. Operator runbook: the exact sequence to create a tournament, generate checkout links, validate e2e, and blast emails. Calls out the "always use the admin endpoint, never ad-hoc sessions" rule. Files the agent should NOT touch: - `routes/webhooks.py` — handler is correct; bug was in session creation, not in the handler - `routes/checkout.py` — `create_player_checkout_session()` is correct; don't modify ### Acceptance Criteria - [ ] Regeneration script, run against tournament_id=2, deletes all existing pending Orders for that tournament and creates new ones via the helper - [ ] Every new Stripe session has `order_id` in metadata (verified via Stripe API after script run) - [ ] Webhook roundtrip test passes: synthesized `checkout.session.completed` event → Order flips from `pending` to `paid` - [ ] Runbook documents the validated sequence: create tournament → generate links → e2e validate → blast - [ ] Runbook explicitly forbids creating Stripe sessions outside `create_player_checkout_session()` ### Test Expectations - [ ] Integration test: `test_tournament_webhook_roundtrip` — creates Order via helper, POSTs signed test event, asserts `Order.status == paid` and `Order.stripe_payment_intent_id` populated - [ ] Unit test: regeneration script dry-run mode lists affected orders without deleting - [ ] Run command: `pytest tests/ -k "tournament_webhook or regenerate"` ### Constraints - Regeneration script must be safe to re-run (idempotent) - Webhook roundtrip test must use test-mode Stripe webhook secret (`~/secrets/stripe/test-webhook-secret`) - Zero changes to the webhook handler — the bug was in session creation, not processing - Must be validated with a full e2e test (real Stripe test session + real webhook delivery) before being used on Utah Invitational data ### Checklist - [ ] PR opened - [ ] Tests pass - [ ] No unrelated changes ### Related - `project-westside-basketball` — parent project - `story:WS-S33` — user story (tournament billing) - #456 — blast system (email infrastructure) - #457 — tournament checkout (introduced the helper) - #463 — integration of checkout URLs into blast query - `sop-email-send` — updated 2026-04-13 with ONE APPROVAL = ONE SEND rule
Author
Owner

Scope Review: READY

Review note: review-1013-2026-04-15

Scope is solid. Template complete, traceability confirmed (WS-S33 story verified on project-westside-basketball), all file target references in webhooks.py and checkout.py verified against the live repo, 3 NEW files confirmed non-existent. Fits in a single agent pass (3 files, 5 AC, single repo). No decomposition needed.

Non-blocking nits for dev:

  • [BODY] Clarify "signed synthesized event" vs. existing mocked stripe.Webhook.construct_event pattern used in tests/test_webhooks.py. Dev can pick either; flagging to preempt review churn.
  • [BODY] Runbook's "never ad-hoc sessions" rule should be scoped to tournament-fee checkout. There are 6 sanctioned stripe.checkout.Session.create sites in src/ (admin.py, jersey.py, register.py x2, checkout.py x3) that are per-product helpers, not ad-hoc.
  • [SCOPE] Follow-up ticket: no arch-checkout or arch-email backing notes exist in pal-e-docs. Not a blocker; worth standing up so future arch-labeled tickets have referents.

Line 529-533 cite in the Context section points to the metadata block inside create_player_checkout_session() (def is at line 467). Functional claim is correct; noting the offset for clarity.

Cleared to advance to next_up.

## Scope Review: READY Review note: `review-1013-2026-04-15` Scope is solid. Template complete, traceability confirmed (WS-S33 story verified on project-westside-basketball), all file target references in webhooks.py and checkout.py verified against the live repo, 3 NEW files confirmed non-existent. Fits in a single agent pass (3 files, 5 AC, single repo). No decomposition needed. Non-blocking nits for dev: - [BODY] Clarify "signed synthesized event" vs. existing mocked `stripe.Webhook.construct_event` pattern used in tests/test_webhooks.py. Dev can pick either; flagging to preempt review churn. - [BODY] Runbook's "never ad-hoc sessions" rule should be scoped to tournament-fee checkout. There are 6 sanctioned `stripe.checkout.Session.create` sites in src/ (admin.py, jersey.py, register.py x2, checkout.py x3) that are per-product helpers, not ad-hoc. - [SCOPE] Follow-up ticket: no `arch-checkout` or `arch-email` backing notes exist in pal-e-docs. Not a blocker; worth standing up so future arch-labeled tickets have referents. Line 529-533 cite in the Context section points to the metadata block inside `create_player_checkout_session()` (def is at line 467). Functional claim is correct; noting the offset for clarity. Cleared to advance to `next_up`.
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#484
No description provided.