fix: set 30-day expires_at on all Stripe Checkout Sessions (#488) #490
No reviewers
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!490
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "488-checkout-session-ttl"
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?
Summary
Every
stripe.checkout.Session.create(...)call in the repo was omittingexpires_at, falling back to Stripe's 24h default. Email-delivered checkout links died 24h after mint — the real root cause of the Utah Invitational 78% blast failure rate and $985 stranded revenue. This patch introducesCHECKOUT_SESSION_TTL_SECONDS = 30 * 24 * 3600(Stripe's max formode="payment") and passesexpires_atat all 8 call sites, plus a parametrized regression test that fails CI if a new call site forgets the kwarg.Changes
src/basketball_api/config.py— added module-levelCHECKOUT_SESSION_TTL_SECONDS = 30 * 24 * 3600constant with docstring explaining the required pattern and link to issue #488.src/basketball_api/services/tournament_checkout.py— blessed tournament helper passesexpires_at.src/basketball_api/routes/jersey.py— legacy jersey purchase passesexpires_at.src/basketball_api/routes/checkout.py— three call sites updated:/checkout/create-session, first monthly payment (prorated), andcreate_player_checkout_sessionshared helper.src/basketball_api/routes/register.py— tryout card path and tryout promo-discount path both passexpires_at.src/basketball_api/routes/admin.py—/admin/send-payment-recoveryhandler passesexpires_aton regenerated sessions.tests/test_checkout_session_ttl.py— new regression test parametrized across all 8 call sites. Each test exercises the minimum path to invokestripe.checkout.Session.create, assertsexpires_atis in the call kwargs, and asserts the delta lands within 29-30 days oftime.time(). Verified-failing whenexpires_atis removed from any site.docs/tournament-billing-runbook.md— "What broke" narrative rewritten: TTL expiry documented as primary root cause (with Stripe-retrieve evidence); original metadata-drop hypothesis moved to aHistorical hypothesis (corrected)subsection; added new "Checkout session TTL (issue #488)" section with the required pattern; sanctioned-call-sites table expanded from 5 to 8 entries with corrected line numbers (4 of the old 5 were stale).Test Plan
ruff check src/ tests/test_checkout_session_ttl.py— cleanruff format --checkon all 7 changed files — cleanpytest tests/test_checkout_session_ttl.py -v— 8/8 pass (one per call site)pytest tests/test_jersey.py tests/test_checkout.py tests/test_first_payment.py tests/test_tournament.py tests/test_tournament_webhook_roundtrip.py tests/test_promo_registration.py tests/test_checkout_session_ttl.py— 143/143 pass (no regression in jersey, tryout, generic checkout, first monthly, tournament, or admin flows)expires_atfromroutes/jersey.pyas a sanity check and confirmed the regression test fails loudly with a clear message citing issue #488, then restoredexpires_at=int(time.time()) + CHECKOUT_SESSION_TTL_SECONDSAcceptance Criteria Mapping
CHECKOUT_SESSION_TTL_SECONDS = 30 * 24 * 3600declared insrc/basketball_api/config.pyexpires_at=int(time.time()) + CHECKOUT_SESSION_TTL_SECONDStests/test_checkout_session_ttl.pyparametrizes across all 8 routes/helpers and asserts each passesexpires_atwithin 29-30 days of nowpytestgreenruff checkcleanexpires_at - created ≈ 2592000sReview Checklist
.envfiles touchedRelated Notes
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
PR #490 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Stripe SDK / pytest. Domain checklist: PEP compliance, OWASP (secrets, input validation), SQLAlchemy session safety, Stripe API correctness, Ruff format/lint, regression-test discrimination.
Correctness of the
expires_atpattern (all 8 sites):Cross-checked the diff against a live
rg "stripe\.checkout\.Session\.create"ofsrc/. The 8 sites named in the diff, the runbook table, and the test file exactly match the 8 live call sites — no missed site, no phantom site:services/tournament_checkout.py:97time,CHECKOUT_SESSION_TTL_SECONDSroutes/jersey.py:311time,CHECKOUT_SESSION_TTL_SECONDSroutes/checkout.py:267/checkout/create-sessiontime,CHECKOUT_SESSION_TTL_SECONDSroutes/checkout.py:431routes/checkout.py:570create_player_checkout_sessionroutes/register.py:1398time,CHECKOUT_SESSION_TTL_SECONDSroutes/register.py:1445routes/admin.py:2025/admin/send-payment-recoverytime,CHECKOUT_SESSION_TTL_SECONDSEvery insertion is a single new kwarg immediately before the closing
). No other session kwargs (mode, customer, line_items, metadata, success_url, cancel_url, discounts) were altered. Confirmed by inspecting each hunk — pure additive.Constant definition:
CHECKOUT_SESSION_TTL_SECONDS = 30 * 24 * 3600 = 2,592,000s. Correct — this is Stripe's documented maximum formode="payment"sessions. Placed at module top-level ofconfig.pywith a clear docstring citing issue #488 and the required call pattern. Importable withfrom basketball_api.config import CHECKOUT_SESSION_TTL_SECONDS.Regression-test discrimination:
_assert_expires_at_within_windowchecks both:"expires_at" in call_kwargs— with an explicit failure message naming issue #48829 days <= (expires_at - now) <= 30 daysStructure is sound: each of the 8 tests is a thin trigger around one call site, mocks
stripeat the module level (correct for the 7 regular routes), and the admin test patchesstripe.checkout.Session.createdirectly sinceadmin.pydoes a lateimport stripeinline at the handler (verified at line 1940 of admin.py). Removingexpires_atfrom any site would cause the corresponding test to fail with a clear citation of #488 — exactly the intended trip-wire. Dev also verified this locally by deletingexpires_atfromroutes/jersey.pyand watching the test fail.Scripts audit:
scripts/regenerate_tournament_orders.pywas flagged by the initial grep as touchingstripe.checkout.Session.create, but inspection shows it only references the string in a docstring and delegates toservices.tournament_checkout.create_tournament_order_checkout_session(call site #1, covered). No ninth site.Runbook narrative correction:
The runbook rewrite is accurate and honest:
expires_at - created == 86400sexactly for all 18 pending).Historical hypothesis (corrected)subsection with an explicit "preserved as a documented wrong turn" framing — the #484 sanctioned-helper rule is correctly preserved as defense-in-depth rather than erased.OWASP / secrets:
.envdiffs.expires_at— alwaysint(time.time()) + <constant>.PEP / Ruff: PR body asserts
ruff check+ruff format --checkclean. Import ordering respects isort (newtimeplaced correctly among stdlib; new config symbol joined to existingfrom basketball_api.config import settings as _settingsvia an additional import statement — note below).BLOCKERS
None. The fix is correct, minimal, covered by a discriminating regression test, and the runbook correction is epistemically honest about the earlier wrong turn.
NITS
pytest.skipin test #7 is a silent-pass hole. Intest_register_promo_discount_path_sets_expires_at, ifmock_stripe.checkout.Session.create.calledis False, the test callspytest.skip(...). A future refactor that reroutes promo flow away from this branch would turn this regression guard into a permanent no-op without anyone noticing. Preferpytest.fail(...)with a message, or use a stricter assertion that the promo-discount branch was actually taken (e.g., assert on a sentinel side-effect). Not blocking because (a) dev verified the path triggers locally and (b) test #5create_player_checkout_sessioncovers an adjacent shared code path.Split import of
basketball_api.config. Several files now have two lines:These can be collapsed to a single
from basketball_api.config import CHECKOUT_SESSION_TTL_SECONDS, settings as _settings.admin.pyandregister.pyalready did this correctly (from basketball_api.config import CHECKOUT_SESSION_TTL_SECONDS, settings);checkout.py,jersey.py, andtournament_checkout.pydid not. Ruff/isort tolerates both forms but the repo's convention is the combined form based on existingadmin.py/register.pystyle.datetimeis imported but only used once in the test file (for the 6htimedeltain test #8). Fine, just an observation — not worth changing._MIN_TTL_SECONDS = 29 * 24 * 3600vs_MAX_TTL_SECONDS = CHECKOUT_SESSION_TTL_SECONDS. The 29-day floor is generous enough to tolerate CI clock skew; the 30-day ceiling is exact. If a future engineer bumpsCHECKOUT_SESSION_TTL_SECONDSabove 30 days (Stripe hard-caps at 30 formode="payment", so Stripe would reject it, but still), the test ceiling auto-tracks. Good design.SOP COMPLIANCE
488-checkout-session-ttlfollows{issue-number}-{kebab-case-purpose}.envfiles, or credentials committedfix: set 30-day expires_at on all Stripe Checkout Sessions (#488))PROCESS OBSERVATIONS
stripe.Session.retrieveon any of the 18 pending sessions would have shownstatus=expiredimmediately.) Candidate future SOP: "before theorizing, retrieve."VERDICT: APPROVED