Payment recovery for abandoned Stripe registrations #389

Closed
opened 2026-04-07 19:45:14 +00:00 by forgejo_admin · 4 comments

Type

Feature

Lineage

Standalone — discovered during registration flow audit (2026-04-07). Current flow has no recovery path for abandoned Stripe checkouts.

Repo

forgejo_admin/basketball-api

User Story

As a parent who started registration but didn't complete Stripe payment
I want to receive a reminder email with a payment link
So that I can complete my registration without re-filling the entire form

Context

Current card payment flow: form → API creates PENDING registration → redirects to checkout.stripe.com → if user pays, webhook fires and completes registration. If user closes the tab or abandons Stripe, the registration sits as PENDING forever. No recovery email is sent. The Stripe session URL expires after 24h. The user must return to /register and fill out the form again to get a new Stripe session.

The payment gate is correct — no payment = no account = no junk registrations. But abandoned registrations have no recovery path. This is lost revenue.

Important: Both card (signup_method="stripe") and cash (signup_method="cash") registrations create PENDING records. Only Stripe registrations should receive recovery emails — cash payments are handled offline by Marcus.

File Targets

Files to modify:

  • src/basketball_api/services/email.py — new send_payment_reminder_email() function using the MJML template system (load_email_template)
  • src/basketball_api/routes/admin.py — new admin endpoint following the existing outbox pattern (see admin.py:899-913 for the pattern). Admin-triggered, not cron — keeps it simple and auditable.
  • src/basketball_api/models.py — add recovery_email_sent: bool column to Registration model (follow existing confirmation_email_sent pattern at line 289). This enforces the "only sent once" requirement at the data layer.
  • Alembic migration for the new column.

Files NOT to touch:

  • Stripe webhook handler — the existing completion flow works

Acceptance Criteria

  • When a Stripe registration is PENDING for >2 hours, a recovery email is sent with a fresh payment link
  • When the user clicks the payment link, they complete payment without re-filling the form
  • Recovery email is only sent once (not repeated) — enforced by recovery_email_sent column
  • Successfully paid registrations are not affected
  • Cash (signup_method="cash") PENDING registrations are NOT sent recovery emails — only signup_method="stripe" registrations qualify

Test Expectations

  • Unit test: recovery email sent for PENDING Stripe registration older than threshold
  • Unit test: recovery email NOT sent for already-paid registration
  • Unit test: recovery email NOT sent twice for same registration
  • Unit test: recovery email NOT sent for cash PENDING registration
  • Run command: pytest tests/ -k test_payment_recovery

Constraints

  • Recovery email should use MJML template system (load_email_template)
  • Use admin-triggered endpoint pattern (not cron) — matches existing outbox processing at admin.py:899-913
  • Filter strictly on signup_method == "stripe" to exclude cash registrations
  • Consider Stripe embedded checkout vs redirect as part of this work
  • Depends on #390 landing first (fixes registration_token generation needed for recovery email URLs)

Checklist

  • PR opened
  • Tests pass
  • No unrelated changes
  • westside-basketball
  • Depends on #390
### Type Feature ### Lineage Standalone — discovered during registration flow audit (2026-04-07). Current flow has no recovery path for abandoned Stripe checkouts. ### Repo `forgejo_admin/basketball-api` ### User Story As a parent who started registration but didn't complete Stripe payment I want to receive a reminder email with a payment link So that I can complete my registration without re-filling the entire form ### Context Current card payment flow: form → API creates PENDING registration → redirects to checkout.stripe.com → if user pays, webhook fires and completes registration. If user closes the tab or abandons Stripe, the registration sits as PENDING forever. No recovery email is sent. The Stripe session URL expires after 24h. The user must return to /register and fill out the form again to get a new Stripe session. The payment gate is correct — no payment = no account = no junk registrations. But abandoned registrations have no recovery path. This is lost revenue. **Important:** Both card (`signup_method="stripe"`) and cash (`signup_method="cash"`) registrations create PENDING records. Only Stripe registrations should receive recovery emails — cash payments are handled offline by Marcus. ### File Targets Files to modify: - `src/basketball_api/services/email.py` — new `send_payment_reminder_email()` function using the MJML template system (`load_email_template`) - `src/basketball_api/routes/admin.py` — new admin endpoint following the existing outbox pattern (see `admin.py:899-913` for the pattern). Admin-triggered, not cron — keeps it simple and auditable. - `src/basketball_api/models.py` — add `recovery_email_sent: bool` column to Registration model (follow existing `confirmation_email_sent` pattern at line 289). This enforces the "only sent once" requirement at the data layer. - Alembic migration for the new column. Files NOT to touch: - Stripe webhook handler — the existing completion flow works ### Acceptance Criteria - [ ] When a Stripe registration is PENDING for >2 hours, a recovery email is sent with a fresh payment link - [ ] When the user clicks the payment link, they complete payment without re-filling the form - [ ] Recovery email is only sent once (not repeated) — enforced by `recovery_email_sent` column - [ ] Successfully paid registrations are not affected - [ ] Cash (`signup_method="cash"`) PENDING registrations are NOT sent recovery emails — only `signup_method="stripe"` registrations qualify ### Test Expectations - [ ] Unit test: recovery email sent for PENDING Stripe registration older than threshold - [ ] Unit test: recovery email NOT sent for already-paid registration - [ ] Unit test: recovery email NOT sent twice for same registration - [ ] Unit test: recovery email NOT sent for cash PENDING registration - Run command: `pytest tests/ -k test_payment_recovery` ### Constraints - Recovery email should use MJML template system (`load_email_template`) - Use admin-triggered endpoint pattern (not cron) — matches existing outbox processing at `admin.py:899-913` - Filter strictly on `signup_method == "stripe"` to exclude cash registrations - Consider Stripe embedded checkout vs redirect as part of this work - Depends on #390 landing first (fixes `registration_token` generation needed for recovery email URLs) ### Checklist - [ ] PR opened - [ ] Tests pass - [ ] No unrelated changes ### Related - `westside-basketball` - Depends on #390
Author
Owner

Scope Review: NEEDS_REFINEMENT

Review note: review-890-2026-04-08
Ticket is well-structured but has 5 fixable gaps before an agent can execute cleanly.

  • [BODY] Missing file target: models.py needs recovery_email_sent boolean column + Alembic migration (follow confirmation_email_sent pattern)
  • [BODY] Missing AC: cash registrations (signup_method='cash') also go PENDING — query must filter signup_method == "stripe" to avoid emailing cash parents
  • [BODY] Constraints incorrectly states "only card payments go PENDING" — both card and cash do (register.py:1306, 1310)
  • [BODY] Implementation pattern should be specified (admin endpoint trigger like outbox at admin.py:899) instead of open-ended "cron, background task, or script"
  • [SCOPE] Architecture note arch-registration does not exist in pal-e-docs — create it
## Scope Review: NEEDS_REFINEMENT Review note: `review-890-2026-04-08` Ticket is well-structured but has 5 fixable gaps before an agent can execute cleanly. - **[BODY]** Missing file target: `models.py` needs `recovery_email_sent` boolean column + Alembic migration (follow `confirmation_email_sent` pattern) - **[BODY]** Missing AC: cash registrations (`signup_method='cash'`) also go PENDING — query must filter `signup_method == "stripe"` to avoid emailing cash parents - **[BODY]** Constraints incorrectly states "only card payments go PENDING" — both card and cash do (register.py:1306, 1310) - **[BODY]** Implementation pattern should be specified (admin endpoint trigger like outbox at admin.py:899) instead of open-ended "cron, background task, or script" - **[SCOPE]** Architecture note `arch-registration` does not exist in pal-e-docs — create it
Author
Owner

Scope refinement applied (review-890-2026-04-08):

  1. Added models.py + Alembic migration as file targets — recovery_email_sent boolean column needed (follows confirmation_email_sent pattern at line 289)
  2. Fixed incorrect claim "only card payments go PENDING" — both signup_method="stripe" and signup_method="cash" create PENDING records. Added explicit signup_method == "stripe" filter requirement.
  3. Specified admin-triggered endpoint pattern (not cron) — matches existing outbox processing at admin.py:899-913
  4. Added AC #5: cash registrations must NOT receive recovery emails
  5. Added unit test for cash exclusion
  6. Added explicit dependency on #390

Arch note gap (arch:registration label with no backing note) tracked as separate backlog item.

**Scope refinement applied (review-890-2026-04-08):** 1. Added `models.py` + Alembic migration as file targets — `recovery_email_sent` boolean column needed (follows `confirmation_email_sent` pattern at line 289) 2. Fixed incorrect claim "only card payments go PENDING" — both `signup_method="stripe"` and `signup_method="cash"` create PENDING records. Added explicit `signup_method == "stripe"` filter requirement. 3. Specified admin-triggered endpoint pattern (not cron) — matches existing outbox processing at `admin.py:899-913` 4. Added AC #5: cash registrations must NOT receive recovery emails 5. Added unit test for cash exclusion 6. Added explicit dependency on #390 Arch note gap (`arch:registration` label with no backing note) tracked as separate backlog item.
Author
Owner

Scope Review: READY

Review note: review-890-2026-04-08
All 6 refinements verified — template complete, file targets confirmed against codebase, traceability intact, ACs testable. Ready for dispatch once #390 lands.

Missing arch-registration note tracked as discovered scope (not a blocker).

## Scope Review: READY Review note: `review-890-2026-04-08` All 6 refinements verified — template complete, file targets confirmed against codebase, traceability intact, ACs testable. Ready for dispatch once #390 lands. Missing `arch-registration` note tracked as discovered scope (not a blocker).
forgejo_admin 2026-04-08 21:35:18 +00:00
Author
Owner

Validation: FAIL

Tiers executed: Tier 1 (local tests), Tier 3 (prod health check)
Validation note: validation-389-2026-04-08
8 checks: 6 PASS, 2 FAIL

Failures:

  • CI pipeline #415 (merge commit for PR #397): update-kustomize-tag step was SKIPPED due to pre-existing test failure. Image was built and pushed to Harbor but kustomize tag was never updated.
  • Deployed image tag is 2d85242cc9 (PR #399), not 16b96f5 (PR #397). PR #397 code is not live in production.

Tier 1 (local): All 4 payment recovery unit tests PASS. Full suite 913/917 pass (4 pre-existing failures unrelated to #397). No regressions.

Remediation: Re-trigger pipeline or manually update kustomize tag to 16b96f547bb73b8680bf164a53db408a33adda4a. Verify basketball-api .woodpecker.yaml has the fix from pal-e-platform PR #275 (run update-kustomize-tag even when test step fails).

## Validation: FAIL Tiers executed: Tier 1 (local tests), Tier 3 (prod health check) Validation note: `validation-389-2026-04-08` 8 checks: 6 PASS, 2 FAIL **Failures:** - CI pipeline #415 (merge commit for PR #397): `update-kustomize-tag` step was SKIPPED due to pre-existing test failure. Image was built and pushed to Harbor but kustomize tag was never updated. - Deployed image tag is `2d85242cc9` (PR #399), not `16b96f5` (PR #397). **PR #397 code is not live in production.** **Tier 1 (local):** All 4 payment recovery unit tests PASS. Full suite 913/917 pass (4 pre-existing failures unrelated to #397). No regressions. **Remediation:** Re-trigger pipeline or manually update kustomize tag to `16b96f547bb73b8680bf164a53db408a33adda4a`. Verify basketball-api `.woodpecker.yaml` has the fix from pal-e-platform PR #275 (run update-kustomize-tag even when test step fails).
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#389
No description provided.