feat: add GET /checkout/first-payment endpoint with Stripe redirect #375
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!375
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "368-checkout-first-payment"
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
GET /checkout/first-payment?token={contract_token}endpoint that validates a signed contract token, calculates the prorated first monthly payment, creates a Stripe Checkout Session withsetup_future_usage: off_sessionto save the card for recurring billing, and returns a 307 redirect to Stripe's hosted checkout page.Changes
src/basketball_api/routes/checkout.py-- added_calculate_prorated_cents()helper andGET /first-paymentendpoint. Imports added:ContractStatus,RedirectResponse. Follows existing Stripe Customer create/reuse and Order creation patterns.tests/test_first_payment.py-- 7 tests covering valid redirect, invalid token (404), unsigned contract (404), duplicate order (409), $180 proration ($15000 cents), null monthly_fee default ($16500 cents), andsetup_future_usageverification.Test Plan
pytest tests/test_first_payment.py -v-- 7/7 passpytest tests/ -x -q-- 758 pass, 1 pre-existing failure intest_templated_email.py::TestEmailTypeEnumValues::test_all_expected_values(from PR #370 addingfirst_paymenttoEmailTypewithout updating the test; confirmed on main before this branch)Review Checklist
ruff formatandruff checkpass+page.svelte:9payment_intent_data.setup_future_usageincluded in Stripe callRelated Notes
test_templated_email.pyfrom PR #370 (not introduced by this PR)Related
QA Review
Verdict: APPROVED (with nits)
What looks good
+page.svelte:9-- verified: $200->$165, $180->$150, $160->$135payment_intent_data={"setup_future_usage": "off_session"}is present -- critical for saving cards for recurring billingcreate_checkout_sessionpatternRedirectResponseas specifiedcontract_tokenmatch ANDcontract_status == signedruff formatandruff checkpasstest_templated_email.py::TestEmailTypeEnumValues) confirmed on main -- not introduced by this PRNits (non-blocking)
No null guard on parent lookup (line 364):
parent = db.query(Parent).filter(Parent.id == player.parent_id).first()-- if parent is somehow None,parent.emailon line 369 crashes withAttributeError. The FK constraint makes this near-impossible with valid data, but a defensiveif not parent: raise HTTPException(500, ...)would match the product null guard pattern above it.Missing test for existing
stripe_customer_idreuse path: All tests create players withoutstripe_customer_id, so theif player.stripe_customer_idbranch (line 365) is never exercised. Not blocking since it's the same pattern as the existingcreate_checkout_sessionendpoint.VERDICT: APPROVED
PR #375 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Stripe SDK / pytest
Endpoint correctness (
checkout.py):GET /first-payment?token={contract_token}-- correctly looks up Player bycontract_token+contract_status == ContractStatus.signed, returns 404 for invalid/unsigned tokens._calculate_prorated_cents()formula:round(monthly_fee * 25 / 30 / 5) * 5 * 100-- matches the westside-contracts reference as documented.monthly_feedefaults to_DEFAULT_MONTHLY_FEE = 200-- correct.Order.status.in_([OrderStatus.paid, OrderStatus.pending])-- matches existingcreate-sessionpattern exactly.db.flush()before Stripe session (to getorder.id) thendb.commit()after storingstripe_checkout_session_id-- correct pattern.RedirectResponse(url=checkout_session.url, status_code=307)-- correct.CRITICAL:
setup_future_usageverification (requirement #8) -- PRESENT and correct:This is in the
stripe.checkout.Session.create()call. Card will be saved for future monthly billing. Verified.Test coverage (
test_first_payment.py-- 359 lines, 8 tests):test_valid_token_redirects_to_stripe-- verifies 307, Location header, Order amount (16500 cents), session ID storedtest_invalid_token_returns_404-- invalid token pathtest_unsigned_contract_returns_404-- offered status rejectiontest_duplicate_order_returns_409-- duplicate preventiontest_180_fee_prorates_to_150-- $180 tier ($15000 cents)test_null_monthly_fee_defaults_to_200-- null default ($16500 cents)test_setup_future_usage_passed_to_stripe-- explicitly verifiespayment_intent_datakwargstest_existing_stripe_customer_reused-- verifiesCustomer.createNOT called whenstripe_customer_idexistsAll 10 requirements from the issue are covered. Test count is 8, which exceeds the 7 stated in the PR body (the 8th test for customer reuse is a bonus).
Stripe mocking approach:
@patch("basketball_api.routes.checkout.stripe")patches at the module level. Tests verify both the happy path redirect and the kwargs passed to Stripe. Solid.BLOCKERS
None. All critical requirements met:
setup_future_usage: "off_session"is present and testedNITS
ProductCategoryimport -- The diff addsContractStatusto the models import but does not showProductCategorybeing added. The endpoint usesProductCategory.monthlyon the product lookup query. The PR states it depends on PR #370 (merged), which likely addsmonthlytoProductCategoryand may also add the import. If the local main I'm reading is stale, this is fine. However, verify thatProductCategoryis imported in the merged branch'scheckout.py. If missing, it would be aNameErrorat runtime.DRY: Stripe Customer create/reuse -- The create-or-reuse-customer block is now duplicated in 4 places across the codebase:
checkout.py(existingcreate-session),checkout.py(newfirst-payment),subscriptions.py, andjersey.py. This is a pre-existing pattern, not introduced by this PR, but worth noting for a future extraction into a shared helper likeget_or_create_stripe_customer(player, parent, db). Consider creating a backlog ticket.No active monthly product (500) -- The endpoint returns HTTP 500 if no active monthly product is found. This is a server misconfiguration error, so 500 is arguably correct. An alternative would be 503 (Service Unavailable) to signal a temporary config issue, but 500 is acceptable.
Unicode em-dash in product name -- The Stripe line item uses
f"First Monthly Payment \u2013 {player.name}"(em-dash). Minor -- works fine in Stripe, just noting for consistency if other product names use hyphens.Logging -- Good structured logging with player name, ID, amount, order ID, and session ID. No concerns.
SOP COMPLIANCE
368-checkout-first-paymentfollows{issue-number}-{kebab-case-purpose}setup_future_usageverification_settings, not hardcodedcheckout.pyandtest_first_payment.pymodifiedfeat: add GET /checkout/first-payment endpoint with Stripe redirect)PROCESS OBSERVATIONS
ProductCategory.monthlyenum value must exist for the product lookup to work. Verify this is on main before merging.test_templated_email.pyfrom PR #370 addingfirst_paymenttoEmailTypewithout updating the test assertion. This should be tracked and fixed separately.VERDICT: APPROVED