Migrate monthly-fee checkout flow to Stripe Payment Links (analog of #494) #498

Closed
opened 2026-04-18 04:45:34 +00:00 by forgejo_admin · 3 comments

Type

Feature

Lineage

Standalone — direct analog of #494 (tournament-fee migration). Monthly-fee flow still mints Stripe Checkout Sessions which cap at 24h. Payment Links do not expire and carry metadata for webhook fulfillment. Same mechanism change, different flow.

Refinement applied after /review-ticket pass 1 (review note review-1032-2026-04-17): added AC for setup_future_usage carry-over, added AC for widening webhook deactivation gate to ProductCategory.monthly, and locked in the email-URL strategy (keep the /checkout/first-payment?token=... redirect, have the endpoint return the persisted Payment Link URL).

Repo

forgejo_admin/basketball-api

User Story

As a parent who receives a monthly-fee payment email, I can click the link any time after receipt — that day, that week, or later — and reach a working Stripe Checkout page. The link does not expire out from under me.

Context

The 2026-04-17 investigation proved Stripe Checkout Sessions are the wrong primitive for any flow where the URL lives in an inbox. We migrated tournament fees to Payment Links in #494. Monthly fees are the next flow in the same pattern: parents receive a monthly-fee link, may open the email a day or two later, hit a dead URL. Same class of customer-facing breakage we just apologized for on tournaments; we will face it again on monthly unless this ships.

Monthly fees differ from tournament fees in three substantive ways that the dev agent MUST preserve:

  1. Per-parent amounts vary (scholarships, prorations, custom contracts). The Payment Link helper must read each Order's amount_cents rather than a product-level price.
  2. setup_future_usage: "off_session" is required on the monthly flow for card-save on recurring billing. The existing Session call sets this via payment_intent_data (checkout.py:423). #494's tournament helper does NOT set this, so a straight copy will silently break recurring charges. Regression coverage lives at tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe and must remain green.
  3. The email embeds our redirect, not a Stripe URL. Monthly-fee emails currently carry {base_url}/checkout/first-payment?token=... (services/email.py:1041-1043). Keep the redirect — the first_payment_checkout endpoint should return the persisted Payment Link URL from Order.stripe_checkout_url, preserving the contract-status re-check that happens on click. Do NOT embed https://buy.stripe.com/... directly in the email template.

File Targets

Dev agent's decision — grep live code for the monthly-fee mint path(s) and substitute the primitive. Verified single site as of 2026-04-17: routes/checkout.py:305 (first_payment_checkout). Do not touch non-monthly flows (tournament already migrated, jersey / tryout / generic / admin regen for tournaments stay as-is).

Acceptance Criteria

  • Monthly-fee payment emails carry URLs that remain clickable indefinitely (no Stripe-side expiry)
  • Each URL is bound 1:1 to an Order, with order_id present in Stripe metadata so the existing webhook matcher continues to flip Order → paid
  • On successful payment, the Payment Link is deactivated programmatically (same pattern as #494)
  • Webhook deactivation gate widened: webhooks.py:246-267 currently deactivates Payment Links only when product.category == ProductCategory.tournament. Widen to include ProductCategory.monthly WITHOUT removing the tournament branch — jersey/tryout/generic must continue to skip deactivation.
  • Per-parent amounts flow through from orders.amount_cents unchanged — scholarships, prorations, custom rates all preserved
  • setup_future_usage: "off_session" carried over from the existing Session call. The regression test tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe must continue to pass (and ideally an analogous assertion added for the Payment Link path).
  • Email continues to embed {base_url}/checkout/first-payment?token=... — no change to the email template. The redirect endpoint returns the persisted Payment Link URL from Order.stripe_checkout_url.
  • Recovery ticket (monthly analog of #486, tracked as #497) can use this helper if the recovery decides to use Payment Links
  • Non-monthly flows untouched

Test Expectations

  • Webhook fulfillment matches order_idOrder.status = paid on payment (mocked)
  • Deactivation on payment asserted for monthly category (and existing tournament assertion still green)
  • setup_future_usage presence asserted on the Payment Link path (extend or add alongside tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe)
  • Real-Stripe integration test using test-mode key — mandatory. Mock-only coverage missed the 24h cap that broke #490; same rigor applies here.

Constraints

  • Do not modify webhook handler signature — metadata-based match continues to work for both Sessions (remaining flows) and Payment Links (tournament + monthly)
  • Do not touch non-monthly call sites
  • Real Stripe integration test is required; mocks are insufficient per the #490 lesson
  • CHECKOUT_SESSION_TTL_SECONDS or similar TTL constants must not be introduced — that was the #490 dead end
  • Do not embed Stripe URLs directly in the email template — keep the /checkout/first-payment?token=... redirect so contract-status re-check on click is preserved

Checklist

  • PR opened
  • Tests pass in CI
  • At least one real-Stripe integration test included
  • setup_future_usage carry-over explicitly asserted
  • Webhook deactivation gate widened to include monthly, tournament branch still intact
  • Email template unchanged (redirect preserved)
  • Helper available to the monthly-recovery ticket without additional plumbing
  • forgejo_admin/basketball-api #494 — tournament migration. Pattern to copy with three monthly-specific deltas above.
  • forgejo_admin/basketball-api #486 — tournament recovery (uses #494's helper). Dogfood analog for monthly.
  • forgejo_admin/basketball-api #497 — monthly-fee recovery ticket. Downstream consumer of this helper.
  • forgejo_admin/basketball-api #493 — revert of broken 30-day approach. Context for why integration test is required.
  • Memory: feedback_retrieve_before_theorize.md
### Type Feature ### Lineage Standalone — direct analog of #494 (tournament-fee migration). Monthly-fee flow still mints Stripe Checkout Sessions which cap at 24h. Payment Links do not expire and carry metadata for webhook fulfillment. Same mechanism change, different flow. Refinement applied after `/review-ticket` pass 1 (review note `review-1032-2026-04-17`): added AC for `setup_future_usage` carry-over, added AC for widening webhook deactivation gate to `ProductCategory.monthly`, and locked in the email-URL strategy (keep the `/checkout/first-payment?token=...` redirect, have the endpoint return the persisted Payment Link URL). ### Repo `forgejo_admin/basketball-api` ### User Story As a parent who receives a monthly-fee payment email, I can click the link any time after receipt — that day, that week, or later — and reach a working Stripe Checkout page. The link does not expire out from under me. ### Context The 2026-04-17 investigation proved Stripe Checkout Sessions are the wrong primitive for any flow where the URL lives in an inbox. We migrated tournament fees to Payment Links in #494. Monthly fees are the next flow in the same pattern: parents receive a monthly-fee link, may open the email a day or two later, hit a dead URL. Same class of customer-facing breakage we just apologized for on tournaments; we will face it again on monthly unless this ships. Monthly fees differ from tournament fees in three substantive ways that the dev agent MUST preserve: 1. **Per-parent amounts vary** (scholarships, prorations, custom contracts). The Payment Link helper must read each Order's `amount_cents` rather than a product-level price. 2. **`setup_future_usage: "off_session"` is required** on the monthly flow for card-save on recurring billing. The existing Session call sets this via `payment_intent_data` (checkout.py:423). #494's tournament helper does NOT set this, so a straight copy will silently break recurring charges. Regression coverage lives at `tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe` and must remain green. 3. **The email embeds our redirect, not a Stripe URL.** Monthly-fee emails currently carry `{base_url}/checkout/first-payment?token=...` (services/email.py:1041-1043). Keep the redirect — the `first_payment_checkout` endpoint should return the persisted Payment Link URL from `Order.stripe_checkout_url`, preserving the contract-status re-check that happens on click. Do NOT embed `https://buy.stripe.com/...` directly in the email template. ### File Targets Dev agent's decision — grep live code for the monthly-fee mint path(s) and substitute the primitive. Verified single site as of 2026-04-17: `routes/checkout.py:305` (`first_payment_checkout`). Do not touch non-monthly flows (tournament already migrated, jersey / tryout / generic / admin regen for tournaments stay as-is). ### Acceptance Criteria - [ ] Monthly-fee payment emails carry URLs that remain clickable indefinitely (no Stripe-side expiry) - [ ] Each URL is bound 1:1 to an Order, with `order_id` present in Stripe metadata so the existing webhook matcher continues to flip Order → paid - [ ] On successful payment, the Payment Link is deactivated programmatically (same pattern as #494) - [ ] **Webhook deactivation gate widened**: `webhooks.py:246-267` currently deactivates Payment Links only when `product.category == ProductCategory.tournament`. Widen to include `ProductCategory.monthly` WITHOUT removing the tournament branch — jersey/tryout/generic must continue to skip deactivation. - [ ] Per-parent amounts flow through from `orders.amount_cents` unchanged — scholarships, prorations, custom rates all preserved - [ ] **`setup_future_usage: "off_session"` carried over** from the existing Session call. The regression test `tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe` must continue to pass (and ideally an analogous assertion added for the Payment Link path). - [ ] Email continues to embed `{base_url}/checkout/first-payment?token=...` — no change to the email template. The redirect endpoint returns the persisted Payment Link URL from `Order.stripe_checkout_url`. - [ ] Recovery ticket (monthly analog of #486, tracked as #497) can use this helper if the recovery decides to use Payment Links - [ ] Non-monthly flows untouched ### Test Expectations - [ ] Webhook fulfillment matches `order_id` → `Order.status = paid` on payment (mocked) - [ ] Deactivation on payment asserted for monthly category (and existing tournament assertion still green) - [ ] `setup_future_usage` presence asserted on the Payment Link path (extend or add alongside `tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe`) - [ ] **Real-Stripe integration test** using test-mode key — mandatory. Mock-only coverage missed the 24h cap that broke #490; same rigor applies here. ### Constraints - Do not modify webhook handler signature — metadata-based match continues to work for both Sessions (remaining flows) and Payment Links (tournament + monthly) - Do not touch non-monthly call sites - Real Stripe integration test is required; mocks are insufficient per the #490 lesson - `CHECKOUT_SESSION_TTL_SECONDS` or similar TTL constants must not be introduced — that was the #490 dead end - Do not embed Stripe URLs directly in the email template — keep the `/checkout/first-payment?token=...` redirect so contract-status re-check on click is preserved ### Checklist - [ ] PR opened - [ ] Tests pass in CI - [ ] At least one real-Stripe integration test included - [ ] `setup_future_usage` carry-over explicitly asserted - [ ] Webhook deactivation gate widened to include monthly, tournament branch still intact - [ ] Email template unchanged (redirect preserved) - [ ] Helper available to the monthly-recovery ticket without additional plumbing ### Related - `forgejo_admin/basketball-api #494` — tournament migration. Pattern to copy with three monthly-specific deltas above. - `forgejo_admin/basketball-api #486` — tournament recovery (uses #494's helper). Dogfood analog for monthly. - `forgejo_admin/basketball-api #497` — monthly-fee recovery ticket. Downstream consumer of this helper. - `forgejo_admin/basketball-api #493` — revert of broken 30-day approach. Context for why integration test is required. - Memory: `feedback_retrieve_before_theorize.md`
Author
Owner

Scope Review: NEEDS_REFINEMENT

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

Ticket is directionally sound and a true standalone analog of #494 (not dependent on #497#497 is the consumer, this is the enabler). Traceability triangle intact: story:WS-S11, arch:dataflow-westside-basketball, issue open. Monthly-fee mint path is unambiguous and grep-able (routes/checkout.py:305 first_payment_checkout is the sole site; services/email.py:1021 send_first_payment_email embeds the URL). Fits a single agent pass (no decomposition).

Three [BODY] refinements needed before this can advance to todo:

  • Add AC for setup_future_usage preservation. The existing monthly Session call passes payment_intent_data={"setup_future_usage": "off_session"} (checkout.py:423) so the card is saved for recurring billing. #494's tournament helper does not set this — tournament doesn't need card save. A dev agent copying #494 verbatim will silently drop card saving. The AC should name this explicitly. Regression test: tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe.
  • Name the webhook gate widening explicitly. Today webhooks.py:246-267 deactivates the Payment Link only when product.category == ProductCategory.tournament. Monthly orders will never trigger deactivation under the existing gate. Add to Acceptance Criteria or Constraints: "Widen the deactivation gate in routes/webhooks.py to include ProductCategory.monthly. Do not remove the gate — jersey/tryout/generic must stay untouched."
  • Answer the email-URL architectural question. Monthly-fee email currently embeds our redirect endpoint ({base_url}/checkout/first-payment?token=...), not a Stripe URL. With Payment Links the dev agent must choose: (a) keep the redirect, have first_payment_checkout return the persisted Payment Link URL on Order.stripe_checkout_url; or (b) embed the https://buy.stripe.com/... URL directly. Recommend (a) — preserves the contract-status re-check on click, minimum diff to the email template. Issue body should state the choice so the dev agent doesn't have to guess.

No [LABEL], [SCOPE], or [DECOMPOSE] recommendations. Per the consolidated-spec convention, these refinements must land in the issue body itself — comments document the why, but the agent reads the body.

Full review: review-1032-2026-04-17 in pal-e-docs.

## Scope Review: NEEDS_REFINEMENT Review note: `review-1032-2026-04-17` Ticket is directionally sound and a true standalone analog of #494 (not dependent on #497 — #497 is the consumer, this is the enabler). Traceability triangle intact: `story:WS-S11`, `arch:dataflow-westside-basketball`, issue open. Monthly-fee mint path is unambiguous and grep-able (`routes/checkout.py:305` `first_payment_checkout` is the sole site; `services/email.py:1021` `send_first_payment_email` embeds the URL). Fits a single agent pass (no decomposition). Three `[BODY]` refinements needed before this can advance to `todo`: - **Add AC for `setup_future_usage` preservation.** The existing monthly Session call passes `payment_intent_data={"setup_future_usage": "off_session"}` (checkout.py:423) so the card is saved for recurring billing. #494's tournament helper does not set this — tournament doesn't need card save. A dev agent copying #494 verbatim will silently drop card saving. The AC should name this explicitly. Regression test: `tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe`. - **Name the webhook gate widening explicitly.** Today `webhooks.py:246-267` deactivates the Payment Link only when `product.category == ProductCategory.tournament`. Monthly orders will never trigger deactivation under the existing gate. Add to Acceptance Criteria or Constraints: "Widen the deactivation gate in `routes/webhooks.py` to include `ProductCategory.monthly`. Do not remove the gate — jersey/tryout/generic must stay untouched." - **Answer the email-URL architectural question.** Monthly-fee email currently embeds our redirect endpoint (`{base_url}/checkout/first-payment?token=...`), not a Stripe URL. With Payment Links the dev agent must choose: (a) keep the redirect, have `first_payment_checkout` return the persisted Payment Link URL on `Order.stripe_checkout_url`; or (b) embed the `https://buy.stripe.com/...` URL directly. Recommend (a) — preserves the contract-status re-check on click, minimum diff to the email template. Issue body should state the choice so the dev agent doesn't have to guess. No `[LABEL]`, `[SCOPE]`, or `[DECOMPOSE]` recommendations. Per the consolidated-spec convention, these refinements must land in the issue body itself — comments document the *why*, but the agent reads the body. Full review: `review-1032-2026-04-17` in pal-e-docs.
Author
Owner

Refinement applied after /review-ticket pass 1 (review note review-1032-2026-04-17). Three body edits:

  1. Added AC + Checklist + Test Expectation for setup_future_usage: "off_session" carry-over. The existing Session call sets this via payment_intent_data (checkout.py:423); #494's tournament helper does not. Straight copy would silently break card-save on recurring billing. Regression test tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe named explicitly as must-stay-green.

  2. Added AC + Checklist for widening the webhook deactivation gate at webhooks.py:246-267 to include ProductCategory.monthly, without removing the tournament branch. Jersey/tryout/generic must continue to skip deactivation.

  3. Locked in email-URL strategy. Keep the {base_url}/checkout/first-payment?token=... redirect; have first_payment_checkout return the persisted Payment Link URL from Order.stripe_checkout_url. Preserves contract-status re-check on click, minimum diff to email template. Added as Context #3, AC, Constraint, and Checklist item.

Also added the verified single mint site (routes/checkout.py:305 first_payment_checkout) to File Targets, and linked #497 in Related. Re-running /review-ticket.

Refinement applied after `/review-ticket` pass 1 (review note `review-1032-2026-04-17`). Three body edits: 1. **Added AC + Checklist + Test Expectation** for `setup_future_usage: "off_session"` carry-over. The existing Session call sets this via `payment_intent_data` (checkout.py:423); #494's tournament helper does not. Straight copy would silently break card-save on recurring billing. Regression test `tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe` named explicitly as must-stay-green. 2. **Added AC + Checklist** for widening the webhook deactivation gate at `webhooks.py:246-267` to include `ProductCategory.monthly`, without removing the tournament branch. Jersey/tryout/generic must continue to skip deactivation. 3. **Locked in email-URL strategy.** Keep the `{base_url}/checkout/first-payment?token=...` redirect; have `first_payment_checkout` return the persisted Payment Link URL from `Order.stripe_checkout_url`. Preserves contract-status re-check on click, minimum diff to email template. Added as Context #3, AC, Constraint, and Checklist item. Also added the verified single mint site (`routes/checkout.py:305 first_payment_checkout`) to File Targets, and linked #497 in Related. Re-running /review-ticket.
Author
Owner

Scope Review Pass 2: APPROVED

Review note: review-1032-2026-04-17-pass-2
Pass-1 note: review-1032-2026-04-17 (NEEDS_REFINEMENT)

All three pass-1 deltas landed in the body as first-class ACs + constraints + test expectations + checklist items:

  1. setup_future_usage: "off_session" preservation — named in Context, AC, Test Expectations, Checklist; regression test tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe (line 477) cited.
  2. Webhook deactivation gate widening — additive-only transform specified for routes/webhooks.py:246-267, tournament branch explicitly preserved.
  3. Email-URL architectural choice — locked in as a directive constraint: keep the /checkout/first-payment?token=... redirect, have first_payment_checkout return the persisted Payment Link URL from Order.stripe_checkout_url. No longer phrased as an open question.

No scope creep — body grew only within the allowed pass-1 deltas + the mint-site citation (routes/checkout.py:305) + the #497 related-link add. Traceability triangle intact. Fits a single agent pass.

Board item #1032 ready to advance from backlogtodo.

## Scope Review Pass 2: APPROVED Review note: `review-1032-2026-04-17-pass-2` Pass-1 note: `review-1032-2026-04-17` (NEEDS_REFINEMENT) All three pass-1 deltas landed in the body as first-class ACs + constraints + test expectations + checklist items: 1. **`setup_future_usage: "off_session"` preservation** — named in Context, AC, Test Expectations, Checklist; regression test `tests/test_first_payment.py::test_setup_future_usage_passed_to_stripe` (line 477) cited. 2. **Webhook deactivation gate widening** — additive-only transform specified for `routes/webhooks.py:246-267`, tournament branch explicitly preserved. 3. **Email-URL architectural choice** — locked in as a directive constraint: keep the `/checkout/first-payment?token=...` redirect, have `first_payment_checkout` return the persisted Payment Link URL from `Order.stripe_checkout_url`. No longer phrased as an open question. No scope creep — body grew only within the allowed pass-1 deltas + the mint-site citation (`routes/checkout.py:305`) + the `#497` related-link add. Traceability triangle intact. Fits a single agent pass. Board item #1032 ready to advance from `backlog` → `todo`.
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#498
No description provided.