Recover stranded monthly-fee parents (investigate + apology blast, analog of #486) #497
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/basketball-api#497
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type
Task
⚠️ HARD STOP — NO EMAILS WITHOUT APPROVAL ⚠️
No email reaches any parent until ALL of the following are true:
draneylucas@gmail.comhas been received AND clicked by Lucas.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.comor@example.comonly. Never a real parent address under any circumstances.Lineage
Standalone — emerged from 2026-04-17 Utah Invitational recovery (#486). The same 24h Stripe Checkout Session expiry that stranded 17 tournament parents also affects monthly-fee payment emails. Architecture migration to Payment Links is a sibling Feature ticket (#498).
Refinement applied after
/review-ticketpass 1 (review notereview-1033-2026-04-17):feedback_email_blast_nuclear_gate).Repo
forgejo_admin/basketball-apiUser Story
As a parent who received a monthly-fee payment email and whose link expired before I could click it, I receive an apology and a fresh working link so I can pay my player's monthly dues without having to chase Marcus.
Context
Monthly-fee flow still uses
stripe.checkout.Session.createwith Stripe's default 24-hourexpires_at— the same mechanism that stranded 17 tournament parents. The only monthly-fee mint site as of 2026-04-17 issrc/basketball_api/routes/checkout.py:410insidefirst_payment_checkout(def at line 306), verified by the audit agent. Architecture migration to Payment Links is tracked in #498 (Feature).Blast-radius audit results (2026-04-17, read-only Stripe retrieve across all product 4 orders):
True-stranded parents (4) — all
subscription_status=none, contract_status=signed, still rostered:All four have Stripe sessions returning
status=expired, payment_status=unpaid, url_present=false. Order 79 from the initial spot-check is excluded (Westside Admin test, parent_id=170).Pending re-audit: Order 95 (Artyom Litvinau / Arseni Litvinau, $150). At audit time the session was
openwith ~0.7h remaining. Re-audit this order the day of Phase A execution — if expired, add to recovery pool; if paid, exclude.Scope
Phase A — Investigation + Recipient Finalization (first deliverable, no drafting).
Phase B — Recovery blast (nuclear-gated, starts ONLY after Lucas signs off on the Phase A dry-run).
Phase C — Observability (recurring paid-vs-abandoned report so this incident class surfaces via data before a parent complaint).
Acceptance Criteria
Phase A — Investigation + Recipient Finalization (NO EMAIL WORK IN THIS PHASE):
subscription_status != noneor withdrawn-flag / unsigned-contract is excluded from the recovery poolPhase B — Approval Gates (BLOCKING, NO CODE OR EMAIL BEFORE ALL OF THESE):
_brand_wrappersystem atservices/email.py:202) covering: what happened, apology, fresh link, action neededdraneylucas@gmail.comPhase B — Mechanical recovery:
stripe_payment_link_id(or newstripe_checkout_session_idwith expiry > 7 days) andmetadata.order_idpresentmetadata.order_idpresent, link/session activePhase B — Execution (ONLY after every Gate is green AND mechanical recovery is complete):
westsidebasktball@gmail.com)paidPhase C — Observability (small scope, can ship independently):
status=pendingANDsession.status=expired(or Payment Linkactive=falsewith no paid fulfillment)Phase D — Post-send documentation:
docs/monthly-billing-runbook.md(or create if absent) — parent list, timing, approval chain, resultsConstraints
feedback_email_blast_nuclear_gate.md— the 6-step gate is the floor, not a guidelinefeedback_no_email_without_five_approvals.mdfeedback_one_approval_one_send.md— if the draft changes between Gate 2 and Gate 4, restart at Gate 1_brand_wrapperMJML system (System 1, Queens branded) perfeedback_two_email_systems.md— NOT the generic MJML templates@example.comordraneylucas@gmail.comperfeedback_never_email_without_approval.mdChecklist
docs/monthly-billing-runbook.mdupdated with Phase D recovery procedure narrativeRelated
forgejo_admin/basketball-api #486— Utah Invitational recovery. Pattern for this ticket's nuclear gate + phased structure.forgejo_admin/basketball-api #498— monthly-fee Payment Link migration (soft dependency for Phase B mechanical recovery)./tmp/monthly_orders_clean.json,/tmp/monthly_orders_enriched.json,/tmp/classify.py,/tmp/summarize_monthly.py,/tmp/retrieve_sessions.py.feedback_retrieve_before_theorize.md,feedback_email_blast_nuclear_gate.md,feedback_no_email_without_five_approvals.md,feedback_one_approval_one_send.md.Scope Review: NEEDS_REFINEMENT
Review note:
review-1033-2026-04-17Scope is directionally sound and mirrors #486's three-phase structure, but the nuclear gate is weakened and Phase C bloats the ticket.
Required refinements:
[BODY]Add⚠️ HARD STOP — NO EMAILS WITHOUT APPROVAL ⚠️banner at the top, identical in structure to #486. Enumerate the 6 gates verbatim; include "Approved draft ≠ approved to send. One approval = one send."; state test recipients aredraneylucas@gmail.com/@example.comonly.[BODY]Replace the single Phase B checkbox "all 6 nuclear-gate steps logged" with 6 individual checkboxes (Lucas approves draft / Marcus approves draft / test email sent / Lucas confirms test / Lucas says BLAST / Ava reads back / Lucas says BLAST #2). Match #486's Phase A block verbatim.[BODY]Add Constraint: "No email drafting, MJML work, or blast endpoint invocation may occur during Phase A. Phase A output = recipient list + classification only."[BODY]Strengthen Phase A AC: require per-parent classification (paid-via-retry / normal-pending / true-stranded / test-account-excluded), not just "exhaustive list."[BODY]Add a Checklist section mirroring #486's.[DECOMPOSE]Split into three tickets: keep #497 as Phase A (investigation + dry-run list). New ticket for Phase B (recovery blast, blocked on #497 Phase A + Lucas approval). New ticket for Phase C (observability, standalone Feature, decoupled from recovery). Route toskill-decompose-ticket.[BODY]Once Phase C is lifted into its own ticket, replace vague AC ("mechanism exists... at minimum") with concrete scope (e.g., SQL view + admin endpoint, or weekly cron report towestsidebasktball@gmail.com).What was verified (passing):
story:WS-S22verified in project-westside-basketball user-stories (stories-parent);arch:dataflow-westside-basketballverified (arch-dataflow-westside-basketballnote exists).forgejo_admin/basketball-api).src/basketball_api/routes/checkout.pyL293 +routes/admin.pyL2005 — noexpires_atoverride). NoCHECKOUT_SESSION_TTL_SECONDSconstant present (consistent with #493 revert).Refine the body, then re-review before todo → next_up.
Refinement applied after
/review-ticketpass 1 (review notereview-1033-2026-04-17). Six body edits:Nuclear gate reproduced verbatim as a HARD STOP banner (copied from #486) + Phase B approval-gate checkboxes, each gate as its own line. Was collapsed to one checkbox — unacceptable per
feedback_email_blast_nuclear_gate.Phase A classification structure — per-parent table required: classification enum (TRUE_STRANDED / PAID_VIA_RETRY / NORMAL_PENDING / WITHDRAWN / TEST_ACCOUNT), rationale, cross-ref with active roster.
Hard constraint added: no email drafting during Phase A. Drafting begins only in Phase B after Lucas approves the recipient list.
Phase C scope capped: one output channel (admin dashboard OR digest OR Grafana — pick one), one metric class. Explicit 'must not expand into generic analytics' constraint.
Decomposition considered and rejected — 2026-04-17 audit (agent a1a654212264ebceb) classified the blast radius at 4 real parents / $455. At this size, single-ticket recovery is correct; splitting would be ceremony.
Full Checklist section added (9 items).
Also absorbed the audit results into Context (full classification table + TRUE_STRANDED detail) and corrected the baseline (5 orders / $460 → 4 parents / $455; order 79 was Westside Admin). Mint-site location corrected to
routes/checkout.py:410(def at 306). Order 95 (Artyom) flagged for re-audit on Phase A execution day. Re-running /review-ticket.Scope Review Pass 2: APPROVED
Review note:
review-1033-2026-04-17-pass-2All six pass-1 refinements are landed correctly:
TRUE_STRANDED / PAID_VIA_RETRY / NORMAL_PENDING / WITHDRAWN / TEST_ACCOUNT) with rationale, in tabular form.Single-ticket decision: defensible. The Lineage block explicitly cites the 2026-04-17 audit (agent a1a654212264ebceb) — 4 real parents / $455 blast radius, ~24% of #486's scale. At this size, three-ticket coordination overhead outweighs the benefit, and the in-body controls (hard Phase A Constraint, per-phase deliverable gates, Phase C scope cap) provide equivalent drift protection.
No scope creep beyond the six pass-1 items. New Context tables and exclusion lists are data handoffs from the audit, not added scope.
Optional nit (non-blocking): Forgejo issue has no labels applied at the platform level. Board item carries the labels, so this is cosmetic.
Ready to move board item #1033 from
backlog→todo.Phase B executed 2026-04-19.
Mechanical recovery (2026-04-19): 4 fresh Stripe Payment Links minted via
create_monthly_order_payment_link(post-#498) and persisted on the 4 stranded Orders. Sarah Silva (order #28, $85) self-paid via a new order #96 on 2026-04-18 before Phase B, so the recovery pool narrowed from 5 → 4. All 4 Payment Links also carrypayment_intent_data.statement_descriptor_suffix = "WKQ MONTHLY"so the credit card statement showsPAL-E* WKQ MONTHLY(partial brand signal until the merchant-of-record spike resolves).Approval chain:
draneylucas@gmail.comusingparent_id=170test players. Lucas completed the $1 Queens test payment end-to-end, confirming webhook → Order flip → Payment Link deactivation. Kings test visual-only.Blast sent to 4 parents (2026-04-19 ~23:40 UTC):
Total stranded now reachable: $520.
Expected webhook fulfillment: each parent's click → Stripe completes →
checkout.session.completedfires → Order flips topaid,stripe_payment_intent_idpopulates, Payment Link deactivates (via the widened gate in #499).Follow-up observability check scheduled for 24h + 7d to track conversion. Phase A + B complete. Phase C (observability) + Phase D (runbook) remain on ticket but no longer block.
Discovered scope during this work (tracked separately):
create_monthly_order_payment_linkand its tournament/jersey/tryout siblings to setstatement_descriptor_suffixat mint time (one-line change, tenant-scoped tag).<meta name="color-scheme">to_brand_wrapperso Gmail doesn't invert the dark theme to white.Also worth an issue: architectural correction — Pal-E is the DBA on the Stripe business profile; ISS LLC is the current legal owner. Pal Enterprises LLC is a future entity. #507 should be scoped with ISS as current platform owner.