Recover stranded Utah Invitational orders via regen + apology email #486

Open
opened 2026-04-17 16:30:45 +00:00 by forgejo_admin · 1 comment

Type

Task

⚠️ HARD STOP — NO EMAILS WITHOUT APPROVAL ⚠️

No email reaches any parent until ALL of the following are true:

  1. Lucas has explicitly approved the final email draft.
  2. Marcus has explicitly approved the final email draft.
  3. A test email sent to draneylucas@gmail.com has been received AND clicked by Lucas.
  4. Lucas has said "BLAST" once to trigger.
  5. Ava has read back subject + recipient count (17) + an example recipient name.
  6. Lucas has said "BLAST" a second time to confirm.

Approved draft ≠ approved to send. One approval = one send. If the copy, recipient list, or timing changes between approval and send, the approval chain restarts from Lucas. This rule is non-negotiable — see feedback_email_blast_nuclear_gate.md, feedback_no_email_without_five_approvals.md, feedback_one_approval_one_send.md, and the 2026-04-04 + 2026-04-13 incidents.

Test recipients: draneylucas@gmail.com or @example.com only. Never a real parent address under any circumstances.

Lineage

Standalone — discovered 2026-04-17 when parent Daniel Niyitanga reported a broken checkout link. Investigation confirmed 18 tournament orders stranded behind expired Stripe sessions, of which 17 are real parents and 1 is a Westside Admin test order (excluded). See sibling Bug #488 for the root-cause fix.

Refinement applied after /review-ticket pass 1 (review note review-1022-2026-04-17):

  • Excluded order 93 (Westside Admin test, not a real parent). Count dropped 18 → 17; stranded revenue $1,040 → $985.
  • Story/arch labels aligned with westside-basketball taxonomy (story:WS-S22, arch:dataflow-westside-basketball).
  • Checklist runbook clause clarified — #486 documents the actual recovery procedure executed; #488 corrects the root-cause narrative. Two different runbook updates, tracked in their respective tickets.

Repo

forgejo_admin/basketball-api

User Story

As a parent who received a Utah Invitational checkout email but couldn't pay because the link expired, I receive an apology and a fresh working link so I can still pay my child's tournament fee.

Context

On 2026-04-12, an email blast sent 21 parents Stripe checkout links for Utah Invitational tournament fees across three divisions (17U Elite $55, 17U Select $65, 16U Elite $55). One additional order (94) was created Apr 15 for a late registrant (Chaoying Fan).

Stripe Checkout Sessions default to expires_at = created + 86400s (24h). Our code never overrides this. Every session from the blast expired Apr 13. Only 5 parents clicked within the TTL and paid; 17 real-parent orders remain pending with dead sessions. Verified via Stripe API (status=expired, url_present=false) on a sample — 100% failure rate. Stranded revenue: $985.

Daniel is the first to complain. The other 16 have silently assumed the email was a dead end.

Scope

Regenerate fresh Stripe Checkout Sessions for all 17 real-parent pending Utah Invitational orders (products 5, 6, 7) using scripts/regenerate_tournament_orders.py --product-ids 5,6,7 --commit (after filtering out order 93). Then send an apology + new-link email to the 17 parents, gated through the blast approval SOP with no exceptions.

Stranded real parents (17) — none have paid, none are on the existing paid list:

  • 17U Elite (prod 5) — $55 × 4: rjmob446, gabrieliuspeciulis08, derob81, zacbod1709
  • 17U Select (prod 6) — $65 × 5: Roni Webster, elsonolotu46, Eric Porter, Kandis & Jeff Froebe, Chaoying Fan (order 94)
  • 16U Elite (prod 7) — $55 × 8: miroakbas, Mohamed Nur, ulgenersarp, yigitulgener, niyitangadaniel72 (Daniel), owencebully29, Artyom Litvinau, Vince Ifote Ifote

Explicitly excluded:

  • Order 93 — Westside Admin test order (westsidebasktball@gmail.com, "Test Kings Player"). Do NOT regen, do NOT email.
  • Already-paid parents — Spencer Thorn, Goudiaby Family, Marcel, Heather Jordan, fatkid816. Do NOT email.

Acceptance Criteria

Phase A — Approval Gates (BLOCKING, NO CODE OR EMAIL BEFORE ALL OF THESE):

  • Apology email template drafted (MJML, Queens pink _brand_wrapper system at services/email.py:202) covering: what happened, apology, fresh link, action needed
  • Lucas approves email draft (Gate 1)
  • Marcus approves email draft (Gate 2)
  • Test email sent to draneylucas@gmail.com
  • Lucas confirms test email received + clicked link + verified Stripe Checkout renders (Gate 3)
  • Lucas says "BLAST" (Gate 4 — trigger)
  • Ava reads back: subject line + recipient count (17) + example recipient name (Gate 5)
  • Lucas confirms "BLAST" a second time (Gate 6 — final confirmation)

Phase B — Mechanical recovery (can run before Phase A gates fire, but blast in Phase C waits):

  • Sibling Bug #488 merged + deployed — fresh sessions must use the 30-day TTL helper, not the default 24h path
  • Dry-run scripts/regenerate_tournament_orders.py --product-ids 5,6,7 lists exactly 18 pending orders; confirm order 93 is filtered or skipped manually so regen touches only 17
  • Lucas reviews dry-run output and confirms the 17-count (post-filter)
  • Regen committed via --commit; 17 Orders now have fresh stripe_checkout_session_id
  • Spot-check 2 orders in the Stripe dashboard: metadata.order_id present, status=open, expires_at ≈ 30 days out (verifies #488 deploy took effect)

Phase C — Execution (ONLY after every Phase A gate is green AND Phase B is complete):

  • Email sent to 17 parents via gmail-sdk / basketball-api blast endpoint (Gmail OAuth on westsidebasktball@gmail.com)
  • Monitor webhook logs: verify at least the first payment flips its order to paid via normal webhook flow
  • Post-send: confirm to Lucas how many sent, how many bounced (if any), first click time

Phase D — Post-send documentation:

  • Append recovery procedure narrative to docs/tournament-billing-runbook.md (what was actually executed — parent list, timing, approval chain, results). This is separate from #488's root-cause correction.

Constraints

  • Follow feedback_email_blast_nuclear_gate.md — the 7-step gate is the floor, not a guideline
  • Follow feedback_no_email_without_five_approvals.md
  • Follow feedback_one_approval_one_send.md — if the draft changes between Gate 2 and Gate 4, restart at Gate 1
  • Use existing _brand_wrapper MJML system (System 1, Queens branded) per feedback_two_email_systems.md — NOT the generic MJML templates
  • Do NOT touch order 71 (fatkid816) or the other 4 paid orders
  • Do NOT touch order 93 (Westside Admin test)
  • Do NOT include any of the above in the recipient list
  • Test recipients use @example.com or draneylucas@gmail.com per feedback_never_email_without_approval.md
  • Phase B blocked by #488 — do not regen until the 30-day TTL helper is deployed
  • If any Gate fails or ambiguity surfaces, STOP. Escalate to Lucas. Do not retry unilaterally.

Checklist

  • Dry-run output shared with Lucas
  • Email template PR opened + reviewed (code review of the MJML template is separate from content approval)
  • All 6 approval gates completed end-to-end in order
  • Blast sent
  • At least 1 payment confirmed post-send
  • docs/tournament-billing-runbook.md updated with Phase D recovery procedure narrative (separate from #488's root-cause correction)
  • project-pal-e-platform
  • Blocked by: forgejo_admin/basketball-api #488 (30-day expires_at patch must ship first so fresh sessions minted by #486's regen carry the durable TTL)
  • Feeds: forensic recovery narrative into the runbook
### Type Task ### ⚠️ HARD STOP — NO EMAILS WITHOUT APPROVAL ⚠️ **No email reaches any parent until ALL of the following are true:** 1. Lucas has explicitly approved the final email draft. 2. Marcus has explicitly approved the final email draft. 3. A test email sent to `draneylucas@gmail.com` has been received AND clicked by Lucas. 4. Lucas has said "BLAST" once to trigger. 5. Ava has read back subject + recipient count (17) + an example recipient name. 6. Lucas has said "BLAST" a second time to confirm. **Approved draft ≠ approved to send. One approval = one send.** If the copy, recipient list, or timing changes between approval and send, the approval chain restarts from Lucas. This rule is non-negotiable — see `feedback_email_blast_nuclear_gate.md`, `feedback_no_email_without_five_approvals.md`, `feedback_one_approval_one_send.md`, and the 2026-04-04 + 2026-04-13 incidents. **Test recipients:** `draneylucas@gmail.com` or `@example.com` only. Never a real parent address under any circumstances. ### Lineage Standalone — discovered 2026-04-17 when parent Daniel Niyitanga reported a broken checkout link. Investigation confirmed 18 tournament orders stranded behind expired Stripe sessions, of which 17 are real parents and 1 is a Westside Admin test order (excluded). See sibling Bug #488 for the root-cause fix. Refinement applied after `/review-ticket` pass 1 (review note `review-1022-2026-04-17`): - Excluded order 93 (Westside Admin test, not a real parent). Count dropped 18 → 17; stranded revenue $1,040 → $985. - Story/arch labels aligned with westside-basketball taxonomy (`story:WS-S22`, `arch:dataflow-westside-basketball`). - Checklist runbook clause clarified — #486 documents the actual recovery procedure executed; #488 corrects the root-cause narrative. Two different runbook updates, tracked in their respective tickets. ### Repo `forgejo_admin/basketball-api` ### User Story As a parent who received a Utah Invitational checkout email but couldn't pay because the link expired, I receive an apology and a fresh working link so I can still pay my child's tournament fee. ### Context On 2026-04-12, an email blast sent 21 parents Stripe checkout links for Utah Invitational tournament fees across three divisions (17U Elite $55, 17U Select $65, 16U Elite $55). One additional order (94) was created Apr 15 for a late registrant (Chaoying Fan). Stripe Checkout Sessions default to `expires_at = created + 86400s` (24h). Our code never overrides this. Every session from the blast expired Apr 13. Only 5 parents clicked within the TTL and paid; 17 real-parent orders remain `pending` with dead sessions. Verified via Stripe API (`status=expired`, `url_present=false`) on a sample — 100% failure rate. Stranded revenue: $985. Daniel is the first to complain. The other 16 have silently assumed the email was a dead end. ### Scope Regenerate fresh Stripe Checkout Sessions for all 17 real-parent pending Utah Invitational orders (products 5, 6, 7) using `scripts/regenerate_tournament_orders.py --product-ids 5,6,7 --commit` (after filtering out order 93). Then send an apology + new-link email to the 17 parents, gated through the blast approval SOP **with no exceptions**. **Stranded real parents (17)** — none have paid, none are on the existing paid list: - **17U Elite (prod 5) — $55 × 4:** rjmob446, gabrieliuspeciulis08, derob81, zacbod1709 - **17U Select (prod 6) — $65 × 5:** Roni Webster, elsonolotu46, Eric Porter, Kandis & Jeff Froebe, Chaoying Fan (order 94) - **16U Elite (prod 7) — $55 × 8:** miroakbas, Mohamed Nur, ulgenersarp, yigitulgener, niyitangadaniel72 (Daniel), owencebully29, Artyom Litvinau, Vince Ifote Ifote **Explicitly excluded:** - Order 93 — Westside Admin test order (`westsidebasktball@gmail.com`, "Test Kings Player"). Do NOT regen, do NOT email. - Already-paid parents — Spencer Thorn, Goudiaby Family, Marcel, Heather Jordan, fatkid816. Do NOT email. ### Acceptance Criteria **Phase A — Approval Gates (BLOCKING, NO CODE OR EMAIL BEFORE ALL OF THESE):** - [ ] Apology email template drafted (MJML, Queens pink `_brand_wrapper` system at `services/email.py:202`) covering: what happened, apology, fresh link, action needed - [ ] **Lucas approves email draft (Gate 1)** - [ ] **Marcus approves email draft (Gate 2)** - [ ] Test email sent to `draneylucas@gmail.com` - [ ] **Lucas confirms test email received + clicked link + verified Stripe Checkout renders (Gate 3)** - [ ] **Lucas says "BLAST" (Gate 4 — trigger)** - [ ] **Ava reads back: subject line + recipient count (17) + example recipient name (Gate 5)** - [ ] **Lucas confirms "BLAST" a second time (Gate 6 — final confirmation)** **Phase B — Mechanical recovery (can run before Phase A gates fire, but blast in Phase C waits):** - [ ] Sibling Bug #488 merged + deployed — fresh sessions must use the 30-day TTL helper, not the default 24h path - [ ] Dry-run `scripts/regenerate_tournament_orders.py --product-ids 5,6,7` lists exactly 18 pending orders; confirm order 93 is filtered or skipped manually so regen touches only 17 - [ ] Lucas reviews dry-run output and confirms the 17-count (post-filter) - [ ] Regen committed via `--commit`; 17 Orders now have fresh `stripe_checkout_session_id` - [ ] Spot-check 2 orders in the Stripe dashboard: `metadata.order_id` present, `status=open`, `expires_at` ≈ 30 days out (verifies #488 deploy took effect) **Phase C — Execution (ONLY after every Phase A gate is green AND Phase B is complete):** - [ ] Email sent to 17 parents via gmail-sdk / basketball-api blast endpoint (Gmail OAuth on `westsidebasktball@gmail.com`) - [ ] Monitor webhook logs: verify at least the first payment flips its order to `paid` via normal webhook flow - [ ] Post-send: confirm to Lucas how many sent, how many bounced (if any), first click time **Phase D — Post-send documentation:** - [ ] Append recovery procedure narrative to `docs/tournament-billing-runbook.md` (what was actually executed — parent list, timing, approval chain, results). This is separate from #488's root-cause correction. ### Constraints - Follow `feedback_email_blast_nuclear_gate.md` — the 7-step gate is the floor, not a guideline - Follow `feedback_no_email_without_five_approvals.md` - Follow `feedback_one_approval_one_send.md` — if the draft changes between Gate 2 and Gate 4, restart at Gate 1 - Use existing `_brand_wrapper` MJML system (System 1, Queens branded) per `feedback_two_email_systems.md` — NOT the generic MJML templates - Do NOT touch order 71 (fatkid816) or the other 4 paid orders - Do NOT touch order 93 (Westside Admin test) - Do NOT include any of the above in the recipient list - Test recipients use `@example.com` or `draneylucas@gmail.com` per `feedback_never_email_without_approval.md` - Phase B blocked by #488 — do not regen until the 30-day TTL helper is deployed - If any Gate fails or ambiguity surfaces, STOP. Escalate to Lucas. Do not retry unilaterally. ### Checklist - [ ] Dry-run output shared with Lucas - [ ] Email template PR opened + reviewed (code review of the MJML template is separate from content approval) - [ ] All 6 approval gates completed end-to-end in order - [ ] Blast sent - [ ] At least 1 payment confirmed post-send - [ ] `docs/tournament-billing-runbook.md` updated with Phase D recovery procedure narrative (separate from #488's root-cause correction) ### Related - `project-pal-e-platform` - Blocked by: `forgejo_admin/basketball-api #488` (30-day `expires_at` patch must ship first so fresh sessions minted by #486's regen carry the durable TTL) - Feeds: forensic recovery narrative into the runbook
Author
Owner

Scope Review: NEEDS_REFINEMENT

Review note: review-1022-2026-04-17

Scope is largely solid — the 6-gate approval chain is prominent and repeated in three places (HARD STOP, Phase A AC, Constraints), and the 18-stranded / 5-paid lists reconcile 100% against live basketball-api DB state ($1,040 math verified). Script scripts/regenerate_tournament_orders.py and the _brand_wrapper MJML helper both exist and match the ticket's references exactly. Four fixable issues prevent advance to todo:

  • [SCOPE] Order 93 treatment unclear. Parent is "Westside Admin", email westsidebasktball@gmail.com, player "Test Kings Player" — this is an internal admin test order, not a real parent. Ticket should either (a) exclude it, drop recipient count to 17 throughout (including Gate 5 read-back), or (b) keep it as intentional QA recipient and say so in Scope.
  • [SCOPE] story:payment-recovery label has no matching entry in project-westside-basketball → stories-parent. Either add WS-S29 ("As a parent whose checkout link expired before I could pay…") or relabel to story:WS-S22 (existing clear-communications story).
  • [SCOPE] arch:stripe-checkout label has no backing note in pal-e-docs (searched). Existing arch notes are arch-domain/dataflow/deployment/auth-westside-basketball. Create arch-stripe-checkout covering the 6 Session creation paths + expires_at/metadata conventions, or relabel to arch:dataflow-westside-basketball.
  • [BODY] Checklist item 6 says "Runbook updated… move into #488's scope to avoid PR churn" — but #488 is the Stripe TTL infra bug, not a docs ticket. Either open a separate runbook tracking item or move this clause into #488's Checklist directly (with confirmation).

Not blocking: the 6-gate approval chain placement is exemplary. Three citations (feedback_email_blast_nuclear_gate.md, feedback_no_email_without_five_approvals.md, feedback_one_approval_one_send.md) are all correct. Approval chain itself needs no refinement.

Decomposition: not needed. 13 AC split across Phases A/B/C, but Phase B is <5 min of agent work and A/C are human-gated — one agent can own the whole ticket with wait states.

## Scope Review: NEEDS_REFINEMENT Review note: `review-1022-2026-04-17` Scope is largely solid — the 6-gate approval chain is prominent and repeated in three places (HARD STOP, Phase A AC, Constraints), and the 18-stranded / 5-paid lists reconcile 100% against live basketball-api DB state ($1,040 math verified). Script `scripts/regenerate_tournament_orders.py` and the `_brand_wrapper` MJML helper both exist and match the ticket's references exactly. Four fixable issues prevent advance to todo: - `[SCOPE]` **Order 93 treatment unclear.** Parent is "Westside Admin", email `westsidebasktball@gmail.com`, player "Test Kings Player" — this is an internal admin test order, not a real parent. Ticket should either (a) exclude it, drop recipient count to 17 throughout (including Gate 5 read-back), or (b) keep it as intentional QA recipient and say so in Scope. - `[SCOPE]` `story:payment-recovery` label has no matching entry in `project-westside-basketball` → stories-parent. Either add `WS-S29` ("As a parent whose checkout link expired before I could pay…") or relabel to `story:WS-S22` (existing clear-communications story). - `[SCOPE]` `arch:stripe-checkout` label has no backing note in pal-e-docs (searched). Existing arch notes are `arch-domain/dataflow/deployment/auth-westside-basketball`. Create `arch-stripe-checkout` covering the 6 Session creation paths + expires_at/metadata conventions, or relabel to `arch:dataflow-westside-basketball`. - `[BODY]` Checklist item 6 says "Runbook updated… move into #488's scope to avoid PR churn" — but #488 is the Stripe TTL infra bug, not a docs ticket. Either open a separate runbook tracking item or move this clause into #488's Checklist directly (with confirmation). **Not blocking:** the 6-gate approval chain placement is exemplary. Three citations (`feedback_email_blast_nuclear_gate.md`, `feedback_no_email_without_five_approvals.md`, `feedback_one_approval_one_send.md`) are all correct. Approval chain itself needs no refinement. Decomposition: not needed. 13 AC split across Phases A/B/C, but Phase B is <5 min of agent work and A/C are human-gated — one agent can own the whole ticket with wait states.
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#486
No description provided.