feat: mint monthly-fee checkout URLs as Stripe Payment Links (#498) #499
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!499
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "498-monthly-payment-links"
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
Migrate the monthly-fee flow from Stripe Checkout Sessions (24h cap) to Stripe Payment Links (no expiry), mirroring #494's tournament-fee migration. Parents who open a monthly-fee email a day or two late now reach a working Stripe page instead of a dead URL. Three monthly-specific deltas from #494 (
setup_future_usage, per-Order amounts, redirect-preserving email template) are carried over verbatim.Closes #498. Downstream consumer: recovery ticket #497 can use the new helper without additional plumbing.
Changes
src/basketball_api/services/monthly_checkout.py(new) --create_monthly_order_payment_linkhelper. Analog ofservices/tournament_checkout.py::create_tournament_order_payment_link, withsetup_future_usage: "off_session"on the PaymentLink'spayment_intent_data,order.amount_centsdriving the Price (notproduct.price_cents), and Payment Link id + URL persisted onto the Order.src/basketball_api/routes/checkout.py--first_payment_checkoutnow calls the new helper instead ofstripe.checkout.Session.create. Fresh-pending reuse redirects straight toOrder.stripe_checkout_url(no Stripe round-trip needed since Payment Links don't expire). Non-monthly call sites atcheckout.py:247,checkout.py:553,admin.py:2005are untouched.src/basketball_api/routes/webhooks.py--_handle_generic_order_completeddeactivation gate widened additively:ProductCategory.monthlyjoinsProductCategory.tournament. Jersey / tryout / generic continue to skip deactivation.tests/test_first_payment.py-- rewrote mocks for the Payment Link path (stripe.Price.create+stripe.PaymentLink.createlive on the newservices.monthly_checkoutmodule). Addedtest_payment_link_metadata_carries_order_idandtest_payment_link_uses_order_amount_cents. Preservedtest_setup_future_usage_passed_to_stripefor the new path.tests/test_monthly_payment_link_real_stripe.py(new) -- opt-in real-Stripe integration test (BASKETBALL_STRIPE_TEST_API_KEY). Re-fetches the PaymentLink from Stripe and assertspayment_intent_data.setup_future_usage == "off_session"from the authoritative server state, not the local helper kwargs. Covers the #490 regression class that mock-only tests missed.tests/test_monthly_webhook_roundtrip.py(new) -- real-signature webhook roundtrip for monthly, plus a guard test assertingPaymentLink.modifyis NOT called for jersey-category orders (deactivation gate is strictly additive).Design Decisions
setup_future_usage:stripe.PaymentLink.PaymentIntentData-- i.e.stripe.PaymentLink.create(payment_intent_data={"setup_future_usage": "off_session", ...}). Verified againststripe==14.4.1viastripe.PaymentLink.PaymentIntentData.__annotations__. Same shape as the existing Session call'spayment_intent_datakwarg, just on a different Stripe object.stripe.Customer.create: PaymentLink does not accept acustomerkwarg. Whensetup_future_usageis set, Stripe auto-creates a Customer and saves the card at click time. The webhook continues to receivecheckout.session.completedwith the PaymentIntent attached;stripe_customer_idsync, if desired, is a follow-up orthogonal to this PR. Consistent with #494's tournament helper.stripe_checkout_urlcan redirect straight to that URL. NoSession.retrieveequivalent needed. Amount-mismatch path still cancels + mints a fresh link.{base_url}/checkout/first-payment?token=...-- the redirect endpoint now returns the persisted Payment Link URL. Preserves the per-clickcontract_statusre-check required by the issue.Order.stripe_checkout_session_idstores theplink_...id (no migration required). The webhook matcher keys offmetadata.order_id, not this column, so mixed Session / PaymentLink ids are safe.Test Plan
ruff format --check+ruff checkcleantests/test_first_payment.py-- 12/12 passed (8 carried over + 4 new)tests/test_monthly_webhook_roundtrip.py-- 2/2 passed (happy path deactivates link, jersey guard does not)tests/test_tournament_webhook_roundtrip.py+tests/test_checkout.py+tests/test_webhooks.py+tests/test_first_payment_blast.py-- 41/41 passed (no regression on non-monthly paths)tests/test_contract.py+ contract flow tests -- 51/51 passedmainwith same failures)BASKETBALL_STRIPE_TEST_API_KEYso the real-Stripe integration test runs against test-mode Stripe (not mock-only) -- confirms the 24h-cap class of regression is defended server-sideReview Checklist
order_idin Stripe metadata on both the Payment Link and the derived PaymentIntentProductCategory.monthly)amount_centsflows through (Price minted fromorder.amount_cents, notproduct.price_cents)setup_future_usage: "off_session"carried over viapayment_intent_dataBASKETBALL_STRIPE_TEST_API_KEY)CHECKOUT_SESSION_TTL_SECONDSor similar TTL constant introduced (the #490 dead end)Related
Related Notes
feedback_retrieve_before_theorize.md-- first tool on any payment incident is the provider's retrieve API; real-Stripe test enforces this for the monthly path.docs/tournament-billing-runbook.mdcovers the Payment Link operational surface and monthly follows the same conventions.PR #499 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Stripe SDK. PEP 484 type hints used throughout. PEP 257 docstrings well-written and explain "why" (load-bearing rationale for Payment Link choice, monthly-specific deltas, scope boundaries).
from __future__ import annotationsproperly used.Stripe / Payments correctness:
stripe.Price.create+stripe.PaymentLink.createis the correct primitive — Payment Links don't accept inlineprice_data(called out in the docstring). Good.setup_future_usage: "off_session"is correctly carried viapayment_intent_dataon the Payment Link — verified by both the unit test (test_setup_future_usage_passed_to_stripe) and the real-Stripe integration test (test_mint_monthly_payment_link_against_real_stripe) which re-fetches from Stripe to inspect authoritative server state. This addresses the #498 AC and prevents a silent recurring-billing regression.order_idmetadata on both the PaymentLink and the PaymentIntent — belt-and-suspenders matches the tournament helper pattern. Webhook matcher will resolve correctly.order.amount_cents(notproduct.price_cents) — correctly preserves scholarships/prorations. Asserted intest_payment_link_uses_order_amount_cents.stripe_checkout_session_idnow storesplink_.... Documented in the docstring, and the Order model already hasstripe_checkout_url(verified atsrc/basketball_api/models.py:453). No migration required.RedirectResponseis the right call — silentNoneredirect would be a worse failure mode.Webhook gate widening:
routes/webhooks.pyis strictly additive:product.category in (ProductCategory.tournament, ProductCategory.monthly). Tournament branch preserved. Jersey/tryout/generic untouched.test_jersey_order_does_not_deactivate_payment_linktest explicitly proves the gate is category-keyed, not presence-keyed, even when apayment_linkfield is defensively included on a non-monthly/non-tournament event. Excellent regression coverage.product.category.valueso observability remains category-aware.Test coverage:
test_first_payment.pywere rewritten cleanly to patchbasketball_api.services.monthly_checkout.stripeinstead of the route's stripe ref. The eager-import pattern at the top is necessary for@patch("basketball_api.services.monthly_checkout.stripe")to resolve — comment explains why.test_monthly_payment_link_real_stripe.pyfollows the lesson learned from #490 (mocks "passed" against an invalid 30-day TTL that Stripe rejected at runtime). It re-fetches the link from Stripe to inspect server-sidesetup_future_usage, assertsexpires_atis absent, and exercises deactivation. Properly skips ifBASKETBALL_STRIPE_TEST_API_KEYenv var missing — but perfeedback_qa_ci_blockers.md, opt-in skips that go silent in CI are a blocker risk. See SOP COMPLIANCE below.test_monthly_webhook_roundtrip.pyexercises the realstripe.Webhook.construct_eventsignature validator end-to-end, only mocking outbound calls. The complement test (jersey does not trigger deactivation) is a valuable additive-change guard.FastAPI / SQLAlchemy:
db.add+db.flush+ then helper commits — consistent with prior pattern, no transaction leak.getattr(payment_link, "url", None)is defensive against Stripe SDK shape changes; matches the tournament helper.BLOCKERS
None.
NITS
Near-duplicate of
tournament_checkout.py(DRY in payments path). The newservices/monthly_checkout.pyis structurally ~95% identical toservices/tournament_checkout.create_tournament_order_payment_link. Differences:f"First Monthly Payment – {player.name}"vsproduct.namesetup_future_usage: "off_session"topayment_intent_dataparent_id+parent_emailkeys to PaymentIntent metadataTwo payment helpers diverging is a legitimate maintenance risk (the next time a Stripe API change lands — e.g. a new mandatory field — both must be updated). Consider a future refactor to a single
create_order_payment_link(db, order, *, line_item_name=None, setup_future_usage=None, extra_pi_metadata=None)and havetournament_checkout+monthly_checkoutbecome thin wrappers. Not a blocker because (a) the divergence is currently small + well-documented in both modules' module docstrings, (b) the analog_payment_intent_metadataand_ensure_customer_metadatahelpers are scoped private and their signatures already differ. Track as Epilogue subphase perfeedback_nits_to_epilogue.md.Real-Stripe test cleanup:
test_mint_monthly_payment_link_against_real_stripedeactivates the link at the end (good), but does not delete/archive the test-modePriceorProductit creates. Over time test-mode dashboards will accumulateIntegration Test Monthly Feeentries. Tournament analog likely has the same characteristic; tracking-only.Helper accepts
success_urlkwarg but no test exercises override. Defensible (tournament analog also has it for symmetry), but future readers may wonder if it's load-bearing. Consider one-liner test or noqa comment.Bare
getattr(payment_link, "url", None)returnsNonein two places.PaymentLink.createalways returns a URL on success, but the persisted-then-checked path goes:getattrreturns toorder.stripe_checkout_urlif not result.url:and raise 500If Stripe ever returns the object pre-URL (it doesn't today), we'd persist
Noneto the DB before raising. Cosmetic — the order ispendingand the next click will replace it. Tracking-only.SOP COMPLIANCE
498-monthly-payment-linksfollows{issue-number}-{kebab-case-purpose}conventionfeat: mint monthly-fee checkout URLs as Stripe Payment Links (#498)BASKETBALL_STRIPE_TEST_API_KEY, validated to start withsk_test_)routes/checkout.py(first_payment endpoint),routes/webhooks.py(gate widening), newservices/monthly_checkout.py, three test filesBASKETBALL_STRIPE_TEST_API_KEY. Perfeedback_qa_ci_blockers.md, "tests that can't run in CI are blockers, not nits." Confirm CI has this secret wired (Woodpecker repo secret) — otherwise this becomes the same silent-skip pattern that allowed #490's 30-day TTL bug through. The comment in the file says "CI injects this secret for the test step" — please verify a Woodpecker secret exists onforgejo_admin/basketball-apiwith that name. If it does, this checkbox flips to checked.PROCESS OBSERVATIONS
Deployment frequency: Clean analog of the tournament migration (#494/#496). Reusing the proven shape — separate helper module, identical webhook gate widening pattern, identical real-Stripe test pattern — is the right move. Cherry-pickable, low-risk change.
Change failure risk: Low.
test_first_payment_fresh_pending_amount_changed).setup_future_usageis set) — this is documented in the test module docstring, but it's worth manual validation: the next-month off-session charge needs a Customer attached to the PaymentMethod. Recommend post-merge validation perfeedback_validate_before_done.mdconfirms one full end-to-end click → pay → next-month charge cycle in test mode before mass blast.Documentation gaps: The monthly billing runbook (analog of
docs/tournament-billing-runbook.mdreferenced in PR #496) is not updated in this PR. If one exists for monthly, refresh; if not, log a follow-up item to mirror the tournament runbook for monthly. Track as Epilogue subphase.DORA mapping: Resolves a CFR (parents losing 78% of payers from 24h-expiring URLs per #488). Reduces MTTR for the same incident class (Payment Links don't expire, so the recovery loop from #486/#497 becomes unnecessary). Strengthens the payments pipeline observability story Lucas is currently shipping in
290-payment-pipeline-observability.VERDICT: APPROVED
Conditional on Woodpecker secret
BASKETBALL_STRIPE_TEST_API_KEYexisting on the basketball-api repo so the real-Stripe test actually runs in CI (perfeedback_qa_ci_blockers.md). If it doesn't, treat that as a blocker and wire it before merge. All other findings are nits and can be tracked in the Epilogue.QA blocker fix applied (commit
7e646ae).The conditional: QA flagged that
tests/test_monthly_payment_link_real_stripe.pywould silently skip in CI withoutBASKETBALL_STRIPE_TEST_API_KEY— the #490 patternfeedback_qa_ci_blockerswarns against.Resolution:
basketball_stripe_test_api_keyonforgejo_admin/basketball-api(events:push,pull_request). Value sourced from~/secrets/stripe/test-secret-key(verifiedsk_test_*prefix).BASKETBALL_STRIPE_TEST_API_KEY: { from_secret: basketball_stripe_test_api_key }into theteststep environment in.woodpecker.yaml.python3 -c "import yaml; yaml.safe_load(...)").The real-Stripe integration test will now execute against test-mode Stripe in CI on this push. Watching the pipeline for confirmation the test actually runs (not skipped).
7e646ae16c0f6e6cc428