Mint Stripe Payment Links for tournament-fee email blasts (replaces expiring Checkout Sessions) #494

Closed
opened 2026-04-17 21:50:47 +00:00 by forgejo_admin · 0 comments

Type

Feature

Lineage

Standalone — surfaced 2026-04-17 after PR #490 (issue #488) proved that expires_at on Checkout Sessions cannot exceed Stripe's 24h cap. Architecture pivot from "extend the TTL" to "use a primitive that doesn't have a TTL." See #489 spike, which this implementation dogfoods.

Repo

forgejo_admin/basketball-api

User Story

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

Context

We currently send Stripe Checkout Session URLs in tournament-fee email blasts. Those URLs are hard-capped by Stripe at a 24-hour lifetime. Real parent email-open latency is often days, so most blast recipients hit dead links. The 2026-04-17 Utah Invitational incident stranded 17 parents behind expired sessions (~$985); attempts to extend expires_at to 30 days broke prod outright because Stripe rejects >24h on Checkout Sessions.

Stripe Payment Links (stripe.PaymentLink) are a different Stripe product designed exactly for this use case: URLs that persist in inboxes for days or weeks without expiring, carry metadata, and fire the same checkout.session.completed webhook we already handle. Fulfillment code does not need to change — only the primitive that mints the URL.

Scope is limited to the tournament-fee flow first. Monthly and jersey blasts follow in separate tickets once this pattern is validated by the Utah Invitational recovery dogfood (see #486).

File Targets

Dev agent's decision — grep live code for the current tournament-fee mint path (helper + any admin regen handler) and substitute the primitive. Do not touch non-tournament flows.

Acceptance Criteria

  • Tournament-fee email blasts 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 mark the Order paid
  • On successful payment, the Payment Link is deactivated programmatically so it can't be re-used to charge a second time
  • Recovery ticket #486 can mint 17 Utah Invitational links via this helper instead of the legacy regen script, and parents receive working links
  • Non-tournament flows (monthly, jersey, tryout, generic checkout, admin regen) are untouched — they still use Checkout Sessions, which is correct for their latency profile

Test Expectations

  • Webhook fulfillment still matches order_idOrder.status = paid on payment (assert against mocked webhook event)
  • Deactivation-on-payment path covered by at least one test
  • Integration test: mint one Payment Link against a real Stripe test-mode key and verify URL does not carry an expires_at. Prevents the class of bug we just hit with mocked-only coverage.

Constraints

  • Do not modify webhook handler signature — metadata-based match must keep working
  • Do not mutate non-tournament call sites — this ticket is scoped to one flow
  • Real Stripe integration test is mandatory (mock-only tests missed the 24h cap on #490)

Checklist

  • PR opened
  • Tests pass in CI
  • At least one real-Stripe integration test included
  • Helper usable by #486 recovery ticket with no additional plumbing
  • forgejo_admin/basketball-api #486 — Utah Invitational recovery. First consumer of this helper. Dogfood.
  • forgejo_admin/basketball-api #489 — architecture spike. This ticket is the implementation path for the spike's recommended answer.
  • forgejo_admin/basketball-api #493 — revert of broken #490 code. Lands before or alongside this ticket.
  • forgejo_admin/basketball-api #488 — original (misdiagnosed) ticket. Superseded.
  • Memory: feedback_retrieve_before_theorize.md — the Stripe hard-fact table at the bottom.
  • forgejo_admin/claude-custom #242 — SOP/template fix that would let future tickets skip these prescribed sections.
### Type Feature ### Lineage Standalone — surfaced 2026-04-17 after PR #490 (issue #488) proved that `expires_at` on Checkout Sessions cannot exceed Stripe's 24h cap. Architecture pivot from "extend the TTL" to "use a primitive that doesn't have a TTL." See `#489` spike, which this implementation dogfoods. ### Repo `forgejo_admin/basketball-api` ### User Story As a parent who receives a tournament-fee email, I can click the link any time after receipt — that day, that week, or a month later — and reach a working Stripe Checkout page. The link does not expire out from under me. ### Context We currently send Stripe Checkout Session URLs in tournament-fee email blasts. Those URLs are hard-capped by Stripe at a 24-hour lifetime. Real parent email-open latency is often days, so most blast recipients hit dead links. The 2026-04-17 Utah Invitational incident stranded 17 parents behind expired sessions (~$985); attempts to extend `expires_at` to 30 days broke prod outright because Stripe rejects >24h on Checkout Sessions. Stripe Payment Links (`stripe.PaymentLink`) are a different Stripe product designed exactly for this use case: URLs that persist in inboxes for days or weeks without expiring, carry metadata, and fire the same `checkout.session.completed` webhook we already handle. Fulfillment code does not need to change — only the primitive that mints the URL. Scope is limited to the tournament-fee flow first. Monthly and jersey blasts follow in separate tickets once this pattern is validated by the Utah Invitational recovery dogfood (see `#486`). ### File Targets Dev agent's decision — grep live code for the current tournament-fee mint path (helper + any admin regen handler) and substitute the primitive. Do not touch non-tournament flows. ### Acceptance Criteria - [ ] Tournament-fee email blasts 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 mark the Order paid - [ ] On successful payment, the Payment Link is deactivated programmatically so it can't be re-used to charge a second time - [ ] Recovery ticket `#486` can mint 17 Utah Invitational links via this helper instead of the legacy regen script, and parents receive working links - [ ] Non-tournament flows (monthly, jersey, tryout, generic checkout, admin regen) are untouched — they still use Checkout Sessions, which is correct for their latency profile ### Test Expectations - [ ] Webhook fulfillment still matches `order_id` → `Order.status = paid` on payment (assert against mocked webhook event) - [ ] Deactivation-on-payment path covered by at least one test - [ ] Integration test: mint one Payment Link against a real Stripe test-mode key and verify URL does not carry an `expires_at`. Prevents the class of bug we just hit with mocked-only coverage. ### Constraints - Do not modify webhook handler signature — metadata-based match must keep working - Do not mutate non-tournament call sites — this ticket is scoped to one flow - Real Stripe integration test is mandatory (mock-only tests missed the 24h cap on #490) ### Checklist - [ ] PR opened - [ ] Tests pass in CI - [ ] At least one real-Stripe integration test included - [ ] Helper usable by `#486` recovery ticket with no additional plumbing ### Related - `forgejo_admin/basketball-api #486` — Utah Invitational recovery. First consumer of this helper. Dogfood. - `forgejo_admin/basketball-api #489` — architecture spike. This ticket is the implementation path for the spike's recommended answer. - `forgejo_admin/basketball-api #493` — revert of broken #490 code. Lands before or alongside this ticket. - `forgejo_admin/basketball-api #488` — original (misdiagnosed) ticket. Superseded. - Memory: `feedback_retrieve_before_theorize.md` — the Stripe hard-fact table at the bottom. - `forgejo_admin/claude-custom #242` — SOP/template fix that would let future tickets skip these prescribed sections.
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#494
No description provided.