feat: payment recovery for abandoned Stripe registrations #397
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!397
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "389-payment-recovery-abandoned-stripe"
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
Add an admin-triggered endpoint that sends payment recovery emails to parents who started Stripe registration but abandoned the checkout. Creates fresh Stripe checkout sessions so parents can complete payment without re-filling the form. Cash registrations are excluded. A
recovery_email_sentcolumn enforces the send-once constraint at the data layer.Changes
src/basketball_api/models.py— Addedrecovery_email_sentboolean column to Registration model; addedpayment_recoveryto EmailType enumsrc/basketball_api/services/email.py— Newsend_payment_recovery_email()function using MJML template system with plaintext fallbacksrc/basketball_api/routes/admin.py— NewPOST /admin/send-payment-recoveryendpoint following existing outbox pattern (admin-triggered, not cron). Filters onsignup_method="stripe",payment_status=pending,recovery_email_sent=False, andcreated_atolder than configurable threshold (default 2h). Supportstest_emailparam for safe testing.alembic/versions/039_add_recovery_email_sent.py— Migration adding the column and enum valuetests/test_payment_recovery.py— 4 unit tests covering all acceptance criteriaTest Plan
pytest tests/test_payment_recovery.py -v— all 4 tests passPOST /admin/send-payment-recovery?test_email=draneylucas@gmail.comto verify end-to-endReview Checklist
Related Notes
Related
Closes #389
QA Review — PR #397
Files Reviewed
alembic/versions/039_add_recovery_email_sent.pysrc/basketball_api/models.pysrc/basketball_api/services/email.pysrc/basketball_api/routes/admin.pytests/test_payment_recovery.pyFindings
1. Double join in test_email filter path (minor)
admin.pyline ~1870: Whentest_emailis provided, the query does.join(Registration.player).join(Player.parent)after already using.options(joinedload(Registration.player).joinedload(Player.parent)). SQLAlchemy'sjoinedloadadds a LEFT OUTER JOIN implicitly, and the explicit.join()adds another. For single-parenttest_emailusage this is unlikely to cause issues in practice, but it is technically redundant and could produce duplicate rows on more complex data. Consider usingcontains_eagerinstead ofjoinedloadwhen the explicit join path is also present, or restructure sojoinedloadis only applied whentest_emailis not set.2. All acceptance criteria covered
3. Pattern compliance
4. No unrelated changes confirmed — diff is scoped to the 5 files listed.
VERDICT: APPROVE with nit
Finding #1 is a minor nit that does not block merge. The core logic, data model, migration, and test coverage are all correct.
PR #397 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Stripe SDK / Gmail OAuth
Migration (
039_add_recovery_email_sent.py)server_default=sa.text("false"),nullable=False,IF NOT EXISTSon enum ADD VALUE.Model (
models.py)payment_recoveryadded toEmailTypeenum -- consistent with existing pattern.recovery_email_sentcolumn mirrors the existingconfirmation_email_sentpattern exactly. Good.Route (
admin.py)require_admindependency,DEFAULT_TENANT_SLUGtenant lookup, response model.contains_eagervsjoinedloadsplit for thetest_emailfilter path is correct SQLAlchemy -- avoids the double-join problem. Good awareness.register.py(same metadata keys, samesuccess_url/cancel_urlstructure).import stripeis inside the function body. Other files in this codebase (register.py,jersey.py,checkout.py) import stripe at module level. This is a minor inconsistency but not blocking -- it works and avoids loading stripe when the endpoint is not called.datetime.utcnow()is used for the cutoff. The rest of the codebase usesdatetime.now(timezone.utc)in newer code (e.g.,register.py:912,players.py:303,password_reset.py:87).utcnow()is deprecated since Python 3.12. Sincecreated_atusesfunc.now()(server time, naive UTC), the comparison still works correctly, but this should be modernized.Email service (
email.py)escape(parent.name),escape(player.name), etc.) before passing to template. Good.checkout_urlis NOT escaped -- this is correct because it is a URL that needs to be used as-is inhrefattributes. The URL comes from Stripe SDK, not user input.FileNotFoundErrorcatch with plaintext fallback is a nice defensive pattern.db.commit()at the end setsrecovery_email_sent = Trueatomically with the email log insert. However, if the commit fails after the email was already sent via Gmail, the email goes out but the flag is not set, meaning a retry would send a duplicate. This is a known pattern in this codebase (same risk exists in other email functions) -- not a new regression.Tests (
test_payment_recovery.py)autouse=Trueon_seed_tenantis consistent with other test files in this repo.BLOCKERS
None.
test_emailandhours_thresholdare query params used in DB filters, not interpolated into SQL. SQLAlchemy parameterizes these. Not a blocker.require_admindependency. Not a blocker.NITS
datetime.utcnow()is deprecated (Python 3.12+). The newer codebase pattern isdatetime.now(timezone.utc)(seeregister.py:912,players.py:303). Should bedatetime.now(timezone.utc).replace(tzinfo=None)to stay compatible with the naive-UTCcreated_atcolumn, matching the pattern inpassword_reset.py:87. Low risk since the comparison works today.Inline
import stripeinside the function body. Every other file in this codebase doesimport stripeat module level. Minor inconsistency. Similarly,from basketball_api.services.email import send_payment_recovery_emailis imported inside the function rather than at module top with the other email imports (line 30-34 ofadmin.py).Missing MJML template file. The code calls
load_email_template("payment-recovery", ...)but nopayment-recovery.htmltemplate exists in the repo. TheFileNotFoundErrorfallback to plaintext will fire every time. If this is intentional (template to be added later), a comment in the code or a note in the PR body would clarify intent.Hardcoded fallback amounts (
3000for tryout,4000otherwise) at line ~1890 of admin.py. These magic numbers duplicate pricing logic. If pricing changes, this is a second place to update. Consider extracting to a constant or reading from the tenant/product config.No integration test for the endpoint itself. The 4 tests cover the email service function and query filter logic (unit level), but there is no test that hits
POST /admin/send-payment-recoveryvia the test client to verify the full request path (auth, Stripe mock, response shape). This is consistent with how some other admin endpoints are tested in this codebase, so not a blocker, but worth noting.SOP COMPLIANCE
389-payment-recovery-abandoned-stripefollows{issue-number}-{kebab-case-purpose}Closes #389PROCESS OBSERVATIONS
test_emailquery parameter for safe testing before real sends is a good operational pattern.VERDICT: APPROVED