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 |
|
| 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:
| Route | Auth | Tenancy | Codes | Notes |
|---|---|---|---|---|
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 caller | 200 {"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 fails | On 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 404s | 200 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:
- No-op if
order.extra_metadata.save_cardis falsy, or ifcredit_tenant_idis missing or not a UUID (logs at ERROR;enqueue_topupalready raises on a missing tenant a few lines earlier in the same branch, so this function never has to guess one). - 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. _authorized_token_from_gateway(line 349) walks every transaction on the gateway document (both theinvoices[].transactions[]and the flattransactions[]shapes,_gateway_transactions, line 340) and returns the id of the one whosestatus == "authorized"- never theconfirmedcapture, which zahls refuses to charge again.- If a token was found,
_card_details_from_gateway(line 364) reads(brand, last4)off that SAME transaction'spaymentblock, checking_BRAND_KEYS/_LAST4_KEYSonly, 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. - Row upsert under
SELECT ... FOR UPDATEontenant_id: a missing row is created withenabled=False,threshold_sparks=0,amount_cents/monthly_cap_centsfrom the product'sprice_cents. An existing row getspayrexx_token_ref,card_brand,card_last4overwritten only when a token was actually found;consecutive_failuresis reset to 0 on any successful bank;disabled_reasonis cleared when it was"no_token"or"declined_3x"(a fresh card is the only thing that lifts a three-strike lock - theenabledtoggle answers 409card_declined_3xuntil the row holds an unstruck token). If the row had noproduct_idyet, the just-bought tier becomes the default re-charge product and its price becomes the default amount (never body-supplied). db.flush(), no commit - the caller's single transaction (the webhook'spending -> paidcommit) 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_transactionasserts a singleget_gatewaycall). - A card fact is never fabricated:
card_brand/card_last4areNULLunless 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.pyline 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_orderreturnsNonewith the credit still queued byenqueue_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_topupsfor the refunded tenant:enqueue_reversaland the auto-top-up notice mail are the entire effect ofreverse_fulfillment'screditsbranch 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)onprima_credit_grants); a duplicatereverse_fulfillmentcall (the webhook racing the merchant-triggered refund endpoint) hits theIntegrityErrorbranch 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.pysection 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 withcard: 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_ofnegative 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_receiptPASSES -card_brand/card_last4from thePrimaAutoTopuprow,None/Nonewhen 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_gatewayaccepts only("last4", "cardLast4")and("brand", "cardBrand")as source keys and only a candidate oflen == 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_usrow("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 ofsubscription_service.resolve_authorized_token_refhas any effect on the assertion -persist_token_from_ordernever 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 byPayrexxService.for_workspacefalling back to the unconfigured module-levelpayrexx_servicesingleton and_fetch_gateway's blanketexcept Exceptionreturning{}. Running the suite to confirm this empirically was out of scope for this recipe (it requires the scratch database andPRIMA_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.pyline 245) monkeypatchessubscription_service.resolve_authorized_token_ref, a functionpersist_token_from_ordernever calls - the real code path is_fetch_gateway+_authorized_token_from_gateway, both local toauto_topup_service.py. The test's expected outcome (no token banked) still holds, but for a different reason:PayrexxService.for_workspaceisn'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 missingextra_metadata.kindwould fall through to gateway-id matching, which an auto-top-up order also lacks (payrexx_gateway_idisNonefor 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.pyline 397) is a back-compat one-call shim (_fetch_gateway+_card_details_from_gateway) that thesave_cardpath itself never calls - it holds the gateway document already, frompersist_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'screditsbranch (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 thecapCHECK);backend/models/prima_credit_grant.py(67 to 141, the full table incl.amount_shapeanduq_prima_credit_grants_order_kind).backend/services/commerce/checkout_service.py(40 to 69, thetokenize/extra_order_metadatacontract this recipe's input depends on but does not implement);backend/services/commerce/subscription_service.py(108, 280 to 338:resolve_authorized_token_refandcharge_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.
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"prints6 passed.python -m pytest tests/prima_credits/test_auto_topup_webhook_and_refund.py -q --no-cov -o addopts=""prints10 passed.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"prints3 passed.grep -n "resolve_authorized_token_ref\|_authorized_token_from_gateway" services/commerce/auto_topup_service.py | wc -lprints3(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) andgrep -c "resolve_authorized_token_ref(" services/commerce/auto_topup_service.pyprints0(resolve_authorized_token_refis named in prose, never called, from this file).grep -n "order.workspace_id" services/commerce/prima_credit_grants.pyprints exactly one line,113- a comment ("is the SELLING MERCHANT, never the buyer"); neitherenqueue_topupnorenqueue_reversalreadsorder.workspace_idas code anywhere in the file.grep -c "^_LAST4_KEYS = " services/commerce/auto_topup_service.pyprints1andpython3 -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').sed -n '393,424p' services/commerce/fulfillment_service.py | grep -c "PrimaAutoTopup"prints0(the refund branch's own code, comments aside, never imports or references thePrimaAutoTopupmodel - 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.