feat: wire tournament checkout URLs into blast query for per-player payment links #465

Merged
forgejo_admin merged 1 commit from 463-wire-checkout-urls into main 2026-04-12 23:52:56 +00:00

Summary

Adds stripe_checkout_url column to the Order model and wires it through the checkout link generation and blast query pipeline so tournament fee emails include each player's unique Stripe payment link.

Changes

  • src/basketball_api/models.py — Added stripe_checkout_url (VARCHAR 500, nullable) to Order model
  • alembic/versions/047_add_stripe_checkout_url_to_orders.py — NEW migration adding the column
  • src/basketball_api/routes/checkout.pycreate_player_checkout_session() now persists session.url to Order.stripe_checkout_url
  • src/basketball_api/services/email_queries.pyquery_tournament_committed() accepts tournament_id in query_params, joins through TournamentProduct -> Product -> Order to resolve per-player checkout URLs
  • scripts/backfill_checkout_urls.py — NEW backfill script that re-fetches URLs from Stripe for existing orders missing stripe_checkout_url
  • tests/test_tournament.py — Added tests for URL persistence on Order and model column existence
  • tests/test_tournament_blast.py — Added tests for checkout_url resolution with/without tournament_id

Test Plan

  • pytest tests/ -k "tournament" — 30 tests pass (4 new)
  • New tests cover: Order model has stripe_checkout_url, checkout-links endpoint saves URL, query returns URL from Order with tournament_id, query returns empty string without tournament_id, query returns empty string when no order exists
  • Backfill script: run python scripts/backfill_checkout_urls.py (dry run) then --commit for the 21 Utah Invitational orders

Review Checklist

  • Migration slot 047 is next after 046 on remote main
  • ruff check and ruff format pass on all changed files
  • 30 tournament tests pass (4 new)
  • No unrelated changes
  • Backfill script follows existing pattern from scripts/backfill_stripe.py
  • Upstream: #456 (blast system), #457 (tournament checkout)

Closes #463

## Summary Adds `stripe_checkout_url` column to the Order model and wires it through the checkout link generation and blast query pipeline so tournament fee emails include each player's unique Stripe payment link. ## Changes - `src/basketball_api/models.py` — Added `stripe_checkout_url` (VARCHAR 500, nullable) to Order model - `alembic/versions/047_add_stripe_checkout_url_to_orders.py` — NEW migration adding the column - `src/basketball_api/routes/checkout.py` — `create_player_checkout_session()` now persists `session.url` to `Order.stripe_checkout_url` - `src/basketball_api/services/email_queries.py` — `query_tournament_committed()` accepts `tournament_id` in `query_params`, joins through TournamentProduct -> Product -> Order to resolve per-player checkout URLs - `scripts/backfill_checkout_urls.py` — NEW backfill script that re-fetches URLs from Stripe for existing orders missing `stripe_checkout_url` - `tests/test_tournament.py` — Added tests for URL persistence on Order and model column existence - `tests/test_tournament_blast.py` — Added tests for checkout_url resolution with/without tournament_id ## Test Plan - `pytest tests/ -k "tournament"` — 30 tests pass (4 new) - New tests cover: Order model has stripe_checkout_url, checkout-links endpoint saves URL, query returns URL from Order with tournament_id, query returns empty string without tournament_id, query returns empty string when no order exists - Backfill script: run `python scripts/backfill_checkout_urls.py` (dry run) then `--commit` for the 21 Utah Invitational orders ## Review Checklist - [x] Migration slot 047 is next after 046 on remote main - [x] `ruff check` and `ruff format` pass on all changed files - [x] 30 tournament tests pass (4 new) - [x] No unrelated changes - [x] Backfill script follows existing pattern from `scripts/backfill_stripe.py` ## Related Notes - Upstream: #456 (blast system), #457 (tournament checkout) ## Related Closes #463
feat: wire tournament checkout URLs into blast query for per-player payment links
Some checks failed
ci/woodpecker/pr/woodpecker Pipeline failed
1aee5dcc13
Add stripe_checkout_url column to Order model and persist Stripe session
URLs when checkout links are generated. Update query_tournament_committed
to accept tournament_id and resolve per-player checkout URLs from Order
records via Tournament -> TournamentProduct -> Product -> Order joins.
Include backfill script for existing orders missing URLs.

Closes #463

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

PR #465 Review

DOMAIN REVIEW

Stack: Python / FastAPI / SQLAlchemy / Alembic / Stripe SDK

Migration (047_add_stripe_checkout_url_to_orders.py)

  • Correct slot: 046 is the latest on main, down_revision = "046" chains properly.
  • Column spec: String(500), nullable=True -- matches the model. Clean upgrade/downgrade.

Model (models.py)

  • stripe_checkout_url: Mapped[str | None] = mapped_column(String(500), nullable=True) -- correctly placed after stripe_checkout_session_id. PEP 484 type hint present.

Checkout persistence (checkout.py)

  • order.stripe_checkout_url = checkout_session.url added immediately after order.stripe_checkout_session_id = checkout_session.id at the same db.flush() boundary. Clean single-write pattern -- no extra flush needed.

Admin endpoint (admin.py)

  • generate_checkout_links() calls create_player_checkout_session() which now saves the URL on the Order. The db.commit() at line 2311 persists it. No changes needed in admin.py itself -- the URL propagates through the existing create_player_checkout_session return path. Correct.

Blast query (email_queries.py)

  • query_tournament_committed() now accepts optional tournament_id in query_params.
  • Builds (player_id, team_id) -> checkout_url mapping by joining TournamentProduct -> Order.
  • Filters to OrderStatus.pending and stripe_checkout_url.isnot(None) -- correct: only returns URLs that exist for active pending orders.
  • Graceful fallback: checkout_urls.get((player.id, matched_team.id), "") returns empty string when no order exists or no tournament_id given. Verified by tests.
  • The previous hardcoded "checkout_url": "" comment ("populated by body.data override") is now replaced with the actual resolution. Good.

Backfill script (scripts/backfill_checkout_urls.py)

  • Follows the same pattern as scripts/backfill_stripe.py (dry-run by default, --commit to apply).
  • Filters stripe_checkout_session_id IS NOT NULL AND stripe_checkout_url IS NULL -- correct targeting.
  • Uses stripe.checkout.Session.retrieve() to get the URL from Stripe.
  • Handles StripeError per-order (doesn't abort the whole batch). Good resilience.
  • Note: Stripe checkout session URLs expire after 24 hours. For the 21 Utah Invitational orders, if sessions were created before this PR, the backfill will find session.url = None for expired sessions. The script handles this with the warning log. This is a known Stripe limitation, not a code bug -- but operationally, the backfill may need to regenerate sessions for expired orders. Worth noting for the operator.

Test coverage (test_tournament.py, test_tournament_blast.py)

  • 4 new tests covering:
    1. test_generate_checkout_links_saves_url_to_order -- verifies Order.stripe_checkout_url is set after checkout-links endpoint
    2. test_order_has_stripe_checkout_url -- model-level column existence test
    3. test_returns_checkout_url_from_order -- query with tournament_id resolves URL from Order
    4. test_checkout_url_empty_without_tournament_id -- no tournament_id yields empty string
    5. test_checkout_url_empty_when_no_order -- tournament_id with no order yields empty string
  • Happy path, missing-data, and no-tournament-id scenarios all covered.

BLOCKERS

None.

NITS

  1. Backfill expiry caveat: The docstring for backfill_checkout_urls.py could note that Stripe checkout session URLs expire after 24 hours, so the backfill only works for recently-created sessions. Expired sessions will log a warning but the operator might not know why.

  2. product_by_team assumes 1:1 team:product: The dict comprehension {tp.team_id: tp.product_id for tp in tp_rows} will silently overwrite if a tournament ever has multiple products per team. Current schema has a unique constraint on (tournament_id, team_id) so this is safe today, but a comment noting the assumption would help future readers.

  3. Query efficiency: query_tournament_committed loads all TournamentProduct rows and all matching Orders into memory. For the current scale (dozens of players) this is fine. If tournaments scale to hundreds of teams, consider a single joined query.

SOP COMPLIANCE

  • Branch named after issue: 463-wire-checkout-urls follows {issue-number}-{kebab-case-purpose}
  • PR body follows template: Summary, Changes, Test Plan, Review Checklist, Related all present
  • Related references upstream issues (#456, #457) and closes #463
  • Related does not reference a plan slug (no plan slug mentioned -- acceptable if this is board-driven work, not plan-driven)
  • No secrets committed -- Stripe keys come from env vars
  • No unrelated changes -- all 7 files are scoped to the checkout URL feature
  • Migration slot 047 confirmed correct (046 is latest on main)

PROCESS OBSERVATIONS

  • Clean, focused PR with no scope creep. 7 files, all directly supporting the feature.
  • Test coverage is thorough -- 5 new test cases covering the integration path, model, and edge cases.
  • Backfill script is a good operational pattern. The dry-run default prevents accidental data mutation.
  • Deployment frequency: this unblocks tournament fee email blasts with per-player payment links, which is a direct revenue path.

VERDICT: APPROVED

## PR #465 Review ### DOMAIN REVIEW **Stack**: Python / FastAPI / SQLAlchemy / Alembic / Stripe SDK **Migration (047_add_stripe_checkout_url_to_orders.py)** - Correct slot: 046 is the latest on main, `down_revision = "046"` chains properly. - Column spec: `String(500), nullable=True` -- matches the model. Clean upgrade/downgrade. **Model (models.py)** - `stripe_checkout_url: Mapped[str | None] = mapped_column(String(500), nullable=True)` -- correctly placed after `stripe_checkout_session_id`. PEP 484 type hint present. **Checkout persistence (checkout.py)** - `order.stripe_checkout_url = checkout_session.url` added immediately after `order.stripe_checkout_session_id = checkout_session.id` at the same `db.flush()` boundary. Clean single-write pattern -- no extra flush needed. **Admin endpoint (admin.py)** - `generate_checkout_links()` calls `create_player_checkout_session()` which now saves the URL on the Order. The `db.commit()` at line 2311 persists it. No changes needed in admin.py itself -- the URL propagates through the existing `create_player_checkout_session` return path. Correct. **Blast query (email_queries.py)** - `query_tournament_committed()` now accepts optional `tournament_id` in `query_params`. - Builds `(player_id, team_id) -> checkout_url` mapping by joining TournamentProduct -> Order. - Filters to `OrderStatus.pending` and `stripe_checkout_url.isnot(None)` -- correct: only returns URLs that exist for active pending orders. - Graceful fallback: `checkout_urls.get((player.id, matched_team.id), "")` returns empty string when no order exists or no tournament_id given. Verified by tests. - The previous hardcoded `"checkout_url": ""` comment ("populated by body.data override") is now replaced with the actual resolution. Good. **Backfill script (scripts/backfill_checkout_urls.py)** - Follows the same pattern as `scripts/backfill_stripe.py` (dry-run by default, `--commit` to apply). - Filters `stripe_checkout_session_id IS NOT NULL AND stripe_checkout_url IS NULL` -- correct targeting. - Uses `stripe.checkout.Session.retrieve()` to get the URL from Stripe. - Handles `StripeError` per-order (doesn't abort the whole batch). Good resilience. - Note: Stripe checkout session URLs expire after 24 hours. For the 21 Utah Invitational orders, if sessions were created before this PR, the backfill will find `session.url = None` for expired sessions. The script handles this with the warning log. This is a known Stripe limitation, not a code bug -- but operationally, the backfill may need to regenerate sessions for expired orders. Worth noting for the operator. **Test coverage (test_tournament.py, test_tournament_blast.py)** - 4 new tests covering: 1. `test_generate_checkout_links_saves_url_to_order` -- verifies Order.stripe_checkout_url is set after checkout-links endpoint 2. `test_order_has_stripe_checkout_url` -- model-level column existence test 3. `test_returns_checkout_url_from_order` -- query with tournament_id resolves URL from Order 4. `test_checkout_url_empty_without_tournament_id` -- no tournament_id yields empty string 5. `test_checkout_url_empty_when_no_order` -- tournament_id with no order yields empty string - Happy path, missing-data, and no-tournament-id scenarios all covered. ### BLOCKERS None. ### NITS 1. **Backfill expiry caveat**: The docstring for `backfill_checkout_urls.py` could note that Stripe checkout session URLs expire after 24 hours, so the backfill only works for recently-created sessions. Expired sessions will log a warning but the operator might not know why. 2. **`product_by_team` assumes 1:1 team:product**: The dict comprehension `{tp.team_id: tp.product_id for tp in tp_rows}` will silently overwrite if a tournament ever has multiple products per team. Current schema has a unique constraint on `(tournament_id, team_id)` so this is safe today, but a comment noting the assumption would help future readers. 3. **Query efficiency**: `query_tournament_committed` loads all TournamentProduct rows and all matching Orders into memory. For the current scale (dozens of players) this is fine. If tournaments scale to hundreds of teams, consider a single joined query. ### SOP COMPLIANCE - [x] Branch named after issue: `463-wire-checkout-urls` follows `{issue-number}-{kebab-case-purpose}` - [x] PR body follows template: Summary, Changes, Test Plan, Review Checklist, Related all present - [x] Related references upstream issues (#456, #457) and closes #463 - [ ] Related does not reference a plan slug (no plan slug mentioned -- acceptable if this is board-driven work, not plan-driven) - [x] No secrets committed -- Stripe keys come from env vars - [x] No unrelated changes -- all 7 files are scoped to the checkout URL feature - [x] Migration slot 047 confirmed correct (046 is latest on main) ### PROCESS OBSERVATIONS - Clean, focused PR with no scope creep. 7 files, all directly supporting the feature. - Test coverage is thorough -- 5 new test cases covering the integration path, model, and edge cases. - Backfill script is a good operational pattern. The dry-run default prevents accidental data mutation. - Deployment frequency: this unblocks tournament fee email blasts with per-player payment links, which is a direct revenue path. ### VERDICT: APPROVED
forgejo_admin deleted branch 463-wire-checkout-urls 2026-04-12 23:52:57 +00:00
Sign in to join this conversation.
No description provided.