Prima Get an API key

Module kit / Billing and credits

Auto top-up card banking and refunds

billing-credits/auto-topup-card-banking-and-refunds

Kind backend
Domain billing-credits
Maturity unmerged
Reusable extract
Stack fastapi, sqlalchemy-sync, postgres
Depends on _shared/fastapi-project-layout, _shared/workspace-scoping, _shared/error-envelope, _shared/outbox-pattern, _shared/hmac-seam-pattern, _shared/scheduler-and-background-work, _shared/alembic-migrations
Frontends billing-credits/ui/subscription-manager
Reference implementation
  • alchemy_labs/backend/services/commerce/auto_topup_service.py
  • alchemy_labs/backend/services/commerce/prima_credit_grants.py
  • alchemy_labs/backend/services/commerce/fulfillment_service.py
  • alchemy_labs/backend/api/routers/commerce_router.py
  • alchemy_labs/backend/models/prima_auto_topup.py
  • alchemy_labs/backend/models/prima_credit_grant.py
  • alchemy_labs/backend/tests/prima_credits/test_auto_topup.py
  • alchemy_labs/backend/tests/prima_credits/test_auto_topup_webhook_and_refund.py
  • alchemy_labs/backend/tests/prima_credits/test_auto_topup_mails.py
Measured on alchemy_labs w1-auto-topup af7a35246dd4a009485bc67b3a13fea6e5a688cc 2026-09-12
Version 1

Purpose

A credits checkout bought with save_card=true leaves Prima's tenant holding a reusable Payrexx card token, banked from the one gateway document the webhook already fetched, and never from the confirmed-capture transaction id that zahls refuses to charge a second time. A refund or chargeback against any auto-top-up order (or against the very save-card order that banked the card) settles cleanly through the same webhook, queues a claw-back Prima clamps to what is still on the balance, and leaves prima_auto_topups - the tenant's standing "charge me when I run low" instruction - completely untouched: refunding one charge is not the tenant withdrawing permission to charge, and a mail says so because that rule is not the one people expect.

When to use

Read it before touching persist_token_from_order, before changing what a credits order's extra_metadata may carry, before editing the Payrexx webhook's order-lookup order, and when someone asks "why does the receipt say Karte hinterlegt with no number", "why didn't the refund cancel auto top-up", "which transaction id gets banked" or "why does a refund of the save-card order need to be told apart from a refund of an auto-charge". Trigger phrases: "save_card", "bank the token", "authorized transaction", "card_last4", "reverse_fulfillment", "enqueue_reversal", "auto top-up refund", "clawback", "tokenizing parent".

Not this recipe: the crossing-to-charge decision itself - charge_if_due/_charge_if_due, the claim-before-send marker, the three-strike dunning, run_auto_topup_retry, _reconcile_pending (all in auto_topup_service.py, a separate domain concern from banking the card and settling a refund); the threshold write to Prima (threshold_sync_*, prima_threshold_sync); the tenant-facing endpoints that read/write the instruction (primaapi_router.py's /credits/auto-topup GET/PUT/DELETE and /credits/topup); the save_card checkout's own creation (checkout_service.create_checkout's tokenize=True branch that opens the preAuthorization=1&chargeOnAuthorization=1 gateway) - this recipe starts at the webhook's pending -> paid transition, after that gateway already exists; the webhook's amount/currency verification and the seat-capacity backstop, which apply to every order kind; subscription_service.charge_token and resolve_authorized_token_ref, reused by (not called from) this path - persist_token_from_order re-implements the "authorized, never confirmed" walk locally, off the gateway document it already holds, rather than calling resolve_authorized_token_ref (Gotchas).

Stack assumptions

Measured at af7a35246dd4a009485bc67b3a13fea6e5a688cc: fastapi synchronous handlers on a Session (sqlalchemy.orm.Session), Postgres. Foundations assumed: S4-equivalent FastAPI project layout (_shared/fastapi-project-layout), workspace scoping for the merchant-side refund endpoint (_shared/workspace-scoping), the outbox pattern for the claw-back queue (_shared/outbox-pattern, prima_credit_grants table), an HMAC-signed seam for the outbound delivery to Prima (_shared/hmac-seam-pattern, services/commerce/prima_credit_sig.signed_headers), a scheduled drain for the outbox (_shared/scheduler-and-background-work, prima_credit_grants.drain_once), Alembic migrations (_shared/alembic-migrations).

Environment read by this path: PRIMA_CREDITS_MERCHANT_WORKSPACE_ID (the one workspace whose credits products and auto-top-up rows exist; unset makes resolve_credit_product and _merchant_workspace_id return None - core/config.py line 749), PRIMA_INTERNAL_BASE_URL and PRIMA_CREDIT_GRANT_SECRET (the outbound grant/reverse seam; unset leaves every claw-back row pending forever, visible, never silently lost - prima_credit_grants.deliver, lines 279 to 291), PRIMA_CREDIT_GRANT_TIMEOUT_SECONDS (default 10.0), PRIMA_CREDIT_GRANT_MAX_ATTEMPTS (default 24, after which a row goes stuck). Fail behaviour throughout this path is closed on the money-moving steps (a rejected PSP refund raises 502 and touches nothing) and open on the convenience step (a card that cannot be resolved logs at ERROR and leaves the credit fulfilled - persist_token_from_order's own docstring, lines 193 to 198).

Data model

The two columns persist_token_from_order writes, on prima_auto_topups (models/prima_auto_topup.py lines 156 to 160, one row per tenant, tenant_id primary key):

prima_auto_topups (
  tenant_id            uuid PRIMARY KEY REFERENCES workspaces(id) ON DELETE RESTRICT,
  enabled              boolean NOT NULL DEFAULT false,        -- a saved card does not turn this on
  payrexx_token_ref    varchar(255) NULL,     -- the AUTHORIZED transaction id, never the confirmed capture
  card_brand           varchar(32) NULL,      -- only when the PSP said so
  card_last4           varchar(4) NULL,       -- only when already exactly 4 digits; never truncated from a PAN
  disabled_reason      text NULL,             -- 'declined_3x' | 'cap_reached' | 'tenant' | 'no_token'
  consecutive_failures integer NOT NULL DEFAULT 0,
  amount_cents         integer NOT NULL DEFAULT 0,
  monthly_cap_cents    integer NOT NULL DEFAULT 0,
  product_id           uuid NULL REFERENCES products(id) ON DELETE SET NULL,
  ...                                          -- the crossing/retry columns belong to the charge-decision recipe
);
CHECK cap: monthly_cap_cents >= amount_cents

What is NOT stored: a card number, a masked PAN, a CVV, an expiry date - card_last4 is refused rather than filled whenever the PSP's field is longer than 4 digits (_card_details_from_gateway, _LAST4_KEYS = ("last4", "cardLast4"), deliberately not any key whose name means "the card number"; auto_topup_service.py lines 305 to 317, 373 to 393). The claw-back row, on prima_credit_grants (models/prima_credit_grant.py, shared with the top-up grant this recipe does not own):

prima_credit_grants (
  id                   uuid PRIMARY KEY,
  order_id             uuid NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
  tenant_id            uuid NOT NULL,          -- Prima's tenant id, mirrored; never order.workspace_id
  kind                 varchar(16) NOT NULL,   -- 'topup' | 'reversal'
  amount_cents         integer NULL,           -- NULL for a reversal by CHECK amount_shape
  currency             varchar(10) NULL,       -- NULL for a reversal
  status               varchar(16) NOT NULL DEFAULT 'pending',  -- 'pending' | 'granted' | 'stuck'
  provider             varchar(32) NULL,
  provider_event_id    varchar(255) NULL,
  attempts             integer NOT NULL DEFAULT 0,
  ...
);
UNIQUE (order_id, kind)                         -- one grant AND one reversal per order, ever
CHECK amount_shape: (kind='reversal' AND amount_cents IS NULL) OR (kind='topup' AND amount_cents IS NOT NULL AND amount_cents > 0)

A reversal row deliberately carries no amount: Prima looks up what it actually credited for the order and clamps the claw-back to what is still on the balance - enqueue_reversal docstring, prima_credit_grants.py lines 188 to 193. The Order this recipe reads and writes carries extra_metadata.credit_tenant_id (the buyer, never order.workspace_id, which is the selling merchant), extra_metadata.save_card (bool, set only by the server-side tokenize=True checkout path, never body-supplied - checkout_service.py lines 50 to 69), and for an auto-charged order extra_metadata.kind == "auto_topup" plus extra_metadata.crossing_seq and extra_metadata.reason (written by _record_charge, out of this recipe's scope but read by the refund path's mail-suppression check, fulfillment_service.py line 411).

API surface

No route in this recipe mints or reads the token directly; the token is banked as a side effect of the existing webhook and read only by the charge-decision code this recipe excludes. Two routes settle money against an order this recipe cares about:

RouteAuthTenancyCodesNotes
POST /api/orders/webhook/payrexx (commerce_router.py line 1390)none (public; re-fetches the transaction from the workspace's own zahls account before trusting anything in the body)resolved per order, not per caller200 {"ok": true, "matched": bool, ...} always on a handled payload; 400 on a tx/order mismatch after re-fetch or a missing transaction id; 502 if the authoritative re-fetch itself failsOn new_status in ("refunded", "cancelled") calls reverse_fulfillment, which for a credits product calls enqueue_reversal and, only for extra_metadata.kind == "auto_topup", best-effort sends the "refund does not disable auto top-up" mail (lines 1703 to 1704, 393 to 424 in fulfillment_service.py)
POST /api/orders/{order_id}/refund (commerce_router.py line 1266)VerifiedUser + workspace member role or above (IsolationService.verify_workspace_access(..., required_role="member"))strictly workspace-scoped: Order.workspace_id == workspace_id in the initial FOR UPDATE query, so a cross-workspace order id 404s200 OrderResponse with status: "refunded"; 404 order not found (wrong workspace or missing); 409 "Only a paid order can be refunded (current status: ...)" or "Order has no captured Payrexx transaction to refund."; 403 below member role; 502 "Payment provider rejected the refund."Refunds the CAPTURED transaction (order.payrexx_transaction_id, never payrexx_token_ref) via PayrexxService.for_workspace(...).refund_transaction; only on provider success flips order.status = "refunded" and calls reverse_fulfillment - same function the webhook uses, so the two are idempotent against each other

The webhook's order lookup (not a route of its own, but the precondition both refund paths share for an auto-top-up order): an auto-top-up Order carries no payrexx_gateway_id at all (it is a token charge), so it is matched by payrexx_transaction_id FIRST, but only for orders whose extra_metadata.kind == "auto_topup" (commerce_router.py lines 1474 to 1520) - never globally, because a Payrexx transaction id is unique per zahls instance and Labs runs one instance per workspace, so two workspaces can hold the same small integer. _tokenizing_parent_of (line 1360) is the guard against the one collision this scoping does not remove by itself: if zahls copies the save-card order's referenceId onto the token charge it spawns, a naive tx-id match plus a referenceId check would flag two DIFFERENT real orders of ours and refuse; _tokenizing_parent_of recognises the legitimate pair (same credit_tenant_id, the older one carrying save_card: true) and lets the transaction-id match stand.

Application logic

Banking the card (persist_token_from_order, auto_topup_service.py lines 177 to 302), called from fulfillment_service.fulfill_order's credits branch (lines 335 to 338), in order:

  1. No-op if order.extra_metadata.save_card is falsy, or if credit_tenant_id is missing or not a UUID (logs at ERROR; enqueue_topup already raises on a missing tenant a few lines earlier in the same branch, so this function never has to guess one).
  2. ONE gateway fetch (_fetch_gateway, line 319): PayrexxService.for_workspace(db, order.workspace_id).get_gateway(order.payrexx_gateway_id), wrapped so any exception logs a warning and returns {} - this runs inside the webhook's own row-locked transaction, so every round trip here is time the order row is held.
  3. _authorized_token_from_gateway (line 349) walks every transaction on the gateway document (both the invoices[].transactions[] and the flat transactions[] shapes, _gateway_transactions, line 340) and returns the id of the one whose status == "authorized" - never the confirmed capture, which zahls refuses to charge again.
  4. If a token was found, _card_details_from_gateway (line 364) reads (brand, last4) off that SAME transaction's payment block, checking _BRAND_KEYS/_LAST4_KEYS only, and accepting a last-four only when it is already exactly 4 digits (candidate.isdigit() and len(candidate) == 4); anything else is refused, not truncated, and logged at WARNING.
  5. Row upsert under SELECT ... FOR UPDATE on tenant_id: a missing row is created with enabled=False, threshold_sparks=0, amount_cents/monthly_cap_cents from the product's price_cents. An existing row gets payrexx_token_ref, card_brand, card_last4 overwritten only when a token was actually found; consecutive_failures is reset to 0 on any successful bank; disabled_reason is cleared when it was "no_token" or "declined_3x" (a fresh card is the only thing that lifts a three-strike lock - the enabled toggle answers 409 card_declined_3x until the row holds an unstruck token). If the row had no product_id yet, the just-bought tier becomes the default re-charge product and its price becomes the default amount (never body-supplied).
  6. db.flush(), no commit - the caller's single transaction (the webhook's pending -> paid commit) covers this write too.

Invariants

  • The banked token is always the AUTHORIZED transaction, never order.payrexx_transaction_id (the confirmed capture) - verified live against a real Payrexx-connected workspace 2026-06-09 that charging the confirmed id errors "No Transaction found ... status 'authorized' OR 'reserved'".
  • Exactly one gateway fetch per save-card fulfilment; the token and the card's brand/last4 come from the same document (test_a_save_card_topup_banks_the_AUTHORIZED_transaction asserts a single get_gateway call).
  • A card fact is never fabricated: card_brand/card_last4 are NULL unless the PSP's own field, in one of the two allowed key names, is present and (for last4) already exactly four digits; a masked PAN's tail is refused, not truncated (INVARIANT A, primaapi_router.py line 776 - no code on this path may ever receive or keep a full card number).
  • Banking the card is best-effort and never fails the sale: any exception resolving the token is caught, logged at ERROR/exception level, and persist_token_from_order returns None with the credit still queued by enqueue_topup (which itself RAISES on failure, the opposite posture, because the credit is not optional).
  • A new row from a save-card checkout is always created enabled=False: banking a card is not the same act as authorising a standing charge.
  • A refund never mutates prima_auto_topups for the refunded tenant: enqueue_reversal and the auto-top-up notice mail are the entire effect of reverse_fulfillment's credits branch on that table - no column on the instruction row is read or written.
  • A claw-back is queued at most once per order (UNIQUE(order_id, kind) on prima_credit_grants); a duplicate reverse_fulfillment call (the webhook racing the merchant-triggered refund endpoint) hits the IntegrityError branch and returns the existing row rather than raising.
  • The auto-top-up "refund does not cancel the instruction" mail is sent only when extra_metadata.kind == "auto_topup" - a refunded hosted (non-auto) top-up sends no such notice, because the sentence is only true about a charge the standing instruction made.
  • An auto-top-up Order's webhook match by transaction id is confirmed twice: once on the unauthenticated payload (to pick the candidate order) and again on the re-fetched, authenticated copy (fresh.get("id") == order.payrexx_transaction_id, then the referenceId cross-check against _tokenizing_parent_of) - a match is never acted on from the unauthenticated body alone.

Frontend

billing-credits/ui/subscription-manager   the tenant-facing card/auto-top-up surface

Minimum admin/debug surface: SELECT tenant_id, enabled, payrexx_token_ref IS NOT NULL AS has_token, card_brand, card_last4, disabled_reason, consecutive_failures FROM prima_auto_topups WHERE tenant_id = :t (what got banked and whether it is live); SELECT id, order_id, kind, status, attempts, last_error, created_at FROM prima_credit_grants WHERE order_id = :order ORDER BY created_at (whether a top-up or its reversal reached Prima); the log lines "auto top-up: banked card for tenant %s from order %s (token=%s, enabled=%s)" and "prima credits: queued reversal for order %s".

Security & tenancy

Auth: the webhook is public by design, its only proof of authenticity being the authoritative re-fetch against the workspace's own zahls credentials (an optional signature header is checked when both a header and PAYREXX_WEBHOOK_SECRET are present, but its absence does not block settlement, commerce_router.py lines 1416 to 1424); the merchant refund endpoint requires VerifiedUser plus workspace member role, checked explicitly because the WorkspaceID dependency alone admits a viewer.

Tenancy: prima_auto_topups.tenant_id and prima_credit_grants.tenant_id are Prima's tenant id, always taken from order.extra_metadata.credit_tenant_id - the BUYER - and never from order.workspace_id, which is the SELLING merchant (crediting or clawing back against the merchant workspace would credit/debit Alchemy's own account). The refund endpoint's row lookup filters on order.workspace_id == workspace_id from the caller's own session, so a merchant cannot refund another workspace's order by guessing an id.

Fail direction: the money-moving steps fail closed - a PSP-rejected refund raises 502 and touches neither the Order nor prima_credit_grants (test_a_provider_rejected_refund_leaves_the_order_and_the_grant_alone); the card-banking step fails open - any exception leaves the credit fulfilled and the card simply unbanked.

Secrets at rest: none on this path beyond what PayrexxService.for_workspace already manages (the workspace's zahls API credential, not read or stored by this recipe's code); the banked value itself, payrexx_token_ref, is an opaque Payrexx transaction id, useless without the workspace's own API credential to act on it - not a card number and not treated as one.

Attacker-controlled inputs: the entire unauthenticated webhook payload is treated as a hint only - every field that could move money is re-read from Payrexx's own authenticated response (fresh) before being trusted, and order.extra_metadata.save_card/credit_tenant_id can only ever be set server-side (checkout_service.create_checkout's extra_order_metadata keyword, never a CheckoutRequest body field).

Tests

On the scratch database described in backend/tests/prima_credits/conftest.py (a throwaway Postgres migrated to head via PRIMA_CREDITS_TEST_DATABASE_URL; hermetic, never the shared test Postgres), measured at af7a35246dd4a009485bc67b3a13fea6e5a688cc:

  • Card banking, test_auto_topup.py section 1: test_a_save_card_topup_banks_the_AUTHORIZED_transaction (line 164, the one-gateway-read and correct-token assertions), test_a_topup_without_save_card_banks_nothing (225), test_an_unresolvable_token_does_not_fail_the_credit (245, the credit still lands with card: null), test_card_details_are_never_fabricated (281), test_a_masked_pan_is_refused_rather_than_truncated (323, the PCI SAQ-A known-answer case), test_an_already_four_digit_last_four_is_accepted (368).
  • Refund settlement, test_auto_topup_webhook_and_refund.py: test_a_refund_webhook_reaches_an_auto_topup_order_with_no_gateway (131), test_a_foreign_instances_transaction_id_cannot_settle_our_order (166, the cross-workspace tx-id-collision guard), test_a_hosted_orders_referenceid_still_wins_over_a_stale_transaction_id (246), test_a_copied_referenceid_cannot_settle_the_wrong_order (298, the _tokenizing_parent_of negative case - a copied referenceId must not let an auto-charge refund land on the save-card order), test_a_hosted_checkout_still_settles_by_reference_id (351), test_an_auto_topup_order_satisfies_every_precondition_of_the_refund_endpoint (397), test_reversing_an_auto_topup_leaves_the_instruction_on_and_says_so (415, the core invariant of this recipe's refund half), test_a_refunded_hosted_topup_sends_no_auto_topup_notice (462), test_the_refund_endpoint_refunds_an_auto_topup_order_end_to_end (546), test_a_provider_rejected_refund_leaves_the_order_and_the_grant_alone (612).
  • Receipt content sourced from the banked card, test_auto_topup_mails.py: test_the_card_line_prints_only_what_the_psp_told_us (199, parametrised over brand/last4 presence - the negative-space rule that a fabricated fact is worse than a missing one), test_fulfilment_fills_the_manual_receipt_from_the_order_the_grant_and_the_banked_card (284, asserts what _send_credits_receipt PASSES - card_brand/card_last4 from the PrimaAutoTopup row, None/None when no row exists), test_the_manual_receipt_carries_the_same_lines_and_the_same_seller (257); FORBIDDEN (line 48) is checked against every receipt rendered in the file and includes the banked token ref and a full card number, asserting neither ever reaches a mail.
  • Known-answer vectors: _card_details_from_gateway accepts only ("last4", "cardLast4") and ("brand", "cardBrand") as source keys and only a candidate of len == 4 and str.isdigit() as a last-four; a mail's card line is present only when at least one of brand/last4 is known and omits the ···· separator when only the brand is known (test_the_card_line_prints_only_what_the_psp_told_us row ("visa", None, "Karte Visa", "····")).
  • Not independently determinable from the code read for this recipe: whether test_an_unresolvable_token_does_not_fail_the_credit's monkeypatch of subscription_service.resolve_authorized_token_ref has any effect on the assertion - persist_token_from_order never calls that function (it re-implements the authorized-vs-confirmed walk locally over the already-fetched gateway document); the test's outcome is explained instead by PayrexxService.for_workspace falling back to the unconfigured module-level payrexx_service singleton and _fetch_gateway's blanket except Exception returning {}. Running the suite to confirm this empirically was out of scope for this recipe (it requires the scratch database and PRIMA_CREDITS_TEST_DATABASE_URL); this is read from the source, not executed.

Deploy & ops

Config keys: PRIMA_CREDITS_MERCHANT_WORKSPACE_ID, PRIMA_INTERNAL_BASE_URL, PRIMA_CREDIT_GRANT_SECRET, PRIMA_CREDIT_GRANT_TIMEOUT_SECONDS, PRIMA_CREDIT_GRANT_MAX_ATTEMPTS (all core/config.py, cited under Stack assumptions). Migrations touching the tables this recipe reads/writes, in chain order: prima_credit_grants_001 (20260803_0003_prima_credit_grants.py, down_revision prima_key_sync_001) creates prima_credit_grants; prima_auto_topups_001 (20260910_0001_prima_auto_topups.py) creates prima_auto_topups, at af7a352 chained with down_revision = 'subscription_cancel_otp_001' - the measured commit itself (af7a35246dd4a009485bc67b3a13fea6e5a688cc, "migrations: chain auto-topup onto reunified main head") re-pointed this from an earlier root so the branch descends from one alembic head after a rebase; prima_auto_topup_retry_001, prima_auto_topup_marker_001, prima_auto_topup_snapshot_001 follow in sequence and add the crossing/reconcile columns this recipe's scope excludes. Whether prima_auto_topup_snapshot_001 is a single global alembic head at the measured commit was not independently re-verified here (the commit message states the intent; a full alembic heads run against a live database would confirm it and was out of scope).

Scheduler jobs: none started by this recipe's own code, but the claw-back it queues depends on prima_credit_grants.drain_once (the scheduled sweep, _shared/scheduler-and-background-work) as its durability backstop - the inline deliver_for_order call right after the webhook's commit (commerce_router.py lines 1719 to 1735) is purely an optimisation; if the process dies before it runs, or it fails, the row stays pending and the drain delivers it later. Rollback: nothing on this path deletes a row - a prima_credit_grants row that cannot be delivered goes stuck, which is the queryable state a human is expected to reconcile by hand (stuck_grants, prima_credit_grants.py line 463); a bad deploy is rolled back at the code level, not by deleting queued obligations.

Gotchas

  • 2026-09-12 (measured at af7a352): test_an_unresolvable_token_does_not_fail_the_credit (test_auto_topup.py line 245) monkeypatches subscription_service.resolve_authorized_token_ref, a function persist_token_from_order never calls - the real code path is _fetch_gateway + _authorized_token_from_gateway, both local to auto_topup_service.py. The test's expected outcome (no token banked) still holds, but for a different reason: PayrexxService.for_workspace isn't mocked here either, so it falls back to the unconfigured singleton and _fetch_gateway's catch-all returns {}. A rebuild's equivalent test should mock the actual call path so a future change to _fetch_gateway's error handling can't silently break this case unnoticed.
  • 2026-09-12 (measured at af7a352): the webhook's tx-id-first lookup is scoped to extra_metadata.kind == "auto_topup" orders specifically to avoid a cross-workspace collision (two zahls instances can share a small integer transaction id) - but the guard depends on every auto-top-up Order actually carrying that metadata key, written only by _record_charge (outside this recipe's scope). A hand-inserted or migrated auto-top-up-shaped Order missing extra_metadata.kind would fall through to gateway-id matching, which an auto-top-up order also lacks (payrexx_gateway_id is None for a token charge), and its refund webhook would silently log "no matching order" - the exact failure Q3 fixed, reintroduced by a row that doesn't self-identify.
  • 2026-09-12 (measured at af7a352): _card_details (auto_topup_service.py line 397) is a back-compat one-call shim (_fetch_gateway + _card_details_from_gateway) that the save_card path itself never calls - it holds the gateway document already, from persist_token_from_order's own fetch. It exists for tests and any future caller wanting the one-call form; a reviewer finding a second, unused entry point to the same logic should not assume it is dead code to delete without checking callers outside this recipe's read set.

Reference implementation

Paths relative to the repo root; branch w1-auto-topup, commit af7a35246dd4a009485bc67b3a13fea6e5a688cc, dated 2026-09-12, NOT merged to main as of the measured date (git merge-base --is-ancestor w1-auto-topup main fails; 17 commits ahead).

  • backend/services/commerce/auto_topup_service.py: resolve_credit_product (147 to 169), persist_token_from_order (177 to 302), _LAST4_KEYS/_BRAND_KEYS (315 to 316), _fetch_gateway (319 to 337), _gateway_transactions (340 to 346), _authorized_token_from_gateway (349 to 361), _card_details_from_gateway (364 to 394), _card_details (397 to 405).
  • backend/services/commerce/prima_credit_grants.py: enqueue_topup (95 to 184), enqueue_reversal (187 to 233), _payload (245 to 264), deliver (267 to 353), deliver_for_order (431 to 460), stuck_grants (463 to 484).
  • backend/services/commerce/fulfillment_service.py: _send_credits_receipt (187 to 221), fulfill_order's credits branch (298 to 341), reverse_fulfillment (370 to 424).
  • backend/api/routers/commerce_router.py: _tokenizing_parent_of (1360 to 1387), payrexx_webhook (1390 to 1738, the tx-id-first order lookup 1474 to 1520, the authenticated re-verification 1547 to 1592), refund_order (1266 to 1326).
  • backend/models/prima_auto_topup.py (156 to 160 the token/card columns, 266 to 270 the cap CHECK); backend/models/prima_credit_grant.py (67 to 141, the full table incl. amount_shape and uq_prima_credit_grants_order_kind).
  • backend/services/commerce/checkout_service.py (40 to 69, the tokenize/extra_order_metadata contract this recipe's input depends on but does not implement); backend/services/commerce/subscription_service.py (108, 280 to 338: resolve_authorized_token_ref and charge_token, reused by name in the docstrings, not called from this recipe's functions).
  • backend/services/payrexx_service.py: for_workspace (94 to 116), get_gateway (296 to 308), refund_transaction (324 to 344).
  • Tests as listed under Tests, all in backend/tests/prima_credits/.
  • Migrations: backend/migrations/versions/20260803_0003_prima_credit_grants.py, 20260910_0001_prima_auto_topups.py, 20260910_0002_auto_topup_retry.py, 20260910_0003_auto_topup_reconcile_marker.py, 20260911_0001_auto_topup_marker_snapshot.py.

Deviations of the reference from an idealised recipe, dated 2026-09-12: the unmerged branch state itself (maturity: unmerged, not prod); a test whose passing assertion is explained by a fallback path rather than the mock it sets up (Gotchas); an unused back-compat shim (_card_details) alongside the code path that duplicates its logic inline; the tx-id-first webhook guard's correctness depends on metadata written entirely outside this recipe's own functions.

Acceptance checks

Run from alchemy_labs/backend at af7a35246dd4a009485bc67b3a13fea6e5a688cc with a scratch Postgres migrated to head and PRIMA_CREDITS_TEST_DATABASE_URL set (see tests/prima_credits/conftest.py for the createdb + alembic upgrade head setup), or from a new app built to this recipe. Expected observations are exact.

  1. python -m pytest tests/prima_credits/test_auto_topup.py -q --no-cov -o addopts="" -k "save_card_topup_banks or without_save_card_banks_nothing or unresolvable_token or card_details_are_never_fabricated or masked_pan_is_refused or already_four_digit" prints 6 passed.
  2. python -m pytest tests/prima_credits/test_auto_topup_webhook_and_refund.py -q --no-cov -o addopts="" prints 10 passed.
  3. python -m pytest tests/prima_credits/test_auto_topup_mails.py -q --no-cov -o addopts="" -k "card_line_prints_only or fulfilment_fills_the_manual_receipt or manual_receipt_carries_the_same_lines" prints 3 passed.
  4. grep -n "resolve_authorized_token_ref\|_authorized_token_from_gateway" services/commerce/auto_topup_service.py | wc -l prints 3 (the local function's one call site at line 234, its own definition at line 349, and the docstring's naming comparison at line 355) and grep -c "resolve_authorized_token_ref(" services/commerce/auto_topup_service.py prints 0 (resolve_authorized_token_ref is named in prose, never called, from this file).
  5. grep -n "order.workspace_id" services/commerce/prima_credit_grants.py prints exactly one line, 113 - a comment ("is the SELLING MERCHANT, never the buyer"); neither enqueue_topup nor enqueue_reversal reads order.workspace_id as code anywhere in the file.
  6. grep -c "^_LAST4_KEYS = " services/commerce/auto_topup_service.py prints 1 and python3 -c "from services.commerce.auto_topup_service import _LAST4_KEYS, _BRAND_KEYS; print(_LAST4_KEYS, _BRAND_KEYS)" (run inside the backend's virtualenv) prints ('last4', 'cardLast4') ('brand', 'cardBrand').
  7. sed -n '393,424p' services/commerce/fulfillment_service.py | grep -c "PrimaAutoTopup" prints 0 (the refund branch's own code, comments aside, never imports or references the PrimaAutoTopup model - nothing there can read or write the instruction row).

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.