feat: mint tournament-fee checkout URLs as Stripe Payment Links (#494) #496
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!496
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "494-payment-links-tournament"
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
Replaces the tournament-fee mint path in
services/tournament_checkout.pywithstripe.PaymentLink.createso blast URLs no longer hit Stripe's 24h Checkout Session cap that stranded 17 Utah Invitational orders in PR #490. The webhook handler is wired to deactivate the Payment Link oncheckout.session.completedso leaked URLs can't double-charge. Scope is strictly tournament-fee — jersey, monthly, tryout, generic checkout, and admin-regen flows are untouched (they'll be handled separately; their latency profile tolerates the 24h cap).Addresses Forgejo issue
forgejo_admin/basketball-api#494. First consumer is the#486Utah Invitational recovery ticket, which can now mint 17 Payment Links through the same blessed helper.Changes
src/basketball_api/services/tournament_checkout.py— rewrote helper to mintstripe.Price.create+stripe.PaymentLink.createwithorder_id/player_id/product_idmetadata. Exposed ascreate_tournament_order_payment_link; keptcreate_tournament_order_checkout_sessionas a back-compat alias (recovery ticket #486 uses the old name). Result dataclass gained asession_idalias property so pre-existing destructuring keeps working.src/basketball_api/routes/webhooks.py—_handle_generic_order_completednow deactivates the Stripe Payment Link (stripe.PaymentLink.modify(id, active=False)) after marking an Orderpaid, gated on (a)session.payment_linkbeing populated and (b)product.category == ProductCategory.tournamentso non-tournament flows are untouched. Deactivation failure is logged-and-swallowed so the fulfillment commit never blocks.scripts/regenerate_tournament_orders.py— updated to retire either acs_orplink_stored id (prefix-dispatched), re-mints through the new helper. Remains idempotent + dry-run by default.tests/test_checkout_session_ttl.py— tournament helper test rewritten to assert PaymentLink minting with correct metadata and noexpires_at. The other 7 Checkout-Session call-site tests are untouched and still enforce the 30d TTL invariant for non-tournament flows.tests/test_tournament_webhook_roundtrip.py— now patchesstripe.PaymentLink.create/Price.create/PaymentLink.modify(notSession.create). Synthesized event now carriespayment_linkso the deactivation branch runs; assertsmock_modify.assert_called_once_with(plink_id, active=False). Still uses the realstripe.Webhook.construct_eventHMAC validator.tests/test_payment_link_real_stripe.py— NEW. Real-Stripe integration test required by #494 AC. Talks to Stripe test-mode API, verifies noexpires_aton the minted link, verifies metadata propagation, deactivates and assertsactive=False. Opt-in viaBASKETBALL_STRIPE_TEST_API_KEYenv var. Guards against live keys — refuses anything not prefixedsk_test_.docs/tournament-billing-runbook.md— updated TL;DR + call-site table + "Adding a new tournament-fee call site" to reflect the PaymentLink pivot. Kept legacy TTL section for the still-Checkout-Session flows.Test Plan
ruff check .— all checks passedruff format --check .— 125 files formattedpytest tests/test_checkout_session_ttl.py tests/test_tournament_webhook_roundtrip.py -v— 10/10 pass locally (Postgres backend, real HMAC signature validator in loop).pytest tests/test_payment_link_real_stripe.py -v— skips cleanly without the test key; will exercise the real Stripe path once CI injectsBASKETBALL_STRIPE_TEST_API_KEY.pytest— 1033 pass, 1 skipped, 5 failures pre-existing on main and unrelated to this PR (test_jersey_reminder.py,test_westside_streamlit_ro_role.py,test_first_payment_email.py::test_send_first_payment_email; verified by stashing changes and re-running).scripts/regenerate_tournament_orders.py --tournament-id 2in dry-run against prod, confirm it proposes PaymentLink mints, then--committo recover the 17 Utah Invitational orders (dogfood for #486).AC Traceability (issue #494)
order_idin Stripe metadata — set on PaymentLink +payment_intent_data.metadatabelt-and-suspenders.stripe.PaymentLink.modify(id, active=False)gated on tournament category.create_tournament_order_checkout_sessionalias + updated regen script preserve call signature.routes/jersey.py,routes/checkout.py,routes/register.py,routes/admin.pycheckout-session call sites. They're left for #493 (which will revert the broken 30d TTL).order_id→Order.status=paid) — covered by roundtrip test.PaymentLink.modifycalled once withactive=False.tests/test_payment_link_real_stripe.py._handle_generic_order_completed(db, session_data)still matches via metadata, no new parameters.Review Checklist
stripe_checkout_session_idcolumn to storeplink_...id)sk_test_prefix assertion)docs/tournament-billing-runbook.md)Related Notes
project-pal-e-platform— basketball-api is a downstream consumer of the platform; no platform changes requiredfeedback_retrieve_before_theorize.md— #490 was mocked-only coverage missing Stripe's server-side 24h cap; this PR ships a real-Stripe integration test so the same class of bug can't recurfeedback_migration_slot_coordination.md— reusedstripe_checkout_session_idcolumn to avoid alembic migration collision with any parallel branchesproject_seven_pillar_validation.md— payment pipeline observability hardening continues (ties to recent #290 alert work)Related
PR #496 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Stripe SDK. Payment-integration code on the hot path — correctness matters more than style here.
Scope audit (tournament-only invariant) — verified clean:
routes/jersey.py,routes/checkout.py,routes/register.py,routes/admin.pyare untouched. Their Checkout Session call sites remain. Good.services/tournament_checkout.pyis the onlyPaymentLink.createcall site insrc/. Good — the helper stays the single source of truth, now for a different primitive.session.payment_linkbeing present ANDproduct.category == ProductCategory.tournament. Non-tournament orders cannot accidentally triggerPaymentLink.modify. Good.PaymentLink.createmetadata correctness:order_id/player_id/product_idset on the PaymentLink itself (line 136 of the new helper) — this is what Stripe propagates ontocheckout.session.completed.metadatawhen a parent pays.payment_intent_data.metadatawith additionalparent_email. This matches the existing webhook matcher semantics at_handle_generic_order_completed(line 194 ofwebhooks.py) which keys offsession_data["metadata"]["order_id"]. Matcher will still fire. Good.success_urlwired through viaafter_completion.redirect.url. Cancel URL intentionally dropped (PaymentLinks have no cancel concept) — doc comment on the alias explains this clearly. Accurate.Deactivation placement:
_handle_generic_order_completed— fired synchronously afterorder.status = paidbut beforedb.commit(). That's the right sequence: we want the Order row committed even if deactivation fails, and the exception handler does exactly that (logged-and-swallowed). No race-prone side-channel. Good.db.commit(). IfPaymentLink.modifysucceeds but the commit later fails, we end up with a deactivated Stripe link for an un-fulfilled order. That's a VERY narrow window (nothing between the two can realistically fail at this point) and the worse alternative (deactivate after commit, fail, leak a charged URL) is also ugly. Acceptable tradeoff — parents don't double-pay on a paid order, so deactivating early is the right call.Real-Stripe integration test (
tests/test_payment_link_real_stripe.py):BASKETBALL_STRIPE_TEST_API_KEY—pytest.mark.skipifat module level means localpytestruns skip cleanly. Good.assert key.startswith("sk_test_")) is load-bearing. This is the safety net the #490 incident was missing. Keep this assertion — it is what makes the test shippable to CI without fear.stripe.PaymentLink.retrieve(...)and asserts against server-side representation (not just the local return value). This is the coverage #490 lacked. Good.active is Falseaftermodify— this is the contract that backs the webhook deactivation hook. Good.after/finallythat also deactivatespricecould tidy. NIT only.Webhook roundtrip test (
tests/test_tournament_webhook_roundtrip.py):Session.create/Customer.createtoPaymentLink.create/Price.create/PaymentLink.modify. Correct set._build_checkout_completed_eventnow carriespayment_linktop-level field when supplied — this matches how Stripe actually populates sessions that materialized from PaymentLink clicks. The webhook's deactivation gate readssession_data.get("payment_link")so this fixture exercises the real branch. Good.mock_modify.assert_called_once_with(fake_payment_link_id, active=False)is the contract assertion that mocks-only #490 missed. Present and correct.stripe.Webhook.construct_eventHMAC validation — the one mock boundary is outbound Stripe API, exactly right.result.session_id == fake_payment_link_idasserted. Good — catches any regression in the alias property.Regen script (
scripts/regenerate_tournament_orders.py):plink_→PaymentLink.modify(active=False),cs_→Session.expire) handles the mixed state correctly during the #486 recovery — some of the 17 Utah Invitational orders havecs_...ids from the original blast, some haveplink_...if this PR has been partially applied._stripe_object_has_order_idhandles both object types. Good.Runbook (
docs/tournament-billing-runbook.md):create_tournament_order_payment_linkwith legacy alias explicitly documented. Accurate.Model coverage check:
order.stripe_checkout_url = payment_link.url. Verifiedstripe_checkout_urlis a realString(500)column on theOrdermodel (migration 047,models.py:453) and is used byservices/email_queries.pyfor the blast URL join. The blast pipeline will therefore see the Payment Link URL without further changes. Good — no new migration needed.stripe_checkout_session_idcolumn holdingplink_...ids:metadata.order_id, not this column, so the column is purely for human/script lookup. Prefix-dispatched retirement in the regen script handles both id shapes. A future rename tostripe_checkout_object_id(or similar) is NOT required for this PR — noting as a possible follow-up if the schema stays heterogeneous long term.BLOCKERS
None.
NITS
expires_aton the 7 non-tournament call sites. The merge sequence #495 → #496 is correct, and #496 will need a rebase after #495 lands. Expect trivial conflicts indocs/tournament-billing-runbook.md(legacy TTL section) and possibly intests/test_checkout_session_ttl.pyaround the non-tournament assertions. Please rebase and re-run the full suite post-#495.tests/test_payment_link_real_stripe.pyleaves the mintedPriceobject undeactivated. Prices cannot be "deleted" via the public API, only archived. Considerstripe.Price.modify(price.id, active=False)in a teardown so the Stripe test-mode dashboard stays tidy over many CI runs. Non-blocking.after_completion.redirect.urlonly supports one URL — nocancel_urlbranch. That's a Stripe constraint, not a PR issue, but worth a one-line note in the helper docstring so future readers don't hunt for a missing cancel redirect._ensure_customer_metadatahelper loses the StripeCustomercreation path from the old helper. That was previously cachingplayer.stripe_customer_id. We now never create a Stripe Customer for tournament-fee orders. That's consistent with PaymentLinks accepting a guest checkout — but if any downstream reconciliation code (Stripe dashboard filtering, accounting exports) relied onstripe_customer_idbeing set onPlayer, this is a silent behavior change. Please confirm no reporting/filter code upstream depends onplayer.stripe_customer_idbeing populated by the tournament path. (Jersey/monthly/tryout paths still set it, so steady-state players will still have one — this only affects tournament-only parents.)logger.info(" unknown stripe object prefix for %s -- skipping", obj_id)uses leading-space indentation for visual nesting. Harmless but unconventional — considerlogger.info("regen: unknown stripe object prefix %s, skipping", obj_id).SOP COMPLIANCE
494-payment-links-tournament✓BASKETBALL_STRIPE_TEST_API_KEYis env-var referenced, never written ✓sk_test_prefix assertion) ✓stripe_checkout_session_id+ pre-existingstripe_checkout_url) ✓create_tournament_order_checkout_session→create_tournament_order_payment_link) ✓PROCESS OBSERVATIONS
VERDICT: APPROVED
Merge-order caveat: land #495 first, then rebase #496 onto the new main, re-run the suite, then merge. No code changes requested on #496 itself.
a8bc0bf7e04c579ee984