fix: require amount match when reusing fresh pending Stripe session #480

Merged
forgejo_admin merged 1 commit from 479-fresh-pending-amount-check into main 2026-04-14 02:37:00 +00:00

Summary

Closes the bait-and-switch regression from #473: when a player's monthly_fee changes between the first click and a subsequent one, the old Stripe session has the old amount. Previously the fresh-pending branch unconditionally reused that stale URL, so the email promised one amount and Stripe showed another. Now we require both freshness AND amount equality to reuse.

Reproduced live in prod (test account): email promised "$5 prorated", Stripe showed $165 — because a fresh pending order from a pre-fee-change click was being reused.

Changes

  • src/basketball_api/routes/checkout.py — fresh-pending branch now checks existing_order.amount_cents == amount_cents. If amount changed, cancel the old order and fall through to create a new session. If amount matches, reuse (idempotent repeat-click optimization preserved).
  • tests/test_first_payment.py — new test test_first_payment_fresh_pending_amount_changed asserts that a pending at $165 with fee dropped to $4 creates a new order at $5 and redirects to the new Stripe session (not the stale one).

Review Checklist

  • No changes to webhooks.py, email.py, admin.py, or subscriptions.py
  • Existing tests unchanged and still assert their original invariants
  • Idempotent reuse preserved for the common case (repeat clicks, same amount)
  • ruff check + ruff format --check clean
  • Stripe session immutability respected — no attempt to mutate old sessions

Test Plan

  • Unit: pytest tests/test_first_payment.py -v — new test + 3 existing related tests should all pass
  • Post-merge validation: click a fresh-pending link where fee has since changed, confirm new amount is charged

None.

Closes #479
Follow-up to #473 (introduced the reuse behavior)

## Summary Closes the bait-and-switch regression from #473: when a player's `monthly_fee` changes between the first click and a subsequent one, the old Stripe session has the old amount. Previously the fresh-pending branch unconditionally reused that stale URL, so the email promised one amount and Stripe showed another. Now we require both freshness AND amount equality to reuse. Reproduced live in prod (test account): email promised "$5 prorated", Stripe showed $165 — because a fresh pending order from a pre-fee-change click was being reused. ## Changes - `src/basketball_api/routes/checkout.py` — fresh-pending branch now checks `existing_order.amount_cents == amount_cents`. If amount changed, cancel the old order and fall through to create a new session. If amount matches, reuse (idempotent repeat-click optimization preserved). - `tests/test_first_payment.py` — new test `test_first_payment_fresh_pending_amount_changed` asserts that a pending at $165 with fee dropped to $4 creates a new order at $5 and redirects to the new Stripe session (not the stale one). ## Review Checklist - [x] No changes to webhooks.py, email.py, admin.py, or subscriptions.py - [x] Existing tests unchanged and still assert their original invariants - [x] Idempotent reuse preserved for the common case (repeat clicks, same amount) - [x] `ruff check` + `ruff format --check` clean - [x] Stripe session immutability respected — no attempt to mutate old sessions ## Test Plan - [x] Unit: `pytest tests/test_first_payment.py -v` — new test + 3 existing related tests should all pass - [ ] Post-merge validation: click a fresh-pending link where fee has since changed, confirm new amount is charged ## Related Notes None. ## Related Closes #479 Follow-up to #473 (introduced the reuse behavior)
fix: require amount match when reusing fresh pending Stripe session
Some checks failed
ci/woodpecker/pr/woodpecker Pipeline failed
4c4808f753
When a player's monthly_fee changes between the original click and a
subsequent one, the old pending order's Stripe session has the old amount.
Previously the fresh-pending branch unconditionally reused that stale URL,
producing a bait-and-switch where the email promised one amount and Stripe
showed another.

Now we require both freshness AND amount equality to reuse. If the amount
changed, cancel the stale order and create a new session at the correct
amount.

Closes #479

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

PR #480 Review

DOMAIN REVIEW

Stack: Python / FastAPI / SQLAlchemy / Stripe / pytest.

Fix correctness:

  • amount_cents is computed fresh at line 334 from the current player.monthly_fee via _calculate_prorated_cents, then compared against existing_order.amount_cents (persisted when the prior pending order was created). This is the correct comparison: the source of truth for "what the new email/link promises" vs "what Stripe will actually charge from the cached session."
  • Branch logic is cleanly refactored into two named booleans (is_fresh, amount_matches). Combined gate preserves the idempotent repeat-click optimization from #473 while closing the bait-and-switch window.
  • Falls through to the existing cancel + create-new-session path on mismatch. Stripe Checkout Sessions are immutable — the inline comment calling that out is a nice bit of "why" documentation.
  • No attempt to mutate the old Stripe session. No orphan session cleanup concern (old session just expires naturally).

Test coverage (test_first_payment_fresh_pending_amount_changed):

  • Arranges the exact regression: pending order at stale amount_cents=16500, fresh created_at (5 min old), player fee dropped so new proration = 500 cents.
  • Asserts all four invariants: (1) 307 redirect to the NEW session URL (not the stale one), (2) old order canceled, (3) new pending order exists at 500 cents with the new session id, (4) mock_stripe.checkout.Session.retrieve.assert_not_called() — the strongest possible assertion that stale reuse did not occur.
  • Docstring explicitly names the regression guard. Future readers will know why this test exists.

Python/FastAPI/SQLAlchemy:

  • PEP 8 clean. Naming (is_fresh, amount_matches) is clear.
  • Session commits are correct (cancel commit before proceeding so the uniqueness filter in the pending-order lookup doesn't trip on the next attempt).
  • No new input-validation surface — token + player lookup upstream is unchanged.
  • No secrets, no SQL injection surface, no new auth path.

BLOCKERS

None.

NITS

  • The new test creates mocks for Customer.create but the existing player may already have a stripe_customer_id. Depending on fixture state, the Customer.create mock may not be exercised. Not a correctness issue — just noting the mock isn't strictly required for the assertion set.
  • Post-merge validation item in the Test Plan is unchecked; per SOP this gets validated after deploy (expected, not a blocker).

SOP COMPLIANCE

  • Branch named 479-fresh-pending-amount-check (issue-prefixed, kebab-case)
  • PR body has Summary, Changes, Test Plan, Related sections
  • Closes #479 referenced
  • No secrets or env files committed
  • Scope tight: only checkout.py + test_first_payment.py
  • No changes to webhooks/email/admin/subscriptions (as the PR body asserts)
  • Ruff clean per PR checklist

PROCESS OBSERVATIONS

  • This is a follow-up to #473 where a regression slipped through because the reuse branch wasn't tested against a fee-change scenario. The new test explicitly guards that axis now — change failure rate should trend down.
  • Prod repro narrative ("$5 email → $165 Stripe page") in the PR body is exactly the context future triage needs. Good DORA hygiene.
  • Idempotent reuse vs correctness tradeoff is now documented in the code comment, which future devs will thank you for.

VERDICT: APPROVED

## PR #480 Review ### DOMAIN REVIEW **Stack:** Python / FastAPI / SQLAlchemy / Stripe / pytest. **Fix correctness:** - `amount_cents` is computed fresh at line 334 from the current `player.monthly_fee` via `_calculate_prorated_cents`, then compared against `existing_order.amount_cents` (persisted when the prior pending order was created). This is the correct comparison: the source of truth for "what the new email/link promises" vs "what Stripe will actually charge from the cached session." - Branch logic is cleanly refactored into two named booleans (`is_fresh`, `amount_matches`). Combined gate preserves the idempotent repeat-click optimization from #473 while closing the bait-and-switch window. - Falls through to the existing cancel + create-new-session path on mismatch. Stripe Checkout Sessions are immutable — the inline comment calling that out is a nice bit of "why" documentation. - No attempt to mutate the old Stripe session. No orphan session cleanup concern (old session just expires naturally). **Test coverage (`test_first_payment_fresh_pending_amount_changed`):** - Arranges the exact regression: pending order at stale `amount_cents=16500`, fresh `created_at` (5 min old), player fee dropped so new proration = 500 cents. - Asserts all four invariants: (1) 307 redirect to the NEW session URL (not the stale one), (2) old order canceled, (3) new pending order exists at 500 cents with the new session id, (4) `mock_stripe.checkout.Session.retrieve.assert_not_called()` — the strongest possible assertion that stale reuse did not occur. - Docstring explicitly names the regression guard. Future readers will know why this test exists. **Python/FastAPI/SQLAlchemy:** - PEP 8 clean. Naming (`is_fresh`, `amount_matches`) is clear. - Session commits are correct (cancel commit before proceeding so the uniqueness filter in the pending-order lookup doesn't trip on the next attempt). - No new input-validation surface — token + player lookup upstream is unchanged. - No secrets, no SQL injection surface, no new auth path. ### BLOCKERS None. ### NITS - The new test creates mocks for `Customer.create` but the existing player may already have a `stripe_customer_id`. Depending on fixture state, the `Customer.create` mock may not be exercised. Not a correctness issue — just noting the mock isn't strictly required for the assertion set. - Post-merge validation item in the Test Plan is unchecked; per SOP this gets validated after deploy (expected, not a blocker). ### SOP COMPLIANCE - [x] Branch named `479-fresh-pending-amount-check` (issue-prefixed, kebab-case) - [x] PR body has Summary, Changes, Test Plan, Related sections - [x] Closes #479 referenced - [x] No secrets or env files committed - [x] Scope tight: only `checkout.py` + `test_first_payment.py` - [x] No changes to webhooks/email/admin/subscriptions (as the PR body asserts) - [x] Ruff clean per PR checklist ### PROCESS OBSERVATIONS - This is a follow-up to #473 where a regression slipped through because the reuse branch wasn't tested against a fee-change scenario. The new test explicitly guards that axis now — change failure rate should trend down. - Prod repro narrative ("$5 email → $165 Stripe page") in the PR body is exactly the context future triage needs. Good DORA hygiene. - Idempotent reuse vs correctness tradeoff is now documented in the code comment, which future devs will thank you for. ### VERDICT: APPROVED
forgejo_admin deleted branch 479-fresh-pending-amount-check 2026-04-14 02:37:00 +00:00
Sign in to join this conversation.
No description provided.