Module kit / Billing and credits
Subscription conversion, dunning and self-service cancel
billing-credits/subscription-cancel-and-dunning
| Kind | backend |
|---|---|
| Domain | billing-credits |
| Maturity | unmerged |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, apscheduler-3.11, stdlib-hmac-sha256, payrexx-tokenization-api |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/error-envelope, _shared/scheduler-and-background-work, _shared/alembic-migrations, _shared/test-layout |
| Frontends | billing-credits/ui/subscription-manager |
| Reference implementation |
|
| Measured on | alchemy_labs w1-auto-topup af7a35246dd4a009485bc67b3a13fea6e5a688cc 2026-09-12 |
| Version | 1 |
Purpose
A ScheduledSubscription charges itself on a banked Payrexx token every cycle with no customer interaction, on a 15-minute scheduler sweep that is claim-before-send: next_charge_at is advanced and committed before the PSP is called, so a crash between the charge and the record cannot double-bill the same cycle. A card that keeps failing is bounded, not retried forever: after MAX_CHARGE_FAILURES (4) consecutive declines the subscription parks itself as failed and drops out of every future sweep. A customer who does not want to log in can still cancel: they prove ownership of their email with a one-time code, and only then see and cancel their own subscriptions through a short-lived, cancel-scoped, HMAC-signed token that mints no Labs session. The invariant that makes the money side trustworthy is the committed claim (Gotchas notes where the dunning side does not carry the same care the sibling auto-top-up service does for PSP-silence-versus-decline).
When to use
Read it before touching ScheduledSubscription.status, next_charge_at, charge_failure_count, or the /api/commerce/subscriptions* and /api/commerce/self-service/* routes. Trigger phrases: "recurring charge", "convert now", "dunning", "parked subscription", "subscription failed", "cancel my subscription", "customer can't log in to cancel", "OTP cancel flow", "claim-before-send", "double-bill".
Not this recipe: how the intro_then_sub / subscription tokenization checkout is opened and how preAuthorization + chargeOnAuthorization bank the reusable token (services/commerce/checkout_service.py, Phase 1 commerce checkout); how the webhook verifies a Payrexx transaction and calls fulfill_order, which is where mint_scheduled_subscription is actually invoked (services/commerce/fulfillment_service.py lines 289-296, api/routers/commerce_router.py /orders/webhook/payrexx); the merchant-side Store console that lists/cancels/converts subscriptions (frontend/src/components/commerce/store.tsx, frontend/src/services/commerceService.ts -- a different frontend module than the one this recipe names); refunds (POST /api/orders/{id}/refund, same router, a neighbouring route); the auto top-up service on the same banked-token mechanic but a different domain object (services/commerce/auto_topup_service.py, ADR-0036, a sibling recipe in this same billing-credits domain, authored separately -- its _classify_charge_failure tri-state fix is the one this recipe's Gotchas says the subscription path never adopted).
Stack assumptions
Measured at af7a35246dd4a009485bc67b3a13fea6e5a688cc (backend/requirements.txt): fastapi==0.118.2 (S4), synchronous handlers on DatabaseSession (SQLAlchemy 2.0 sync), Postgres, APScheduler==3.11.0 (S12) running the conversion job inside the process-wide advisory-lock singleton so only one worker charges. The self-service cancel token is signed with stdlib hmac/hashlib.sha256 only -- no JWT library, no external token store. Payrexx is reached through PayrexxService.for_workspace(db, workspace_id) / payrexx_service._make_request; charging a banked token is POST /Transaction/{authorized_tx_id}/ with {"amount": ...} (no re-checkout).
Foundations assumed: S4 (mount_router(app, commerce_router.router, prefix="/api", surfaces=LABS_SURFACES), app_fastapi.py:838), S5 (VerifiedUser/WorkspaceID gate the merchant-side subscription routes), S6 (every merchant route filters on ScheduledSubscription.workspace_id), S7 (the Labs error envelope on every HTTPException this router raises), S12 (the scheduler job and its singleton lock), S8 (the two migrations below).
Environment: SECRET_KEY (core/config.py:58, SecretStr, min_length=32) is the only env var this recipe reads, and only for signing the cancel-scope token (self_service_cancel.py:74, salted with the literal string "commerce-self-service-cancel"); validate_secret_keys (core/config.py:261) refuses the default dev value once ENVIRONMENT is not development, so the token cannot be forged with the shipped default in a real deploy. No other config key gates this recipe's behaviour -- MAX_CHARGE_FAILURES, OTP_TTL_MINUTES, TOKEN_TTL_SECONDS, MAX_ATTEMPTS and MAX_SENDS_PER_HOUR are module-level constants, not settings (subscription_service.py:47; self_service_cancel.py:52-55), so changing any of them is a code change, not a config change.
Data model
The scheduled-charge row (models/commerce_models.py:187-246; migration commerce_subscriptions_001, migrations/versions/20260610_0004_commerce_subscriptions.py):
scheduled_subscriptions (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
order_id uuid NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id uuid NOT NULL REFERENCES products(id) ON DELETE CASCADE,
customer_email varchar(255) NOT NULL,
payrexx_token_ref varchar(255) NULL, -- the banked AUTHORIZED tx id (never the confirmed capture)
recurring_price_cents integer NOT NULL,
currency varchar(10) NOT NULL DEFAULT 'CHF',
recurring_interval varchar(10) NOT NULL, -- 'day' | 'week' | 'month' | 'year' (day accepted by the interval
-- math, not by the CHECK below -- see Gotchas)
recurring_interval_count integer NOT NULL DEFAULT 1,
convert_at timestamptz NOT NULL, -- first recurring charge instant
status varchar(20) NOT NULL DEFAULT 'scheduled',
last_charged_at timestamptz NULL,
next_charge_at timestamptz NULL, -- NULL once cancelled; the sweep's due-query column
cancelled_at timestamptz NULL,
charge_failure_count integer NOT NULL DEFAULT 0,
metadata jsonb NOT NULL DEFAULT '{}',
created_at, updated_at timestamptz NOT NULL
);
CHECK ck_scheduled_subscriptions_status: status IN ('scheduled','active','cancelled','failed')
CHECK ck_scheduled_subs_price_nonneg: recurring_price_cents >= 0
UNIQUE uq_scheduled_subscriptions_order (order_id) -- idempotent mint under at-least-once webhook delivery
INDEX ix_scheduled_subscriptions_status_convert (status, convert_at)
INDEX (workspace_id), (customer_email), (convert_at), (status), (next_charge_at) -- one column each
The four-column status machine this recipe drives: scheduled (minted, never yet charged) → active (at least one successful charge) → cancelled (terminal, via cancel_subscription) or failed (terminal, via bounded dunning or a missing token). run_due_conversions's due-query only ever selects status IN ('scheduled','active') (subscription_service.py:373), so cancelled and failed are both permanently out of the sweep -- the model does not distinguish "the merchant/customer stopped this" from "the card kept failing" at the query level, only by which column (cancelled_at vs charge_failure_count >= 4) is populated.
The OTP row backing self-service cancel (models/commerce_models.py:248-270; migration subscription_cancel_otp_001, migrations/versions/20260911_0002_subscription_cancel_otp.py):
subscription_cancel_otps (
id uuid PRIMARY KEY,
email varchar(255) NOT NULL,
code_hash varchar(64) NOT NULL, -- sha256(code + "\x00" + email), never plaintext
expires_at timestamptz NOT NULL, -- created_at + 10 minutes (OTP_TTL_MINUTES)
attempts integer NOT NULL DEFAULT 0,
consumed_at timestamptz NULL, -- single-use: set on success AND on burning past MAX_ATTEMPTS
ip varchar(64) NULL,
created_at timestamptz NOT NULL
);
INDEX ix_subscription_cancel_otps_email, ix_subscription_cancel_otps_created_at,
ix_sub_cancel_otp_email_created (email, created_at) -- backs the per-email send-rate and lookup queries
What is NOT stored: the cancel-scope token itself -- it is never persisted anywhere (issue_token, self_service_cancel.py:77-84), just a base64 timestamp:email payload plus an HMAC-SHA256 signature over it, verified by recomputing the signature (read_token, self_service_cancel.py:87-100); a sessions row for it (it mints no Labs session by design); the plaintext OTP code (only its hash); which merchant workspace a self-service cancel came from (the flow is deliberately email-scoped, not workspace-scoped -- see Security & tenancy).
API surface
All routes below are api/routers/commerce_router.py, mounted at /api with surfaces=LABS_SURFACES (app_fastapi.py:838). The merchant-facing routes additionally require user: VerifiedUser, workspace_id: WorkspaceID; the three self-service routes carry no such dependency and are reachable anonymously (Security & tenancy explains why the surface mount does not close them).
| Route | Auth | Body/params | Codes | Notes |
|---|---|---|---|---|
GET /commerce/subscriptions (1040-1072) | VerifiedUser + WorkspaceID | query status_filter, product_id, customer_email, limit, offset | 200 {subscriptions: [...], total} | workspace-filtered; customer_email is ILIKE %term% |
GET /commerce/subscriptions/{id} (1075-1090) | VerifiedUser + WorkspaceID | path | 200; 404 "Subscription not found" | cross-workspace id 404s |
POST /commerce/subscriptions/{id}/cancel (1093-1117) | VerifiedUser + WorkspaceID | path | 200 the refreshed row; 404 | idempotent: cancelling a cancelled/failed sub is a no-op (cancel_subscription only acts on scheduled/active) |
POST /commerce/subscriptions/{id}/convert (1179-1222) | VerifiedUser + WorkspaceID | path | 200 the refreshed row; 404; 409 "...not in a chargeable state..."; 409 "No reusable Payrexx token is banked..."; 502 "Payment provider declined the conversion charge." | forces convert_now, skipping next_charge_at; same claim-before-send guarantee |
POST /commerce/self-service/request-code (1139-1152) | none (public) | {email} | 200 always, fixed sentence "Falls zu dieser E-Mail ein Abo besteht, haben wir dir einen Code geschickt." | never varies by whether the email has subscriptions or hit the send-rate limit |
POST /commerce/self-service/verify (1155-1164) | none (public) | {email, code} | 200 {token, subscriptions: [...]}; 400 {"error": "invalid_code"} | subscriptions are revealed only here, after proof |
POST /commerce/self-service/cancel (1167-1176) | none (public); the token body field is the auth | {token, subscription_id} | 200 the cancelled summary; 401 {"error": "invalid_or_expired_token"}; 404 {"error": "subscription_not_found"} (self_service_cancel.py:242-243, raised inside the service, not the router) | 404 fires both for an unknown id and for an id belonging to a different email -- the caller cannot tell which |
ScheduledSubscriptionResponse (schemas/commerce_schemas.py:454-472) is the shape returned by the four merchant routes: id, workspace_id, order_id, product_id, customer_email, recurring_price_cents, currency, recurring_interval, recurring_interval_count, convert_at, status, last_charged_at, next_charge_at, cancelled_at, created_at, updated_at. It does NOT include payrexx_token_ref or charge_failure_count -- a merchant cannot see how many times a subscription has already declined, only that it is still active/scheduled or has become failed. The self-service summary (self_service_cancel.py:118-134, _sub_summary, a hand-built dict, not the schema above) is narrower still: id, product_name, status, price_cents, currency, interval, next_charge_at -- no email, no token ref, no failure count; next_charge_at is deliberately never backfilled from convert_at once the sub is not scheduled/active, so a cancelled subscription never shows a future charge date (self_service_cancel.py:128-133, asserted by test_full_flow_request_verify_cancel).
Application logic
Minting (mint_scheduled_subscription, subscription_service.py:141-223), called only from fulfillment_service.fulfill_order on a webhook-paid order (fulfillment_service.py:294-296), in order: return None for any billing_model other than intro_then_sub/subscription; return the existing row if one already exists for order.id (the UNIQUE(order_id) makes this idempotent under Payrexx's at-least-once webhook delivery -- test_webhook_double_delivery_mints_one_sub); compute first_charge_at as paid_at + convert_after for intro_then_sub or paid_at + one interval for a pure subscription (the full first period was already charged at checkout); resolve the authorized transaction id via a live gateway lookup (resolve_authorized_token_ref, walks gw["invoices"][*]["transactions"] and gw["transactions"] for status == "authorized", subscription_service.py:108-135), falling back to order.extra_metadata["payrexx_token_ref"], and if neither resolves, logging an error and minting the row with payrexx_token_ref=None anyway -- deferring the failure to conversion time rather than refusing the mint. The function does not commit; the caller's single fulfillment-transaction commit covers it.
The 15-minute sweep (run_due_conversions / _convert_one, subscription_service.py:344-492; scheduled as job id commerce_subscription_conversion, IntervalTrigger(minutes=15), coalesce=True, max_instances=1, scheduled_jobs.py:437-445, run by run_subscription_conversion_job at scheduled_jobs.py:950-976), one row at a time, each in its own transaction:
- Select due rows:
status IN ('scheduled','active') AND next_charge_at IS NOT NULL AND next_charge_at <= now, oldest-due first, capped atbatch_size(default 100) (subscription_service.py:370-380). - Per row,
_convert_onere-selectsFOR UPDATEand re-checks under the lock: bail ("skipped") if the status is no longerscheduled/activeor it is no longer due -- this is the race-loss path (subscription_service.py:405-419). - No token banked: park as
failedimmediately, no charge attempted (subscription_service.py:420-426). - CLAIM: capture
charged_cycle = sub.next_charge_at, advancesub.next_charge_atto the next cycle via_add_interval, anddb.commit()-- before Payrexx is called (subscription_service.py:428-435). This commit is the whole claim-before-send guarantee: a crash after this point but before step 6 leavesnext_charge_atalready in the future, so the next sweep will not re-select this row for the same cycle (test_conversion_idempotent_no_double_charge). - SEND:
charge_token(token_ref, recurring_price_cents, currency, px=PayrexxService.for_workspace(...))(subscription_service.py:438). - RECORD: re-select
FOR UPDATE. If cancelled in the charge window, honour the cancel and record nothing (subscription_service.py:449-452). Onresult["ok"]: insert a newOrder(status="paid", provider="payrexx", payrexx_transaction_id=..., extra_metadata={"kind": "subscription_charge", "scheduled_subscription_id": ..., "billed_cycle": ...})in the sub's ownworkspace_id, setstatus="active",last_charged_at=now,charge_failure_count=0, commit (subscription_service.py:454-478). On failure:charge_failure_count += 1; if it has now reachedMAX_CHARGE_FAILURES(4),status="failed"(terminal, drops out of every future sweep); otherwise the row is leftscheduled/activeand will be retried at thenext_charge_atalready advanced in step 4 -- i.e. one interval later, not on the next 15-minute tick (subscription_service.py:480-492).
Convert now (convert_now, subscription_service.py:498-588), the merchant "charge immediately" action: identical claim/send/record shape, with two differences -- the due-time gate is skipped entirely, and the claimed cycle is sub.next_charge_at or now while the NEW next_charge_at is anchored at now (not at the old, possibly-future, convert_at), so converting a trial early pulls the whole schedule forward from this instant (subscription_service.py:531-536, test_convert_now_charges_immediately). Returns "charged" | "failed" | "skipped" | "no_token"; the router (commerce_router.py:1205-1217) maps "skipped"→409, "no_token"→409, "failed"→502.
Cancel (cancel_subscription, subscription_service.py:594-605): pure state mutation, no commit (the caller's transaction owns it). If status IN ('scheduled','active'): set status="cancelled", cancelled_at=now, next_charge_at=None. Called from two places with different transaction ownership: the merchant router commits itself (commerce_router.py:1114-1116); self_service_cancel.cancel also commits itself and additionally sends a confirmation email only when was_active was true before the call, i.e. only on a real state transition, not on a no-op re-cancel (self_service_cancel.py:244-249).
Self-service cancel, step 1 -- request a code (request_code, self_service_cancel.py:142-174): normalize the email; reject empty/no-@; check the per-email send-rate (count(*) FROM subscription_cancel_otps WHERE email=... AND created_at >= now-1h) against MAX_SENDS_PER_HOUR (5) and return silently if hit; return silently (still 200, still the same sentence) if the email has zero _active_subscriptions (status IN ('scheduled','active')); otherwise mint a 6-digit code (secrets.randbelow), store its hash with a 10-minute expiry, commit, and dispatch the email -- as a BackgroundTasks job from the router (commerce_router.py:1149-1150) so the send happens after the response is written, keeping response timing from correlating with whether the address has subscriptions (the body is already constant either way).
Step 2 -- verify (verify, self_service_cancel.py:193-221): select the newest unconsumed, unexpired OTP row for the email, FOR UPDATE (serializes concurrent verifies so the attempts cap cannot be raced past -- no row, no code, no comment in the source names the race explicitly beyond the lock itself); increment attempts; if now over MAX_ATTEMPTS (5), burn the row (consumed_at=now) and refuse; compare the submitted code's hash to the stored hash with hmac.compare_digest; on success, consume the row and return (True, issue_token(email), [summary for each active sub]); on any failure, (False, None, []).
Step 3 -- cancel (cancel, self_service_cancel.py:229-250): read_token(payload.token) recovers the authorized email (router-side, commerce_router.py:1173-1175, 401 if invalid/expired); the service then re-derives email from its own email argument (already the token's email, passed in by the router) and looks up the target sub filtered on BOTH id and customer_email == email in one query (self_service_cancel.py:235-240) -- a foreign subscription id is indistinguishable from an unknown one, both 404 (test_token_cannot_cancel_another_emails_subscription).
Invariants
- Every recurring charge is claim-before-send:
next_charge_atis advanced and committed strictly before the Payrexx call, on both the sweep andconvert_now, so a crash between "charged" and "recorded" cannot re-charge the same cycle (subscription_service.py:428-435,531-536;test_conversion_idempotent_no_double_charge). - A subscription is charged by exactly one process at a time: the per-row
FOR UPDATEinside its own transaction is the serialization point, not the scheduler's advisory-lock singleton (which only stops two workers from running the sweep concurrently -- aconvert_nowclick racing the sweep on the same row is what the row lock actually guards). - Dunning is bounded and one-directional:
charge_failure_countonly ever resets to 0 on a successful charge; once it reachesMAX_CHARGE_FAILURES(4) the row becomesfailedand the due-query (status IN ('scheduled','active')) permanently excludes it -- nothing un-parks afailedrow automatically (Gotchas: nothing tells the customer, either). - Cancel is terminal and idempotent: only a
scheduled/activerow is mutated bycancel_subscription; calling it again, or on an already-failedrow, is a no-op that still returns 200. - The self-service cancel token authorizes exactly one email and nothing else: it carries no subscription id, no workspace id, and no admin capability, and mints no
sessionsrow --read_tokenonly ever returns an email string orNone(self_service_cancel.py:87-100). - The self-service flow never reveals whether an email has subscriptions before the code is verified:
request_code's response is one fixed sentence on every path -- no subscriptions, rate-limited, or a code genuinely sent (test_unknown_email_is_neutral_and_sends_nothingasserts byte-identical wording across two different emails). - An OTP is single-use and bounded:
consumed_atis set both on a correct verify and on exceedingMAX_ATTEMPTS, so a burned code cannot be retried even with the right digits after the cap (self_service_cancel.py:209-213).
Frontend
The module that renders this, by id:
billing-credits/ui/subscription-manager the public, unauthenticated self-service page (email -> code ->
subscriptions -> cancel) -- no caller exists in the repo at
af7a352 (`grep -rl "self-service/request-code" frontend/src`
is empty); this module's own recipe is the authority on its
screens and states, this recipe only guarantees the three
endpoints it consumes
The merchant-side list/detail/cancel/convert-now surface already has a caller in this repo, but it belongs to a different, already-existing frontend module, not the one above: frontend/src/components/commerce/store.tsx (the Store admin console) calls listSubscriptions, getSubscription, cancelSubscription, convertSubscription (frontend/src/services/commerceService.ts:540-576) against the four VerifiedUser-gated routes in API surface.
Minimum admin and debug surface, all workspace-scoped SQL a merchant or an operator can run directly: SELECT status, count(*) FROM scheduled_subscriptions WHERE workspace_id = <ws> GROUP BY 1 (the funnel: scheduled/active/cancelled/failed); SELECT id, customer_email, charge_failure_count, next_charge_at FROM scheduled_subscriptions WHERE workspace_id = <ws> AND status = 'failed' ORDER BY updated_at DESC (parked subscriptions -- the field the merchant console's response schema does not expose, see API surface); GET /api/commerce/subscriptions?status_filter=failed (the same list over the API); the log lines subscription: %s parked as failed after %s declines and subscription: %s charge declined (attempt %s); will retry (subscription_service.py:484,489) are the only trace of a decline anywhere -- there is no admin_actions-style audit row for a dunning event (unlike identity-auth's admin router, this domain keeps no audit table at all for subscription state changes).
Security & tenancy
Merchant routes (list_subscriptions, get_subscription, cancel_subscription_endpoint, convert_subscription_now_endpoint): VerifiedUser (S5) plus WorkspaceID (S6) on every one; every query filters ScheduledSubscription.workspace_id == workspace_id, so a cross-workspace id 404s and is never touched (test_cancel_endpoint_workspace_scoped, test_convert_endpoint_workspace_scoped). The router is mounted surfaces=LABS_SURFACES, so a Prima-surface session is refused before any handler runs (S4).
Self-service routes: deliberately carry no VerifiedUser/WorkspaceID dependency, and the surface mount does not close them either -- SurfaceGate.__call__ returns immediately for an anonymous request with no bearer token and no API key (api/surface_gate.py:116-118, "Anonymous. Not our call -- the endpoint decides whether that is OK."), so LABS_SURFACES on the router mount is a no-op for these three routes specifically; a credential that IS presented still has to name a surface the router opens, but nothing requires one. The design is DELIBERATELY email-scoped, not workspace-scoped (self_service_cancel.py:20-30): a verified email owner sees and cancels every subscription tied to that email across every merchant workspace, because there is no per-merchant entry point to this page and asking the customer which merchant would defeat the point. This is not cross-tenant access to a stranger's data (the email owner legitimately owns every row returned), but it does mean a customer who bought from two merchants on this platform sees both merchants' product names and prices on one page -- confirm this is the intended product behaviour before shipping; the file's own docstring names the fix (thread a workspace_id into the token and filter on it) if a per-merchant view turns out to be wanted.
Rate limits: two independent layers. (1) DB-based, per email, inside self_service_cancel.py itself: MAX_SENDS_PER_HOUR=5 on request_code, MAX_ATTEMPTS=5 on verify (burns the OTP row past the cap) -- chosen specifically so the flow does not depend on Redis (self_service_cancel.py:14-15). (2) Per-IP, Redis-backed, in UnifiedMiddleware._check_rate_limit (api/middleware/unified_middleware.py:143-184): /api/commerce/self-service is one of the PUBLIC_PREFIXES (unified_middleware.py:140) and gets the stricter ceiling, settings.RATE_LIMIT_PUBLIC_IP_PER_MINUTE (default 240/min, core/config.py:893), on top of the general RATE_LIMIT_IP_PER_MINUTE (default 1000/min, core/config.py:889) that applies to /api/ at large; the limiter fails OPEN on a Redis error. Neither layer rate-limits per target-email independent of the requester's IP beyond the DB check in (1), so a single IP enumerating many different emails against request-code is bounded only by the 240/min ceiling, not by anything email-specific.
Credentials at rest: the OTP code is sha256(code + "\x00" + email) (self_service_cancel.py:68-70, the email folded in so a stolen hash cannot be replayed against a different address), never plaintext, compared with hmac.compare_digest. The cancel-scope token is signed, not encrypted -- email and the issue timestamp are readable by anyone holding the token (base64, not obfuscation), only the signature is confidential; that is an accepted tradeoff for a token whose only claim is "this bearer proved this email," not confidentiality. SECRET_KEY is the sole key material and is shared with every other stdlib-HMAC-signed thing in the codebase that reads settings.SECRET_KEY (the _TOKEN_SALT prefix is this flow's only domain separation from them).
Attacker-controlled inputs on the self-service path, all reachable with zero credentials: email (normalized, no enumeration by design); code (rate- and attempt-bounded, compared in constant time); token (HMAC-verified, TTL-checked); subscription_id (filtered by the verified email, so an id from anyone else's subscription 404s rather than 200s or 403s -- test_token_cannot_cancel_another_emails_subscription). None of these five is trusted for anything beyond what verification proves.
Tests
On the test Postgres of S9 (postgresql://test_user:test_password@localhost:5434/test_db), run 2026-09-12 at af7a352: python -m pytest tests/test_commerce_subscriptions.py tests/test_commerce_self_cancel.py -q --no-cov -o addopts="" → 30 passed in 16.51s.
test_commerce_subscriptions.py (23 cases; this recipe's slice, the others belong to Phase-1 checkout/refund neighbours also in this file): test_scheduled_subscription_unique_order and test_scheduled_subscription_bogus_status_rejected (the UNIQUE and CHECK constraints); test_webhook_paid_mints_scheduled_subscription, test_webhook_paid_mints_pure_subscription_next_interval, test_webhook_double_delivery_mints_one_sub (mint, both billing models, idempotency); test_conversion_fires_at_convert_at, test_cancel_stops_conversion, test_conversion_idempotent_no_double_charge, test_conversion_decline_does_not_record_order (the sweep, claim-before-send, one decline); test_cancel_endpoint_workspace_scoped, test_list_subscriptions_workspace_scoped (S6); test_convert_now_charges_immediately, test_convert_now_skips_cancelled, test_convert_endpoint_workspace_scoped, test_convert_endpoint_decline_returns_502 (convert-now, service and endpoint).
test_commerce_self_cancel.py (7 cases, the whole file): test_full_flow_request_verify_cancel (the happy path end to end, including the no-next_charge_at-fallback assertion); test_unknown_email_is_neutral_and_sends_nothing (no-enumeration); test_wrong_code_is_rejected, test_verify_without_a_requested_code_fails; test_cancel_requires_a_valid_token; test_token_cannot_cancel_another_emails_subscription; test_send_rate_limit_stops_after_five.
Known-answer vector: test_wrong_code_is_rejected reads the error envelope's structured-detail shape (S7) directly -- v.json()["details"]["error"] == "invalid_code" for a 400 whose HTTPException(detail={"error": "invalid_code"}) the envelope repacks into {"error": <message>, "details": {"error": "invalid_code"}, ...} (app_fastapi.py:773-793).
Gaps, both genuine and measured, not invented: (1) no test in this repo exercises MAX_CHARGE_FAILURES actually being reached -- test_conversion_decline_does_not_record_order asserts charge_failure_count == 1 after one decline and stops there; the "parks as failed after the fourth consecutive decline" behaviour is real (read directly off subscription_service.py:481-485) but has zero test coverage in tests/test_commerce_subscriptions.py (grep -n "MAX_CHARGE_FAILURES" tests/*.py matches nothing). (2) No test exercises the tri-state charge_token outcome ("unknown" vs "declined") on the subscription path at all: the one decline test monkeypatches subscription_service.charge_token directly with a plain {"ok": False, ...} dict that carries no "outcome" key, bypassing _classify_charge_failure entirely -- so the Gotcha below (an "unknown" counts as a strike here) is read off the code, not proven by a failing assertion anywhere.
Deploy & ops
Config keys: SECRET_KEY only (Stack assumptions). Migrations order: commerce_subscriptions_001 (down_revision = contact_spine_001) creates scheduled_subscriptions and the products billing-model columns; subscription_cancel_otp_001 (down_revision = workspace_documents_001) creates subscription_cancel_otps -- the two are on different lineages of the same chain and neither depends on the other. Scheduler job: commerce_subscription_conversion, IntervalTrigger(minutes=15), coalesce=True, max_instances=1, id fixed so a redeploy's replace_existing=True reschedules rather than duplicates (scheduled_jobs.py:437-445); runs only on the worker that won the process-wide advisory lock (S12). Rollback: both migrations' downgrade() drop their table (and, for commerce_subscriptions_001, the four products columns and their CHECK) -- dropping scheduled_subscriptions loses every subscription's charge history and current cycle position with no archive; there is no soft-delete or export step in either downgrade().
Argus-style checks (none of these are wired into an actual Argus check at this commit; they are what a rebuild's checks should assert): SELECT count(*) FROM scheduled_subscriptions WHERE status IN ('scheduled','active') AND next_charge_at < now() - interval '1 hour' should be 0 on a healthy scheduler (a nonzero count means the 15-minute job stopped running); SELECT count(*) FROM scheduled_subscriptions WHERE status = 'failed' AND charge_failure_count < 4 should be 0 (the only other path to failed is a missing token, worth distinguishing in a real check by joining payrexx_token_ref IS NULL).
Gotchas
- 2026-09-12 (measured at af7a352): dunning does not distinguish "the PSP said no" from "we never heard back."
charge_tokenclassifies every failure into a tri-stateoutcome("declined"vs"unknown"-- a timeout, a 5xx, or even OUR OWN misconfiguredPAYREXX_API_SECRETreturning 401/403 all classify as"unknown",subscription_service.py:233-277) precisely so a caller can avoid striking a customer's card for a failure that was never the card's fault._convert_oneandconvert_nownever readresult["outcome"], onlyresult["ok"](grep -n 'result\[.outcome.\]' services/commerce/subscription_service.pymatches nothing) -- so an "unknown" incrementscharge_failure_countexactly like a genuine decline, and four PSP timeouts (or four requests made whilePAYREXX_API_SECREThappens to be wrong) permanently park a subscription with a perfectly good card. The function's own docstring says this out loud: "okis kept so_convert_onereads the result unchanged; only the auto top-up caller readsoutcome" (subscription_service.py:296-298). The sibling auto-top-up service in this same domain was fixed to respect the tri-state on 2026-09-11 (grep -n MAX_CHARGE_FAILURESfinds only this file, confirming the two dunning loops are independent). A rebuild readsresult["outcome"]here too and only strikes on"declined". - 2026-09-12 (measured at af7a352): nobody is told when a subscription parks.
grep -rn "charge_failure_count" services/shows the only outputs arelogger.error/logger.warninglines (subscription_service.py:484,489,584,586); no customer email, no merchant notification, no row in any audit/notification table fires anywhere in this file or inscheduled_jobs.py. The only two customer emails that exist anywhere near this recipe are the OTP code and the cancel confirmation, both inself_service_cancel.py. A card that fails four times just stops being charged, silently, from both sides. - 2026-09-12 (measured at af7a352): the sweep's returned
outcomestring conflates two different things._convert_one(subscription_service.py:480-492) sets the local variableoutcome = "failed"on BOTH the terminal park (line 483,charge_failure_count >= MAX_CHARGE_FAILURES) and the ordinary "will retry next cycle" decline (line 490, one comment apart) --run_due_conversions's summary{"failed": N, ...}therefore cannot tell a caller how many of those N are permanently parked versus merely declined-and-retrying without a separate query.convert_nowhas the identical shape (subscription_service.py:581-588) and its router maps that single"failed"outcome straight to a 502 regardless of which case it was (commerce_router.py:1216-1217). - 2026-09-12 (measured at af7a352):
_add_interval(subscription_service.py:53-71) accepts'day'as a unit and computes it correctly, butScheduledSubscription.recurring_interval's comment and every fixture in the tests only ever use'week'|'month'|'year'(models/commerce_models.py:217,models/commerce_models.pydocstring at 190). Nothing in the schema, the CHECK constraints, or the tests actually forbids'day'; it is untested, not unsupported. - 2026-09-12 (measured at af7a352): a
mint_scheduled_subscriptioncall that cannot resolve an authorized token still creates the row, withpayrexx_token_ref=Noneandstatus='scheduled'(subscription_service.py:192-217), deferring the failure to the first sweep, which then parks it asfailedwith zero charges attempted (subscription_service.py:420-426). Between the mint and that first sweep tick the row is indistinguishable from a healthy trial in every merchant-facing list (the response schema does not exposepayrexx_token_ref, API surface) -- the only tell ispayrexx_token_ref IS NULLin the database directly. - 2026-09-12 (measured at af7a352):
POST /commerce/self-service/cancelreturns the identical 404 body ({"error": "subscription_not_found"}) for a subscription id that does not exist and for one that belongs to a different, verified email (self_service_cancel.py:241-243,test_token_cannot_cancel_another_emails_subscription). That is the correct security property (no ownership oracle) but it also means a genuine typo in a subscription id is indistinguishable, from the response alone, from an attempted cross-account cancel.
Reference implementation
Paths are relative to the repo root; all at af7a35246dd4a009485bc67b3a13fea6e5a688cc on branch w1-auto-topup (unmerged to alchemy_labs main as of 2026-09-12).
backend/services/commerce/subscription_service.py(606 lines):_add_interval/_add_months/_days_in_month(53-89),_convert_at(92-102),resolve_authorized_token_ref(108-135),mint_scheduled_subscription(141-223),CHARGE_OUTCOMES,_classify_charge_failure(233-277),charge_token(280-338),run_due_conversions(344-397),_convert_one(400-492),convert_now(498-588),cancel_subscription(594-605);MAX_CHARGE_FAILURES = 4(47).backend/services/commerce/self_service_cancel.py(278 lines): constants (52-57),_hash_code/_secret_bytes(68-74),issue_token/read_token(77-100),_active_subscriptions/_sub_summary(108-134),request_code/_send_code_email(142-185),verify(193-221),cancel/_send_cancel_confirmation/_send/_default_mailer(229-277).backend/api/routers/commerce_router.py:list_subscriptions(1040-1072),get_subscription(1075-1090),cancel_subscription_endpoint(1093-1117), request/verify/do schemas (1125-1137),self_cancel_request_code(1139-1152),self_cancel_verify(1155-1164),self_cancel_do(1167-1176),convert_subscription_now_endpoint(1179-1222); router mountapp_fastapi.py:838.backend/models/commerce_models.py:Order(110-150),ScheduledSubscription(187-246),SubscriptionCancelOtp(248-270);Productbilling-model columns (47-53).backend/services/scheduled_jobs.py: job registration (430-445),run_subscription_conversion_job(950-976).backend/migrations/versions/20260610_0004_commerce_subscriptions.py(commerce_subscriptions_001, downcontact_spine_001);backend/migrations/versions/20260911_0002_subscription_cancel_otp.py(subscription_cancel_otp_001, downworkspace_documents_001).backend/api/middleware/unified_middleware.py:AUTH_PREFIXES/PUBLIC_PREFIXES(130-141),_check_rate_limit(143-184).backend/core/config.py:SECRET_KEY(58),validate_secret_keys(261),RATE_LIMIT_ENABLED/RATE_LIMIT_IP_PER_MINUTE/RATE_LIMIT_PUBLIC_IP_PER_MINUTE(882-893).backend/schemas/commerce_schemas.py:ScheduledSubscriptionResponse/ScheduledSubscriptionListResponse(454-477).- Tests as listed under Tests:
backend/tests/test_commerce_subscriptions.py,backend/tests/test_commerce_self_cancel.py. - Frontend caller of the (different) merchant-side module:
frontend/src/components/commerce/store.tsx,frontend/src/services/commerceService.ts:540-576.
Deviations of the reference from this recipe, all dated 2026-09-12: dunning does not read the tri-state PSP-silence-vs-decline classification it already computes; a parked subscription notifies nobody; the sweep's "failed" outcome string conflates terminal-park with will-retry; 'day' as recurring_interval is reachable but untested; a token-less mint defers its failure invisibly to the first sweep; the self-service cancel 404 does not distinguish unknown-id from wrong-owner (by design, but worth naming); no audit table for any subscription state change, unlike identity-auth's admin_actions.
Acceptance checks
Run from alchemy_labs/backend at af7a352 with the :5434 test Postgres up (already running as deap-postgres-test in this environment), or from a new app built to this recipe. Expected observations are exact.
python -m pytest tests/test_commerce_subscriptions.py tests/test_commerce_self_cancel.py -q --no-cov -o addopts=""prints30 passed.grep -n "MAX_CHARGE_FAILURES = " services/commerce/subscription_service.pyprintsMAX_CHARGE_FAILURES = 4at line 47.grep -n 'result\["outcome"\]' services/commerce/subscription_service.py | wc -lprints0(the unread tri-state, Gotchas; a rebuild that fixes it raises this above0).grep -c "surfaces=LABS_SURFACES" api/../app_fastapi.pyis not the check here -- instead: with the harness up,curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8020/api/commerce/self-service/request-code -H 'Content-Type: application/json' -d '{"email":"[email protected]"}'with NO Authorization header returns200(the self-service routes are reachable anonymously, Security & tenancy).- With a due
ScheduledSubscriptionrow (convert_atin the past, a stubbedPayrexxService._make_requestreturning aconfirmedcharge):run_due_conversions(db, now=datetime.now(timezone.utc))returns{"charged": 1, ...}; a second call with the samenowreturns{"charged": 0, ...}and the mocked Payrexx charge endpoint was hit exactly once (claim-before-send;test_conversion_idempotent_no_double_charge). - Same fixture,
charge_tokenmonkeypatched to always return{"ok": False, ...}: callingrun_due_conversionsfour times (one due cycle each) leavesSELECT status, charge_failure_count FROM scheduled_subscriptions WHERE id = <sub>at('failed', 4); a fifth call does not touch the row (grep -n "next_charge_at <= now" services/commerce/subscription_service.pyshows the due-query, andstatus IN ('scheduled','active')in the same query excludes it once parked). POST /api/commerce/self-service/request-codewith the same email six times inside an hour: the sixth call still returns200with the identical message, andSELECT count(*) FROM subscription_cancel_otps WHERE email = '<email>'is5, not6(MAX_SENDS_PER_HOUR,test_send_rate_limit_stops_after_five).POST /api/commerce/self-service/verifywith a correct code returns200with atoken;POST /api/commerce/self-service/cancelwith that token and asubscription_idbelonging to a DIFFERENT email returns404 {"error": "subscription_not_found"}, andSELECT status FROM scheduled_subscriptions WHERE id = <that other id>is unchanged (test_token_cannot_cancel_another_emails_subscription).npm run checkinprima-modulesprints no line for this recipe.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.