feat: Stripe subscription endpoints + webhooks for monthly payments #84
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!84
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "83-stripe-subscription-endpoints"
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
Adds Stripe Subscription management for the club's $200/month player dues. Includes admin endpoints for creating/canceling subscriptions, overview dashboard, webhook processing for billing events, and a player-facing portal link endpoint.
Changes
src/basketball_api/models.py: AddedSubscriptionStatusenum (active/past_due/canceled/none) and three new fields on Player:subscription_status,stripe_customer_id,stripe_subscription_idalembic/versions/009_add_subscription_fields.py: Migration adding the three subscription columns to the players table, chained from 008src/basketball_api/routes/subscriptions.py: New router with 6 endpoints:POST /setup(idempotent product+price creation),POST /{player_id}(create subscription),GET /(list by status),GET /overview(counts + revenue),DELETE /{player_id}(cancel),GET /mine(player portal)src/basketball_api/routes/webhooks.py: Extended webhook handler with 4 new event types:invoice.paid,invoice.payment_failed,customer.subscription.updated,customer.subscription.deletedsrc/basketball_api/main.py: Registered subscriptions router at/api/subscriptionstests/test_subscriptions.py: 26 tests covering all endpoints, webhook event processing, auth enforcement, and edge cases. All Stripe API calls are mocked.Test Plan
pytest tests/test_subscriptions.py -v-- 26 pass)pytest tests/ -v-- 242 pass, no regressions)ruff format+ruff checkcleanReview Checklist
Related
plan-2026-03-08-tryout-prepPhase 9a (Backend subscription endpoints)Review: PR #84 -- Stripe Subscription Endpoints
Summary
Self-review of backend subscription feature. 6 files changed, 1256 insertions, 6 deletions. All 242 tests pass (26 new), ruff clean.
Findings
Model & Migration
SubscriptionStatusenum correctly placed beforeOnboardingStatusto avoid forward-reference issues.checkfirst=True. Downgrade drops columns then enum. Clean.server_default="none"onsubscription_statusensures existing rows get the right default without backfill.Route Design
require_admin = require_role("admin")follows the established pattern fromteams.pyandadmin.py, enabling test overrides.POST /setupprecedesPOST /{player_id}, andGET /mineandGET /overvieware static paths that cannot collide with path-parameter routes (which only use POST/DELETE methods)._get_tenant_or_404helper is duplicated fromteams.py. Acceptable for now -- extracting to a shared module is a future cleanup.Webhook Handler
_process_subscription_eventdispatches to four typed handlers._STRIPE_STATUS_MAPcovers all Stripe subscription statuses including edge cases (unpaid,incomplete,incomplete_expired).checkout.session.completedflow is preserved unchanged.Tests
@patch("basketball_api.routes.subscriptions.stripe").stripe.Webhook.construct_eventlevel, bypassing signature verification cleanly.No Issues Found
tenant_idon all queries.VERDICT: PASS
PR #84 Review
BLOCKERS
1. Webhook tests will fail in CI --
settings.stripe_webhook_secretis emptyThe 5 webhook tests (
TestWebhookSubscriptionEvents) mockstripe.Webhook.construct_eventbut do NOT set or mocksettings.stripe_webhook_secret. The webhook handler (webhooks.py:135) checksif not settings.stripe_webhook_secret:and raises HTTP 503 beforeconstruct_eventis ever called. Sincestripe_webhook_secretdefaults to""inconfig.pyand no test fixture or conftest setsBASKETBALL_STRIPE_WEBHOOK_SECRET, these 5 tests will return 503 instead of the expected 200 in any clean environment.The tests likely passed locally because the dev agent's environment had
BASKETBALL_STRIPE_WEBHOOK_SECRETset as an env var.Fix: Either (a) set
os.environ["BASKETBALL_STRIPE_WEBHOOK_SECRET"] = "whsec_test"in conftest.py alongside the other env vars, or (b) patchbasketball_api.routes.webhooks.settings.stripe_webhook_secretin each webhook test. Option (a) is simpler and matches the existing pattern in conftest.py.Affected tests:
test_invoice_paid_sets_activetest_invoice_payment_failed_sets_past_duetest_subscription_deleted_sets_canceledtest_subscription_updated_syncs_statustest_webhook_ignores_unknown_subscription2.
create_subscriptionassumes subscription is immediatelyactivewithout checking Stripe's responseAt
subscriptions.py:202, the code unconditionally setsplayer.subscription_status = SubscriptionStatus.activeafterstripe.Subscription.create()returns. However, Stripe subscriptions can start inincompletestatus when the first payment requires Strong Customer Authentication (3DS/SCA). The subscription object returned bystripe.Subscription.create()has a.statusfield that should be read and mapped to the local enum instead of hardcodingactive.Fix: Use
_STRIPE_STATUS_MAP(from webhooks.py) or checksubscription.statusto set the correct initial status:This prevents the local DB from showing "active" while Stripe has "incomplete", which would confuse the admin overview and revenue calculation.
NITS
1. Dead code:
_build_webhook_payloadhelper and 4 unused importstests/test_subscriptions.py:166defines_build_webhook_payload()which is never called by any test. The 4 imports at lines 3-6 (hashlib,hmac,json,time) exist solely for this function. Ruff should flag these as unused -- remove the function and its imports.2. Stripe Product is not tenant-scoped
setup_subscription_productcreates a product named "Monthly Club Dues" without any tenant identifier. If multiple tenants use this API, they would share the same Stripe product. This is fine for the current single-tenant deployment but worth noting as a TODO for multi-tenancy. Consider adding tenant slug/name to the product name or metadata.3. Revenue calculation only counts locally-tracked active subscriptions
subscription_overviewcalculatesmonthly_revenue_centsby multiplyingMONTHLY_AMOUNT_CENTSby the count of locally-active players. This could drift from Stripe's actual billing if webhook processing delays cause status mismatches. Not a bug for MVP, but a comment noting this is an estimate (not Stripe's authoritative revenue) would help future developers.4.
cancel_subscriptionclearsstripe_subscription_idbut the webhook handler does the sameWhen an admin cancels via
DELETE /{player_id}, the code callsstripe.Subscription.cancel()and immediately setsstripe_subscription_id = None. Stripe will then fire acustomer.subscription.deletedwebhook, which_handle_subscription_deletedalso handles by settingstripe_subscription_id = None. The second write is a no-op (already None) so it is harmless, but the comment in the code could note this expected double-write to avoid confusion during debugging.5.
_get_tenant_or_404and_get_player_or_404duplicate patterns from teams.pyBoth
subscriptions.pyandteams.pydefine identical tenant/player lookup helpers. Consider extracting these to a sharedroutes/helpers.pymodule to reduce duplication. Low priority.SOP COMPLIANCE
83-stripe-subscription-endpointsreferences issue #83)## Summary,## Changes,## Test Plan,## RelatedCloses #83and plan slugBASKETBALL_STRIPE_WEBHOOK_SECRET-- see Blocker #1)whsec_test_secretin test helper is a fake value)tenant_idparameter (lesson from PR #82 applied)stripe.Webhook.construct_event)/mineendpoint correctly matches by Keycloak email via Parent recordVERDICT: NOT APPROVED
Two blockers must be resolved:
BASKETBALL_STRIPE_WEBHOOK_SECRETset in the test environmentcreate_subscriptionshould readsubscription.statusfrom Stripe's response instead of hardcodingactiveBoth are straightforward fixes. The overall implementation is solid -- good auth guards, proper tenant scoping, clean Pydantic schemas, and thorough test coverage structure. The webhook handler design with
_STRIPE_STATUS_MAPfor mapping Stripe statuses is well done. Just need these two correctness issues addressed before merge.QA blockers resolved in commit
6b65a29:Blocker 1 -- Webhook tests fail without
BASKETBALL_STRIPE_WEBHOOK_SECRETAdded
os.environ.setdefault("BASKETBALL_STRIPE_WEBHOOK_SECRET", "whsec_test")intests/conftest.pyalongside existing env var setup. The webhook handler'sif not settings.stripe_webhook_secret:guard no longer fires a 503 before mocks are reached.Blocker 2 --
create_subscriptionhardcodes status asactiveAdded
_STRIPE_STATUS_MAPdict toroutes/subscriptions.py(same mapping used inwebhooks.py).create_subscriptionnow readssubscription.statusfrom Stripe's response viagetattr(subscription, "status", "active")and maps it through the dict. Handlesincomplete,trialing,unpaid, etc. gracefully without adding new enum values.Nit -- Dead code cleanup
Removed
_build_webhook_payloadhelper and 4 unused imports (hashlib,hmac,json,time) fromtests/test_subscriptions.py.Test fix
Updated 3 mocked
Subscription.createreturn values to includestatus="active"so they exercise the new status-mapping code path correctly.All 242 tests pass.
ruff format+ruff checkclean.PR #84 Review (Re-review after fix cycle)
BLOCKERS
None. Both previously identified blockers have been resolved:
Webhook tests env var -- FIXED.
tests/conftest.pyline 13 now setsBASKETBALL_STRIPE_WEBHOOK_SECRET=whsec_testviaos.environ.setdefaultbefore settings import. The webhook handler's 503 guard no longer fires before mocks are reached.Hardcoded active status -- FIXED.
_STRIPE_STATUS_MAPdict inroutes/subscriptions.py(lines 23-31) maps all 7 Stripe subscription statuses (active,past_due,canceled,unpaid,incomplete,incomplete_expired,trialing).create_subscriptionreadssubscription.statusfrom the Stripe response (line 212) and maps through the dict (line 213) instead of hardcoding.Dead code removal -- Confirmed.
_build_webhook_payloadreturns zero matches across the entire repo.NITS
Duplicated
_STRIPE_STATUS_MAP-- The same map is defined in bothroutes/subscriptions.py(lines 23-31) androutes/webhooks.py(lines 17-24). The two copies are not identical:subscriptions.pyincludes"trialing"whilewebhooks.pydoes not. Consider extracting to a shared location (e.g.,models.pyor a shared constants module) to keep them in sync.Inconsistent fallback defaults --
subscriptions.pyline 213 uses_STRIPE_STATUS_MAP.get(stripe_status, SubscriptionStatus.active)(fallback toactive), whilewebhooks.pyline 96 uses_STRIPE_STATUS_MAP.get(stripe_status, SubscriptionStatus.none)(fallback tonone). Both are defensible in their respective contexts (optimistic for creation, conservative for webhooks), but worth a comment explaining the intent.PR body missing
## Relatedsection -- The SOP template calls for a## Relatedsection referencing the plan slug. The issue title mentions "Phase 9a" but the PR body does not have a Related section linking to the plan. Non-blocking for this review.SOP COMPLIANCE
83-stripe-subscription-endpointsreferences issue #83)## Summary,## Changes,## Test Plan## Relatedsection with plan slug reference.envfiles, or credentials committedVERDICT: APPROVED
Both blockers from the previous review are resolved. The status mapping fix correctly handles all Stripe subscription states. The nits (DRY map duplication, missing Related section) are non-blocking and can be addressed in a follow-up.