Prima Get an API key

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
  • alchemy_labs/backend/services/commerce/subscription_service.py
  • alchemy_labs/backend/services/commerce/self_service_cancel.py
  • alchemy_labs/backend/api/routers/commerce_router.py
  • alchemy_labs/backend/models/commerce_models.py
  • alchemy_labs/backend/services/scheduled_jobs.py
  • alchemy_labs/backend/migrations/versions/20260610_0004_commerce_subscriptions.py
  • alchemy_labs/backend/migrations/versions/20260911_0002_subscription_cancel_otp.py
  • alchemy_labs/backend/api/middleware/unified_middleware.py
  • alchemy_labs/backend/core/config.py
  • alchemy_labs/backend/tests/test_commerce_subscriptions.py
  • alchemy_labs/backend/tests/test_commerce_self_cancel.py
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).

RouteAuthBody/paramsCodesNotes
GET /commerce/subscriptions (1040-1072)VerifiedUser + WorkspaceIDquery status_filter, product_id, customer_email, limit, offset200 {subscriptions: [...], total}workspace-filtered; customer_email is ILIKE %term%
GET /commerce/subscriptions/{id} (1075-1090)VerifiedUser + WorkspaceIDpath200; 404 "Subscription not found"cross-workspace id 404s
POST /commerce/subscriptions/{id}/cancel (1093-1117)VerifiedUser + WorkspaceIDpath200 the refreshed row; 404idempotent: cancelling a cancelled/failed sub is a no-op (cancel_subscription only acts on scheduled/active)
POST /commerce/subscriptions/{id}/convert (1179-1222)VerifiedUser + WorkspaceIDpath200 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:

  1. Select due rows: status IN ('scheduled','active') AND next_charge_at IS NOT NULL AND next_charge_at <= now, oldest-due first, capped at batch_size (default 100) (subscription_service.py:370-380).
  2. Per row, _convert_one re-selects FOR UPDATE and re-checks under the lock: bail ("skipped") if the status is no longer scheduled/active or it is no longer due -- this is the race-loss path (subscription_service.py:405-419).
  3. No token banked: park as failed immediately, no charge attempted (subscription_service.py:420-426).
  4. CLAIM: capture charged_cycle = sub.next_charge_at, advance sub.next_charge_at to the next cycle via _add_interval, and db.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 leaves next_charge_at already in the future, so the next sweep will not re-select this row for the same cycle (test_conversion_idempotent_no_double_charge).
  5. SEND: charge_token(token_ref, recurring_price_cents, currency, px=PayrexxService.for_workspace(...)) (subscription_service.py:438).
  6. RECORD: re-select FOR UPDATE. If cancelled in the charge window, honour the cancel and record nothing (subscription_service.py:449-452). On result["ok"]: insert a new Order(status="paid", provider="payrexx", payrexx_transaction_id=..., extra_metadata={"kind": "subscription_charge", "scheduled_subscription_id": ..., "billed_cycle": ...}) in the sub's own workspace_id, set status="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 reached MAX_CHARGE_FAILURES (4), status="failed" (terminal, drops out of every future sweep); otherwise the row is left scheduled/active and will be retried at the next_charge_at already 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_at is advanced and committed strictly before the Payrexx call, on both the sweep and convert_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 UPDATE inside its own transaction is the serialization point, not the scheduler's advisory-lock singleton (which only stops two workers from running the sweep concurrently -- a convert_now click racing the sweep on the same row is what the row lock actually guards).
  • Dunning is bounded and one-directional: charge_failure_count only ever resets to 0 on a successful charge; once it reaches MAX_CHARGE_FAILURES (4) the row becomes failed and the due-query (status IN ('scheduled','active')) permanently excludes it -- nothing un-parks a failed row automatically (Gotchas: nothing tells the customer, either).
  • Cancel is terminal and idempotent: only a scheduled/active row is mutated by cancel_subscription; calling it again, or on an already-failed row, 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 sessions row -- read_token only ever returns an email string or None (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_nothing asserts byte-identical wording across two different emails).
  • An OTP is single-use and bounded: consumed_at is set both on a correct verify and on exceeding MAX_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_token classifies every failure into a tri-state outcome ("declined" vs "unknown" -- a timeout, a 5xx, or even OUR OWN misconfigured PAYREXX_API_SECRET returning 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_one and convert_now never read result["outcome"], only result["ok"] (grep -n 'result\[.outcome.\]' services/commerce/subscription_service.py matches nothing) -- so an "unknown" increments charge_failure_count exactly like a genuine decline, and four PSP timeouts (or four requests made while PAYREXX_API_SECRET happens to be wrong) permanently park a subscription with a perfectly good card. The function's own docstring says this out loud: "ok is kept so _convert_one reads the result unchanged; only the auto top-up caller reads outcome" (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_FAILURES finds only this file, confirming the two dunning loops are independent). A rebuild reads result["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 are logger.error/logger.warning lines (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 in scheduled_jobs.py. The only two customer emails that exist anywhere near this recipe are the OTP code and the cancel confirmation, both in self_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 outcome string conflates two different things. _convert_one (subscription_service.py:480-492) sets the local variable outcome = "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_now has 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, but ScheduledSubscription.recurring_interval's comment and every fixture in the tests only ever use 'week'|'month'|'year' (models/commerce_models.py:217, models/commerce_models.py docstring 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_subscription call that cannot resolve an authorized token still creates the row, with payrexx_token_ref=None and status='scheduled' (subscription_service.py:192-217), deferring the failure to the first sweep, which then parks it as failed with 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 expose payrexx_token_ref, API surface) -- the only tell is payrexx_token_ref IS NULL in the database directly.
  • 2026-09-12 (measured at af7a352): POST /commerce/self-service/cancel returns 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 mount app_fastapi.py:838.
  • backend/models/commerce_models.py: Order (110-150), ScheduledSubscription (187-246), SubscriptionCancelOtp (248-270); Product billing-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, down contact_spine_001); backend/migrations/versions/20260911_0002_subscription_cancel_otp.py (subscription_cancel_otp_001, down workspace_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.

  1. python -m pytest tests/test_commerce_subscriptions.py tests/test_commerce_self_cancel.py -q --no-cov -o addopts="" prints 30 passed.
  2. grep -n "MAX_CHARGE_FAILURES = " services/commerce/subscription_service.py prints MAX_CHARGE_FAILURES = 4 at line 47.
  3. grep -n 'result\["outcome"\]' services/commerce/subscription_service.py | wc -l prints 0 (the unread tri-state, Gotchas; a rebuild that fixes it raises this above 0).
  4. grep -c "surfaces=LABS_SURFACES" api/../app_fastapi.py is 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 returns 200 (the self-service routes are reachable anonymously, Security & tenancy).
  5. With a due ScheduledSubscription row (convert_at in the past, a stubbed PayrexxService._make_request returning a confirmed charge): run_due_conversions(db, now=datetime.now(timezone.utc)) returns {"charged": 1, ...}; a second call with the same now returns {"charged": 0, ...} and the mocked Payrexx charge endpoint was hit exactly once (claim-before-send; test_conversion_idempotent_no_double_charge).
  6. Same fixture, charge_token monkeypatched to always return {"ok": False, ...}: calling run_due_conversions four times (one due cycle each) leaves SELECT 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.py shows the due-query, and status IN ('scheduled','active') in the same query excludes it once parked).
  7. POST /api/commerce/self-service/request-code with the same email six times inside an hour: the sixth call still returns 200 with the identical message, and SELECT count(*) FROM subscription_cancel_otps WHERE email = '<email>' is 5, not 6 (MAX_SENDS_PER_HOUR, test_send_rate_limit_stops_after_five).
  8. POST /api/commerce/self-service/verify with a correct code returns 200 with a token; POST /api/commerce/self-service/cancel with that token and a subscription_id belonging to a DIFFERENT email returns 404 {"error": "subscription_not_found"}, and SELECT status FROM scheduled_subscriptions WHERE id = <that other id> is unchanged (test_token_cannot_cancel_another_emails_subscription).
  9. npm run check in prima-modules prints 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.