feat: add payment_links table and outstanding balance email #519
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
ldraney/basketball-api!519
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "518-add-payment-links-table-and-outstanding"
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
New
payment_linkstable stores persistent Stripe Payment Links per player per product. Backfill script mints links for all signed players. Outstanding balance email blasts parents with per-item Pay Now buttons.Changes
payment_linkstable with unique constraint (player_id, product_id, tenant_id)PaymentLinkmodel,PaymentLinkStatusenum,outstanding_balanceEmailTypescripts/backfill_payment_links.py— mints Stripe links for signed players, creates missing price tiers, copies pending Orders. Dry-run default.query_outstanding_balancesin QUERY_REGISTRY — groups active links by parentsend_outstanding_balance_email— branded email with Player | Item | Amount | Pay Now tablePOST /admin/email/outstanding-balance?test_email=endpointTest Plan
--commitmints Stripe links and inserts rowsReview Checklist
setup_future_usage: off_sessionon monthly links_brand_wrapper(System 1) email stylingRelated Notes
Closes #518
PR #519 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Stripe API / Gmail SDK
This PR adds a
payment_linkstable, a backfill script that mints Stripe Payment Links, a query registry entry, an admin blast endpoint, and a branded email sender. Six files, 733 additions, zero deletions.BLOCKERS
1. Zero test coverage (733 lines of new functionality)
No test file is included in this PR. This is a hard blocker per review policy. The following need tests:
backfill_payment_links.py: dry-run path, commit path with mocked Stripe, idempotent skip when link exists, error handling when_mint_payment_linkraises.admin_send_outstanding_balanceendpoint: happy path (grouped by parent),test_emailfilter, empty links, missing parent email.send_outstanding_balance_email: HTML escaping of player/description, zero-item edge, Gmail send failure.query_outstanding_balances: grouping logic,test_emailfilter, parent with no email skipped.2. Massive DRY violation between
query_outstanding_balancesandadmin_send_outstanding_balanceThe admin route (
admin.pylines 1345-1419) and the query function (email_queries.pylines 281-366) duplicate nearly identical logic:PaymentLinkfiltered by tenant + active status.parent_idusingdict.setdefault.test_emailfilter.The query function (
query_outstanding_balances) is registered inQUERY_REGISTRYbut the admin endpoint does not use it. The endpoint re-implements the same grouping and filtering from scratch. This means:#f0f0f0,#333,#d42026) while the email function uses brand constants (_BRAND_LIGHT,_BRAND_BORDER,_BRAND_RED). These will diverge.Either the endpoint should call the query function, or the query function should be removed from the registry if it is unused. Two parallel implementations of the same business logic is a defect factory.
3. Hardcoded inline styles diverge from brand constants
In
email_queries.py, thequery_outstanding_balancesfunction hardcodes CSS colors directly:Meanwhile
email.pyuses the defined brand constants (_BRAND_RED,_BRAND_BORDER,_BRAND_WHITE, etc.). If brand colors change,query_outstanding_balanceswill render stale colors. This is a correctness risk for a function registered in the query registry and presumably callable from the generic blast path.NITS
4.
db.query(Product).get(order.product_id)is deprecatedIn
backfill_payment_links.pyline ~220,Session.query(Model).get(pk)was deprecated in SQLAlchemy 2.0. Usedb.get(Product, order.product_id)instead.5. Backfill script swallows exceptions with broad
except ExceptionLines ~180 and ~230 in the backfill script catch
Exceptionbroadly, log it, and continue. This is acceptable for a backfill (you want partial progress), but the error counter should be surfaced more prominently. Consider also logging the traceback withlogger.exception()(which it does -- good) but returning a non-zero exit code even on partial success (which it does via theerrorscounter -- also good). No action needed, just noting the pattern.6.
payment_intent_dataon Payment LinksIn
_mint_payment_link,payment_intent_datais set onPaymentLink.create()for monthly links. Per Stripe docs,payment_intent_data.setup_future_usageon Payment Links is valid, but verify this works correctly in test mode before blast. The PR checklist marks this as reviewed, which is good.7. Unique constraint allows
product_id = NULLduplicatesThe unique constraint
(player_id, product_id, tenant_id)will not prevent duplicate rows whereproduct_id IS NULL, because SQL NULL != NULL in unique constraints. Ifbackfill_from_ordersever processes orders without aproduct_id, duplicates can slip through. The backfill code does queryOrder.product_idwithout a null check. Consider whether a partial index or application-level guard is needed.8. Migration downgrade does not remove the
emailtypeenum valueThe downgrade drops the table and the
paymentlinkstatustype, but does not remove'outstanding_balance'from theemailtypeenum. PostgreSQL does not supportALTER TYPE ... REMOVE VALUE, so this is technically correct (you cannot cleanly downgrade enum values in PG), but worth documenting in a comment.9.
_SUCCESS_URLloaded at module import timeThe
_SUCCESS_URLin the backfill script readsFRONTEND_URLat import time viaos.environ.get. If the env var is set after import (unlikely for a script, but possible in test harnesses), the fallback URL will be used silently. Minor, but worth noting.10. The
send_outstanding_balance_emailfunction commits inside itselfLine 2625 of
email.py:db.commit()is called inside the email send function after inserting theEmailLog. But the caller (admin_send_outstanding_balance) does not expect this -- it loops over parents and each send commits independently. If a later send fails, earlier sends are already committed. This is inconsistent with the blast pattern where you might want atomicity. Verify this matches the existing email send pattern in the codebase (it likely does, given othersend_*functions also commit internally, but it is worth confirming).SOP COMPLIANCE
518-add-payment-links-table-and-outstandingfollows{issue-number}-{kebab-case-purpose}conventionwestside-billing-status-2026-05(a note, not a plan slug) and PR #498. No plan slug, which is acceptable since this is standalone work without a plan.PROCESS OBSERVATIONS
feedback_blast_pool_verification) is directly relevant here.feedback_migration_slot_coordination.)VERDICT: NOT APPROVED
Two blockers must be resolved:
query_outstanding_balancesinemail_queries.pyand the inline implementation inadmin_send_outstanding_balance. Either the endpoint should consume the query function, or the query function should be removed. Two parallel implementations of the same grouping/filtering/rendering logic is unacceptable.