Module kit / Billing and credits
Prima credit-grant outbox (CHF and Sparks)
billing-credits/prima-credit-grant-outbox
| Kind | backend |
|---|---|
| Domain | billing-credits |
| Maturity | unmerged |
| Reusable | extract |
| Stack | fastapi, sqlalchemy-2, postgres, httpx, apscheduler, pydantic-settings |
| Depends on | _shared/outbox-pattern, _shared/hmac-seam-pattern, _shared/scheduler-and-background-work, _shared/alembic-migrations, _shared/workspace-scoping, _shared/test-layout |
| Frontends | billing-credits/ui/subscription-manager |
| Reference implementation |
|
| Measured on | alchemy_labs w1-auto-topup af7a35246dd4a009485bc67b3a13fea6e5a688cc 2026-09-12 |
| Version | 1 |
Purpose
A settled CHF top-up (a Payrexx payment against a credits Product) and a Labs-side Sparks grant (a subscription allowance, the one-time welcome bonus, or a deliberate internal team top-up) both become "Prima owes this tenant balance" durably, the instant the causing transaction commits, without ever calling Prima from inside that transaction. The invariant that makes it trustworthy is that both paths write an outbox row carrying one idempotency anchor per obligation (the PSP's own transaction id for a CHF top-up, a deterministic string for everything Sparks) and that anchor rides every retry unchanged, so a redelivered webhook or a re-run drain can never mint the same credit twice: Prima's own unique index refuses the duplicate and answers 200, which this outbox treats as success because the work is already done.
When to use
Read it before adding a third way to tell Prima to change a tenant's balance, before touching prima_credit_grants or sparks_grants, before wiring a new Sparks grant occasion, or when someone asks "why does this customer have no credit after paying", "how do I find a stuck top-up", "why did the Sparks allowance not renew" or "can a retried webhook double-credit someone". Trigger phrases: "top-up", "prepaid credit", "credit grant", "Sparks allowance", "welcome grant", "team grant", "the outbox is stuck", "provider_event_id", "duplicate: true", "grant drain".
Not this recipe: the general outbox shape (three verbs, the row shape, the retry policy, the due-query, the scheduler wiring) - that is _shared/outbox-pattern (S11), which this recipe's tables are two of the three worked examples in. The HMAC signing scheme itself (canonical string, header names, why the body is hashed not concatenated) is _shared/hmac-seam-pattern (S10); prima_credit_sig.py is shared verbatim by both outboxes in this recipe and is not re-derived here. Prima's own receiving side (POST /internal/credits/grant, the ledger, the two-bucket ledger split) is Prima's codebase and out of scope - this recipe is Labs' half only, and says plainly where Labs' assumption about Prima's contract is unverified (Gotchas). The auto-top-up standing-instruction feature (prima_auto_topups, auto_topup_service.py, threshold pushes) is a different obligation - it decides WHEN to charge a card, not how a settled charge or a Sparks occasion becomes an outbox row - and belongs to its own recipe. The Sparks low-balance email (sparks_low_balance.py, sparks_low_balance_alerts) is a notification dedup, not a grant, and is not this recipe. The Sparks tier catalogue and pricing (core/sparks_config.py's tier table) is read by this recipe but owned by whichever recipe covers POST /sparks/checkout and POST /sparks/change.
Stack assumptions
Labs at af7a352: fastapi==0.118.2, synchronous SQLAlchemy 2.0.43 ORM sessions, httpx==0.28.1 for the outbound call (imported lazily inside deliver() in both modules, not at module load), APScheduler==3.11.0 for the drain, pydantic-settings==2.11.0 for core/config.py. Python 3.11+ (repo pin). Foundations assumed: S11 for the general outbox contract this recipe's two tables help define; S10 for the signing scheme (services/commerce/prima_credit_sig.py, unchanged by this recipe, only consumed); S12 for the leader-only scheduler the two drains register on; S8 for the four dated migrations that built and later widened these tables; S6 for why tenant_id is a mirrored value with no foreign key.
Environment (core/config.py, all measured 2026-09-12): PRIMA_INTERNAL_BASE_URL (line 767, shared by both outboxes and by the unrelated key-sync seam; unset means every row queues pending and nothing is ever delivered - a visible backlog, not a silent loss); PRIMA_CREDIT_GRANT_SECRET (line 755, SecretStr; the Sparks outbox reuses this same variable, sparks_grants.py lines 360 and 383 read settings.PRIMA_CREDIT_GRANT_SECRET, there is no separate PRIMA_SPARKS_GRANT_SECRET); PRIMA_CREDIT_GRANT_TIMEOUT_SECONDS (line 756, default 10.0, shared by both); PRIMA_CREDIT_GRANT_MAX_ATTEMPTS (line 757, default 24, shared by both). A model_validator (lines 782 to 839) refuses to boot the process if PRIMA_CREDIT_GRANT_SECRET equals PRIMA_KEY_SYNC_SECRET or PRIMA_LOW_BALANCE_ALERT_SECRET - raised as RuntimeError, deliberately not ValueError, so Pydantic's own error path (which echoes the whole input dict) never puts a credential value in a boot-failure log. Fail behaviour of the outbox itself is described end to end in Application logic; there is no environment variable that changes it.
Data model
Two tables, one shape, two migrations plus one widening migration (sparks_kind_valid/sparks_shape_valid gained 'team' in 20260824_0001_sparks_team_grants.py, after the base 20260808_0001_sparks_billing.py). Transcribed from models/prima_credit_grant.py and models/sparks_billing.py:
-- prima_credit_grants (migrations/versions/20260803_0003_prima_credit_grants.py)
CREATE TABLE prima_credit_grants (
id uuid PRIMARY KEY,
order_id uuid NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
tenant_id uuid NOT NULL, -- the BUYER's workspace; never orders.workspace_id (the merchant)
kind varchar(16) NOT NULL DEFAULT 'topup', -- 'topup' | 'reversal'
amount_cents integer, -- what Payrexx captured; NULL only for a reversal
currency varchar(10),
bonus_credit_micro integer, -- from the product's server-side metadata; Prima honours it only above the cash value
provider varchar(32),
provider_event_id varchar(255), -- THE idempotency anchor: order.payrexx_transaction_id, falling back to the gateway id
status varchar(16) NOT NULL DEFAULT 'pending', -- 'pending' | 'granted' | 'stuck'
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz,
last_error text, -- a class name or 'HTTP <n>', never a response body
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
granted_at timestamptz,
CONSTRAINT kind_valid CHECK (kind IN ('topup','reversal')),
CONSTRAINT status_valid CHECK (status IN ('pending','granted','stuck')),
CONSTRAINT amount_shape CHECK ((kind = 'reversal' AND amount_cents IS NULL) OR
(kind = 'topup' AND amount_cents IS NOT NULL AND amount_cents > 0)),
CONSTRAINT uq_prima_credit_grants_order_kind UNIQUE (order_id, kind) -- one grant + one reversal per order, ever
);
CREATE INDEX ix_prima_credit_grants_tenant ON prima_credit_grants (tenant_id);
CREATE INDEX ix_prima_credit_grants_status ON prima_credit_grants (status);
CREATE INDEX ix_prima_credit_grants_due ON prima_credit_grants (status, next_attempt_at); -- the drainer's query
-- sparks_grants (migrations/versions/20260808_0001_sparks_billing.py, widened by 20260824_0001_sparks_team_grants.py)
CREATE TABLE sparks_grants (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL, -- mirrored, no FK, same convention as prima_credit_grants.tenant_id
kind varchar(16) NOT NULL, -- 'allowance' | 'welcome' | 'team'
subscription_id uuid REFERENCES subscriptions(id) ON DELETE SET NULL, -- informational only, allowance only
tier varchar(20), -- 'free'|'starter'|'pro'|'max'; NULL for welcome/team
period_key varchar(20), -- 'YYYY-MM'; NULL for welcome/team
spark_amount integer NOT NULL, -- native unit, for audit/display
amount_micro integer NOT NULL, -- what goes on the wire; 32-bit, so enqueue_team_grant caps at (2^31-1)//1000 sparks
expires_at timestamptz, -- allowance only; welcome and team never expire
provider varchar(32) NOT NULL DEFAULT 'sparks',
provider_event_id varchar(255) NOT NULL, -- deterministic: see the three enqueue_* functions below
status varchar(16) NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
granted_at timestamptz,
CONSTRAINT sparks_kind_valid CHECK (kind IN ('allowance','welcome','team')),
CONSTRAINT sparks_status_valid CHECK (status IN ('pending','granted','stuck')),
CONSTRAINT sparks_shape_valid CHECK ((kind IN ('welcome','team') AND tier IS NULL AND period_key IS NULL) OR
(kind = 'allowance' AND tier IS NOT NULL AND period_key IS NOT NULL)),
CONSTRAINT sparks_amount_positive CHECK (spark_amount > 0),
CONSTRAINT uq_sparks_grants_provider_event UNIQUE (provider_event_id) -- the ONLY guard against a double grant at this table
);
CREATE INDEX ix_sparks_grants_tenant ON sparks_grants (tenant_id);
CREATE INDEX ix_sparks_grants_status ON sparks_grants (status);
CREATE INDEX ix_sparks_grants_due ON sparks_grants (status, next_attempt_at);
provider_event_id per kind, exactly as built in sparks_grants.py: enqueue_allowance → f"sparks-allowance-{tenant_id}-{period_key}" where period_key = period_start.strftime("%Y-%m") (line 89, period_key()); enqueue_welcome → f"sparks-welcome-{tenant_id}" (line 262, tenant-only, deliberately, so there is exactly one ever); enqueue_team_grant → f"sparks-team-{tenant_id}-{reference}" (line 214) where reference is a caller-supplied, non-empty string, checked so a team top-up cannot collide with the tenant's one welcome key and so a repeated reference is idempotent while a new one is not silently dropped (test_sparks_team_grant.py::test_a_team_grant_cannot_collide_with_the_welcome_grant).
What is NOT stored, in either table: a balance, an account row, or anything from prima_credit_accounts / prima_credit_entries - those tables exist only in Prima's own database and chain (founder ruling, 2026-08-03, models/prima_credit_grant.py lines 1 to 12); a signature or nonce (both are re-made fresh per attempt by S10, so there is no signature column to go stale); the receiver's response body (last_error is a class name or HTTP <n> only); a µCHF amount on the CHF table (amount_cents only - the cents-to-µCHF bridge is Prima's one constant, on the side that owns the ledger); an amount on a reversal row (Prima looks up and clamps the claw-back itself). sparks_grants carries no order_id at all, unlike prima_credit_grants: the Free tier and the welcome grant are both CHF 0, so no Payrexx gateway is ever opened for them and there is often no orders row to reference (models/sparks_billing.py lines 20 to 24).
API surface
Neither table has a route of its own; the "surface" is the signed outbound call and the function contract every caller uses.
Outbound (both outboxes call the same path with different bodies - S10 signs it): POST {PRIMA_INTERNAL_BASE_URL}/internal/credits/grant and, for a CHF reversal only, POST {PRIMA_INTERNAL_BASE_URL}/internal/credits/reverse (GRANT_PATH / REVERSE_PATH, prima_credit_grants.py lines 75 to 76). Response handling, identical in both deliver() functions (prima_credit_grants.py lines 267 to 353, sparks_grants.py lines 351 to 420):
| Status | Meaning here | Row outcome |
|---|---|---|
200 | Granted, or {"duplicate": true} - both are terminal success | status='granted', granted_at=now(), next_attempt_at=NULL, last_error=NULL |
4xx except 408, 429 | Our own bug (bad body, bad signature, an amount out of range) - not retryable | status='stuck' immediately, last_error='HTTP <n>', next_attempt_at=NULL |
408, 429, 5xx | Transient | _fail(): backoff, or stuck once attempts >= PRIMA_CREDIT_GRANT_MAX_ATTEMPTS |
| transport exception | Timeout, connection refused, etc. | _fail() with last_error=type(exc).__name__, same backoff/stuck rule |
PRIMA_INTERNAL_BASE_URL or the credential unset | Not configured | _fail(row, "GrantNotConfigured", None) - row stays pending, visible |
Request body per kind, exactly as built by each module's _payload():
- CHF
topup(prima_credit_grants.pylines 251 to 264):{"tenant_id", "amount_cents", "currency", "provider", "provider_event_id", "order_id"}plus"bonus_credit_micro"when the product metadata set one. Neveramount_micro. - CHF
reversal(same function, lines 246 to 250):{"tenant_id", "order_id"}only - no amount. - Sparks
welcome/team(sparks_grants.pylines 308 to 330):{"tenant_id", "bucket": "purchase", "purchase_sparks", "provider", "provider_event_id"}. - Sparks
allowance(lines 332 to 348):{"tenant_id", "bucket": "allowance", "allowance_sparks", "allowance_expires_at", "provider", "provider_event_id"}.
Function contract, identical shape across both modules (the general form is S11's table; this is what the two modules in this recipe actually export):
| Function | Commits | Raises | Returns |
|---|---|---|---|
enqueue_topup(db, order, product) | no | yes - no/malformed/unknown tenant, non-positive amount | the row, or the existing row on a (order_id, kind) collision |
enqueue_reversal(db, order) | no | no | the row, the existing row on collision, or None if nothing was ever granted for the order |
enqueue_allowance(db, *, tenant_id, tier, period_start, period_end, subscription_id=None) | no | on an unknown SparksTier string (ValueError/KeyError from SparksTier(tier)) | the row, or the existing row for that (tenant_id, period) |
enqueue_welcome(db, *, tenant_id) | no | no | the row, or the existing row if one already exists for that tenant |
enqueue_team_grant(db, *, tenant_id, sparks, reference) | no | ValueError (non-positive sparks, blank reference), TeamGrantTooLarge (over MAX_TEAM_GRANT_SPARKS) | the row, or the existing row for that (tenant_id, reference) |
deliver(db, row) | no (caller commits) | never | True if the row reached granted |
drain_once(db, limit=50) | yes, per row | never | {"due": n, "delivered": m} |
deliver_for_order(db, order_id, *, kind="topup") | yes, once | never past its own log | True/False |
deliver_for_grant(db, grant_id) | yes, once | never past its own log | True/False |
stuck_grants(db, *, older_than_minutes=15) | no | no | rows with status='stuck', plus pending older than the cutoff |
Application logic
For a paid CHF top-up, in commit order:
- The Payrexx webhook (
commerce_router.py::payrexx_webhook) authoritatively re-verifies the transaction and moves the orderpending→paidinside its own transaction (a different recipe's concern). Onpaid, it callsfulfill_order(db, order)(line 1697), still inside that transaction, before the singledb.commit()at line 1706. fulfill_orderroutes onproduct.kind == "credits"(fulfillment_service.pyline 298) and callsenqueue_topup(db, order, product)(line 322).enqueue_topupreadsorder.extra_metadata["credit_tenant_id"]- neverorder.workspace_id, which is the selling merchant, not the buyer - parses it as a UUID, checks aworkspacesrow exists for it, checksorder.amount_cents > 0, picksprovider_event_id = order.payrexx_transaction_id or order.payrexx_gateway_id, reads an optional integer bonus fromproduct.extra_metadata["credit_micro"](a non-integer value is logged and dropped, not sent), and builds the row withstatus="pending",attempts=0,next_attempt_at=now()(prima_credit_grants.pylines 95 to 184). It does not callhttpxand does not commit.- Any failure to establish WHO to credit - no tenant id, a malformed one, an unknown one, a non-positive amount - RAISES a
ValueError. This is the opposite of every other fulfilment hook in the file, which are best-effort (a failed confirmation email must not roll back a paid order): here, swallowing the exception would mean the money was captured and the obligation silently never recorded, so the webhook is allowed to 500 and Payrexx redelivers into this same idempotent path (fulfillment_service.pylines 316 to 320). - If the
INSERTcollides onuq_prima_credit_grants_order_kind(a redelivered webhook that got here twice),enqueue_topuprolls back and returns the existing row rather than raising (lines 163 to 179) - the obligation is already recorded, which is all the call was for. db.commit()(commerce_router.pyline 1706, the same commit that settles the order). After this, "the buyer is owed credit" is exactly as durable as "the buyer paid".- INLINE, after that commit, on the same session: if the product kind was
credits,deliver_for_order(db, order.id, kind="topup")runs throughasyncio.to_thread(commerce_router.pylines 1719 to 1729, becausehttpx.postblocks and the endpoint is async). It locks the pending rowFOR UPDATE SKIP LOCKED, callsdeliver, and commits itself - wrapped in atry/exceptthat only logs, because the row is already durable and a failure here costs a minute, not a customer (lines 1730 to 1735). - A refund or chargeback (
new_status in ("refunded", "cancelled")) callsreverse_fulfillment, which callsenqueue_reversal(db, order)(fulfillment_service.pyline 401) if atopupgrant exists for the order; it carries no amount because Prima looks up what it actually credited and clamps the claw-back to what is still on the balance. The same webhook's inline step then callsdeliver_for_order(db, order.id, kind="reversal"). - DRAIN, every minute, from the leader-only scheduler (
services/scheduled_jobs.py::run_prima_credit_grant_drain, registered at lines 337 to 345 withIntervalTrigger(minutes=1),coalesce=True,max_instances=1): selects up to 50pendingrows whosenext_attempt_atisNULLor past, oldest first,FOR UPDATE SKIP LOCKED, delivers each, commits per row (not per batch - one throwing row must not revert the successful deliveries beside it back topending).
For a Sparks grant, the same three-verb shape with a different cause per kind:
- Allowance: a Payrexx webhook for a Sparks-tier subscription (first charge or a monthly renewal) resolves the
Subscriptionrow, sets its period,db.flush()es, then callssparks_grants.enqueue_allowance(db, tenant_id=..., tier=..., period_start=..., period_end=..., subscription_id=sub.id)(webhooks_router.py::handle_sparks_payment_confirmed, lines 847 to 854),db.commit(), thensparks_grants.deliver_for_grant(db, grant.id)inside its owntry/exceptthat only logs (lines 860 to 872) - the same commit-then-deliver-after shape as the CHF path, for the same reason. - Welcome:
services/primaapi/identity.pycallssparks_grants.enqueue_welcome(db, tenant_id=ws_id)at account creation (line 377) anddeliver_for_grantafter the commit (line 429), inside a block the surrounding code documents as never allowed to fail the signup. - Team: an operator runs
scripts/grant_team_sparks.py, which validates the sparks amount againstMAX_TEAM_GRANT_SPARKSbefore calling the service (so the CLI's own error message names the ceiling rather than surfacing a raw overflow), callsenqueue_team_grant(db, tenant_id=..., sparks=..., reference=...), commits, thendeliver_for_grant. - DRAIN:
services/scheduled_jobs.py::run_sparks_grant_drain, registered at lines 370 to 378, sameIntervalTrigger(minutes=1)shape. This job's docstring (lines 812 to 831) states plainly that until it was added,sparks_grants.drain_onceexisted and nothing scheduled ever called it, so a failed inline delivery for a welcome, allowance or team grant stayedpendingforever with no retry (Gotchas). _fail(row, exception_class, status), identical in both modules:last_erroris the exception class name or"HTTP <n>", never a response body; ifattempts >= PRIMA_CREDIT_GRANT_MAX_ATTEMPTSthe row becomesstuckwithnext_attempt_at=NULL; otherwisestatus='pending'andnext_attempt_at = now() + backoff(attempts), where_BACKOFF_SECONDS = (5, 15, 60, 300, 900, 3600)is the same literal tuple in both files, indexed byattempts - 1and clamped at the last entry.
Invariants
- The enqueue runs inside the caller's transaction and never commits; only the caller commits, exactly once - so the obligation and its cause become durable together (
test_a_paid_credits_order_queues_a_grant_for_the_BUYER_not_the_merchant,test_credit_topup_fulfilment.pyline 80). - Every attempt for one obligation carries the SAME
provider_event_id- never regenerated per attempt - which is what makes a retry safe: Prima's own unique index refuses the duplicate insert and returns 200, and this outbox treats that as success (test_every_attempt_carries_the_same_psp_event_id,test_credit_grant_client.pyline 187;test_every_attempt_carries_the_same_deterministic_event_id,test_sparks_grant_client.pyline 217). - A redelivered webhook produces exactly one row, never a second:
enqueue_topupcollides onuq_prima_credit_grants_order_kindand returns the existing row (test_a_replayed_webhook_delivery_queues_one_grant);enqueue_allowanceandenqueue_welcomecollide onuq_sparks_grants_provider_eventthe same way (test_a_retried_webhook_delivery_does_not_double_grant_the_same_period,test_welcome_grant_called_twice_for_the_same_tenant_is_a_single_row). - The enqueue RAISES when it cannot establish who is owed what (
enqueue_topupwith no or an unknowncredit_tenant_id); this is the opposite polarity from every other best-effort fulfilment hook in the same file, deliberately, because the money is already captured. deliver()never raises past itself on a transport or server-side failure; a failure is recorded as row state, not an exception a caller must catch correctly (test_a_transport_failure_leaves_a_pending_row_with_a_backoff, both client test files).last_erroris content-free: a class name or an HTTP status, never a response body - the receiver is described in both modules' comments as "an inference gateway" and this is a payments table in Labs (test_the_error_recorded_is_a_class_or_a_status_never_a_response_body).- A
teamgrant rides thepurchasebucket, neverallowance:allowanceRESETS a tenant's balance and forfeits the remainder, so routing a top-up there would destroy the very Sparks it was meant to add to (test_team_grant_rides_the_purchase_bucket_not_the_allowance_bucket, the single test named "the one that matters most for a top-up"). - The
welcomegrant is exactly once per tenant, ever, by construction of its deterministic event id (sparks-welcome-{tenant_id}, tenant-only, no reference); ateamgrant cannot collide with it because a team event id always carries a caller-supplied reference (test_a_team_grant_cannot_collide_with_the_welcome_grant). - A CHF reversal names no amount; Prima looks up what it actually credited for the order and clamps the claw-back to what is still on the balance - a second opinion on the number from Labs' side is exactly what would let a refund take more than the top-up gave (
test_a_refund_queues_a_reversal, and the module docstring). amount_centsis the only unit on the wire for CHF;amount_microis the only unit on the wire for Sparks. Neither module ever sends the other's unit (test_the_wire_carries_cents_and_never_micro_chf; the allowance/welcome/team payload tests assert"amount_micro" not in body"is the WRONG assertion for those - they assert the Sparks bucket fields and the absence ofamount_cents/kind, since Prima's two-bucket contract refuses all three, pertest_allowance_payload_uses_the_allowance_bucket_and_expiry's docstring).- Labs holds no ledger anywhere on this path:
test_labs_has_no_credit_ledger_tableassertsto_regclass('prima_credit_accounts')andto_regclass('prima_credit_entries')are bothNULL, andtest_no_labs_source_file_executes_anything_against_the_prima_ledgerparses every.pyfile's AST (excluding docstrings and comments) for a live string naming either table. - No card-data field exists anywhere on either credits path:
test_no_card_data_field_anywhere_on_the_credits_pathparses the AST of each listed file (includingprima_credit_grant.py,prima_credit_grants.py,prima_credit_sig.py) for any bound or read name matching a closed set of card-field names (pan,cvc,card_number, etc.) - the payer types their card on Payrexx's own hosted page and no endpoint on this path can receive one.
Frontend
The one module the task names for this domain, per the frontmatter's frontend_modules:
billing-credits/ui/subscription-manager the tenant-facing view of Sparks tier, allowance
period and top-up history this outbox feeds;
not independently verified against a live route
in this pass (see Gotchas - no admin console for
either outbox exists at this commit)
Minimum admin/debug surface, from the module docstrings (there is no admin route for either table at this commit - S11's F30 outbox console is planned and covers both):
SELECT g.id, g.order_id, g.tenant_id, g.amount_cents, g.attempts, g.last_error, g.created_at, o.customer_email
FROM prima_credit_grants g JOIN orders o ON o.id = g.order_id
WHERE g.status <> 'granted'
ORDER BY g.created_at;
and the equivalent named function, stuck_grants(db, older_than_minutes=15) in each module - status='stuck' OR (status='pending' AND created_at older than the cutoff). Log lines to grep: "prima credits: grant %s is STUCK after %s attempts" and "sparks grants: grant %s is STUCK after %s attempts".
Security & tenancy
Auth dependency: none on the outbox functions themselves - they are called from inside already-authenticated or already-verified paths (the Payrexx webhook's own signature/re-fetch verification, an authenticated signup flow, an operator running a script at a shell). The outbound HTTP call is what carries authority, via S10's HMAC signature over (timestamp, nonce, method, path, sha256(body)) with PRIMA_CREDIT_GRANT_SECRET; nothing about the outbox rows themselves is guarded by a FastAPI dependency because they have no route.
Workspace derivation: tenant_id is written once, at enqueue time, and never re-derived. For a CHF top-up it comes from order.extra_metadata["credit_tenant_id"] - explicitly NOT order.workspace_id, which is the selling merchant's workspace; crediting it would credit Alchemy instead of the buyer (enqueue_topup's comment, prima_credit_grants.py lines 113 to 114, and the test named exactly for this: test_a_paid_credits_order_queues_a_grant_for_the_BUYER_not_the_merchant). For Sparks, tenant_id is the Labs workspace id passed in by the caller (the webhook's resolved Subscription.workspace_id, or the workspace id at signup); no FK to workspaces on either table, because the value is mirrored into another service's database and must mean the same thing there even if the local row is later removed (models/sparks_billing.py lines 52 to 54).
Fail direction: enqueue fails CLOSED (enqueue_topup raises on an unresolvable tenant, and the caller's 500 makes Payrexx retry); delivery fails SAFE (deliver() never raises past itself; an unconfigured PRIMA_INTERNAL_BASE_URL or credential is a stalled, visible pending row, not a dropped one, not an exception). Secrets at rest: none on either row - the credential lives only in settings.PRIMA_CREDIT_GRANT_SECRET, read fresh on every deliver() call; a fresh nonce and timestamp are generated per attempt (signed_headers), so there is no stored signature to leak or replay.
Attacker-controlled inputs: none reach either table directly from a request body. credit_tenant_id comes from order.extra_metadata, itself set server-side at checkout, not from the webhook payload; the webhook's own fields (transaction id, amount, currency) are the authoritative-re-fetch values a different recipe verifies before fulfill_order is even called. The Sparks reference on a team grant is operator-supplied at a shell, not request-facing. Rate limiting toward Prima is the backoff table itself plus the drain's limit=50 per run; there is no per-tenant limit on this path because a row is only ever created for a cause already established elsewhere (a verified capture, an authenticated signup, an operator at a shell).
Tests
All five files are read at af7a352; test counts below are STATIC (grep -c '^def test_'), not an executed run - this environment had no pytest installed for the repo's pinned Python and no reachable Postgres, so the live-database suites could not actually be run in this pass (a genuine gap: acceptance check 1 below states the commands to run them for real, and their pass/fail is unverified here).
tests/prima_credits/test_credit_grant_client.py- 17 tests, no database (hermetic; runs unconditionally). Covers the S10 known-answer vector shared with Prima's own test suite (test_the_signature_matches_the_prima_vector), the delivery state machine (test_a_successful_delivery_marks_the_row_granted,test_a_duplicate_answer_is_success,test_a_transport_failure_leaves_a_pending_row_with_a_backoff,test_attempts_exhausted_becomes_STUCK_and_is_never_deleted,test_a_4xx_goes_straight_to_stuck_rather_than_burning_the_retries,test_the_two_retryable_4xx_are_still_retriedparametrized on 408/429), the wire shape (test_the_wire_carries_cents_and_never_micro_chf,test_a_reversal_sends_no_amount), andtest_the_backoff_grows_and_is_bounded.tests/prima_credits/test_credit_topup_fulfilment.py- 15 tests, live against a scratch Postgres named byPRIMA_CREDITS_TEST_DATABASE_URL(module-levelpytestmarkskips the whole file when unset). Covers the buyer-not-merchant tenant resolution, replay-produces-one-row, the raise-on-unresolvable-tenant path, the estate invariant that Labs holds no ledger table (test_labs_has_no_credit_ledger_table, an AST scan for live SQL against the two Prima-only tables), and INVARIANT A as a parametrized AST scan for card-data field names across every file on the credits path.tests/sparks/test_sparks_grant_client.py- 14 tests, no database. The three payload shapes (test_allowance_payload_uses_the_allowance_bucket_and_expiry,test_welcome_payload_uses_the_purchase_bucket_and_never_expires,test_team_grant_rides_the_purchase_bucket_not_the_allowance_bucket), that both outboxes share one wire and one signing scheme (test_it_posts_to_the_same_grant_path_as_credits_topups,test_it_signs_with_the_same_scheme_as_prima_credit_sig), the identical state machine, andtest_backoff_schedule_matches_prima_credit_grants(assertsgrants._BACKOFF_SECONDS == topup_grants._BACKOFF_SECONDS- one retry policy for the whole seam).tests/sparks/test_sparks_team_grant.py- 7 tests, no database. Whatenqueue_team_grantrefuses (non-positive sparks, a blank orNonereference, an amount that would overflow the 32-bitamount_microcolumn) and the row it builds, includingtest_the_event_id_is_scoped_to_tenant_and_referenceandtest_a_team_grant_cannot_collide_with_the_welcome_grant.tests/sparks/test_sparks_grants_db.py- 12 tests, live againstSPARKS_TEST_DATABASE_URL. Tier-to-amount for all four Sparks tiers (free/starter/pro/max, parametrized), the exact webhook call shape (test_a_confirmed_payment_queues_exactly_one_allowance_grant), replay-does-not-double-grant-the-period, a renewal next month IS a new grant (test_a_renewal_next_month_is_a_new_grant_not_a_duplicate), welcome exactly-once, welcome-and-allowance-coexist, and the seed script's idempotency (test_seed_is_idempotent_across_repeated_runs, three runs, one row).
Known-answer values pinned across these files: _BACKOFF_SECONDS = (5, 15, 60, 300, 900, 3600), identical in both modules; the S10 signature vector (KAT_SECRET/KAT_TIMESTAMP/KAT_NONCE/KAT_BODY in test_credit_grant_client.py, shared with prima/tests/test_credit_grant_seam.py::test_the_known_answer_vector per that file's own docstring, not independently verified here since the Prima repo was not read in this pass); the three provider_event_id string shapes for Sparks.
Deploy & ops
Config keys (per S13's app heredoc): PRIMA_INTERNAL_BASE_URL, PRIMA_CREDIT_GRANT_SECRET, PRIMA_CREDIT_GRANT_TIMEOUT_SECONDS, PRIMA_CREDIT_GRANT_MAX_ATTEMPTS - all four shared verbatim between the CHF and Sparks outboxes; there is no separate Sparks-specific credential or timeout.
Migrations, in order: 20260803_0003_prima_credit_grants.py (creates prima_credit_grants, widens products.kind to admit 'credits'), 20260808_0001_sparks_billing.py (creates sparks_grants and sparks_low_balance_alerts, adds subscriptions.workspace_id), 20260824_0001_sparks_team_grants.py (drops and recreates sparks_kind_valid and sparks_shape_valid to admit kind='team' - Postgres has no ALTER CONSTRAINT for a CHECK, so this is a drop-and-recreate; its downgrade() refuses to run while any row has kind='team', raising RuntimeError naming the count, rather than silently narrowing the constraint under live rows). All three are additive; the downgrade of either grant-table migration drops the table and with it every undelivered obligation, so neither is meant to run on a database that has ever taken a payment or issued a grant.
Scheduler jobs (services/scheduled_jobs.py, both registered on the same leader-only APScheduler at lines 322 to 378): prima_credit_grant_drain (run_prima_credit_grant_drain, IntervalTrigger(minutes=1), coalesce=True, max_instances=1) and sparks_grant_drain (run_sparks_grant_drain, same interval and flags). Both are idempotent by construction at Prima's own unique index, which is why running every minute from a single worker is safe and would stay safe from two.
Argus-style check: stuck_grants() on either module returns an empty list; a nonempty one is a customer or a tenant who is owed something they do not have. Rollback: nothing deployable beyond the code and the additive migrations above; a stuck row is never deleted by any rollback and stays queryable.
Gotchas
- 2026-09-12 (measured at af7a352):
sparks_grants.py's own module docstring (lines 27 to 49) states plainly that Prima's CURRENTLY VERIFIED contract, at the time this file was written, recognised onlyprima_credit_grants' shape (kind IN ('topup','reversal'),amount_cents); the two-bucket{"bucket": "allowance"|"purchase", ...}shape this module actually sends is described as "the two-bucket agent's side of this seam" and was NOT independently confirmed against Prima's code in this pass (the Prima repository was not read while writing this recipe). The client tests (test_sparks_grant_client.py) pin what LABS sends; they cannot pin what Prima's handler at any given commit actually does with it. Treat the payload shapes in API surface as "what Labs sends", not as a verified round-trip. - 2026-09-12 (measured at af7a352): until
run_sparks_grant_drainwas added to the scheduler,sparks_grants.drain_onceexisted as a function but nothing scheduled ever called it (services/scheduled_jobs.pylines 812 to 822, the job's own docstring names this explicitly as "the missing" half). A welcome, allowance or team grant whose inlinedeliver_for_grantfailed - Prima down, a timeout, the process dying between commit and the inline call - satpendingindefinitely with no retry, and the tenant was simply never given their Sparks, with no error anywhere. The CHF outbox's drain (prima_credit_grant_drain) did not have this gap at the same commit. - 2026-09-12 (measured at af7a352):
PRIMA_CREDIT_GRANT_SECRETis the credential for BOTH outboxes; there is no independent credential scoped to Sparks alone. Anyone who can mint a valid signature with this one value can grant CHF credit or Sparks to any tenant - the boot-time distinctness check (core/config.pylines 782 to 839) only guards against this credential colliding with the unrelated key-sync or low-balance-alert credentials, not against the CHF and Sparks obligations sharing one credential by design. - 2026-09-12 (measured at af7a352): the two
drain_onceimplementations are near-identical (per-row commit,FOR UPDATE SKIP LOCKED, same due-query shape) but are two separate functions in two separate modules rather than one shared implementation parameterised by model class;_BACKOFF_SECONDS,_fail, and the delivery response-handling branches are each duplicated verbatim betweenprima_credit_grants.pyandsparks_grants.py.test_backoff_schedule_matches_prima_credit_grantspins that the two tuples stay equal, but nothing enforces the rest of the duplication staying in sync beyond that one assertion and code review. - 2026-09-12 (measured at af7a352): no admin or debug route exists for either table at this commit; the only way to find a stuck row is the SQL in each module's docstring or the named
stuck_grants()function run from a database prompt or a script.frontend_modulesin this recipe's frontmatter namesbilling-credits/ui/subscription-manageras the one Sparks/billing-facing UI in this domain, but that module was not independently confirmed to render either outbox's status in this pass - it is the closest known frontend module in the domain, not a verified consumer ofstuck_grants().
Reference implementation
Paths relative to the repo root; branch w1-auto-topup, commit af7a35246dd4a009485bc67b3a13fea6e5a688cc, dated 2026-09-12. This commit is NOT an ancestor of alchemy_labs's origin/main (git merge-base --is-ancestor af7a352 origin/main exits 1, checked 2026-09-12) - hence maturity: unmerged.
backend/services/commerce/prima_credit_grants.py(484 lines):enqueue_topup(95 to 184),enqueue_reversal(187 to 233),_payload(245 to 264),deliver(267 to 353),_fail(356 to 376),drain_once(384 to 428),deliver_for_order(431 to 460),stuck_grants(463 to 484).backend/services/commerce/sparks_grants.py(525 lines):period_key(85 to 89),enqueue_allowance(97 to 156),enqueue_team_grantandMAX_TEAM_GRANT_SPARKS(163 to 250),enqueue_welcome(253 to 292),_payload(304 to 348),deliver(351 to 420),_fail(423 to 434),drain_once(442 to 473),deliver_for_grant(476 to 496),stuck_grants(509 to 525).backend/services/commerce/prima_credit_sig.py(84 lines):canonical_string(49 to 52),sign(55 to 60),signed_headers(63 to 84).backend/models/prima_credit_grant.py(142 lines);backend/models/sparks_billing.py(SparksGrant45 to 102,SparksLowBalanceAlert105 to 125 - the low-balance table is out of scope for this recipe, see When to use).backend/services/commerce/fulfillment_service.py: thecredits-kind branch offulfill_order(298 to 341) and ofreverse_fulfillment(393 to 401).backend/api/routers/commerce_router.py: thepayrexx_webhookhandler's fulfilment call (1697), commit (1706), and inline delivery (1708 to 1735).backend/api/routers/webhooks_router.py:handle_sparks_payment_confirmed(739 to roughly 874), theenqueue_allowancecall (847) and inlinedeliver_for_grant(866).backend/services/primaapi/identity.py: the welcome-grant enqueue (377) and inline delivery (429), in the self-serve signup path.backend/scripts/grant_team_sparks.py: the operator CLI,enqueue_team_grant(133) anddeliver_for_grant(150).backend/services/scheduled_jobs.py:run_prima_credit_grant_drain(684 to 752, including the threshold-sync second pass which is a different, related outbox out of this recipe's scope) andrun_sparks_grant_drain(812 onward); the scheduler registrations at 322 to 345 and 361 to 378.backend/core/config.py: the four shared settings (749 to 767) andvalidate_prima_seam_secrets_are_distinct(782 to 839).backend/core/sparks_config.py:SparksTier,get_tier,MICRO_PER_SPARK = 1_000(line 50),WELCOME_SPARKS = 5_000(103),WELCOME_GRANT_MICRO(104), the four tier configs (83 to 95).backend/migrations/versions/20260803_0003_prima_credit_grants.py,20260808_0001_sparks_billing.py,20260824_0001_sparks_team_grants.py.- Tests as listed under Tests.
Deviations of the reference from an idealised version of this recipe, all dated 2026-09-12: the Sparks outbox's payload contract against Prima was unverified in this pass (Gotchas); the Sparks drain job was missing for a real stretch of this branch's history and only recently added; both outboxes share one credential and one config surface with no per-obligation-kind credential; the delivery/backoff/fail logic is duplicated across two files rather than shared; no admin surface exists for either table at this commit.
Acceptance checks
Commands to actually run these tests were not executed in this pass (Tests section states why); a verifier runs them fresh. Expected observations below are what the code and its own test names assert, not a live run's output.
- From
alchemy_labs/backendat af7a352, with dependencies installed:python -m pytest tests/prima_credits/test_credit_grant_client.py tests/sparks/test_sparks_grant_client.py tests/sparks/test_sparks_team_grant.py -q --no-cov -o addopts=""should collect and pass 17 + 14 + 7 = 38 tests with no database configured (all three files are hermetic). - With
PRIMA_CREDITS_TEST_DATABASE_URLandSPARKS_TEST_DATABASE_URLpointed at a migrated scratch Postgres (alembic upgrade head):python -m pytest tests/prima_credits/test_credit_topup_fulfilment.py tests/sparks/test_sparks_grants_db.py -q --no-cov -o addopts=""should collect and pass 15 + 12 = 27 tests. grep -n "_BACKOFF_SECONDS = " services/commerce/prima_credit_grants.py services/commerce/sparks_grants.pyprints the same tuple(5, 15, 60, 300, 900, 3600)in both files.grep -n "PRIMA_CREDIT_GRANT_SECRET" services/commerce/sparks_grants.py services/commerce/prima_credit_grants.py | wc -lis greater than zero in both, confirming the shared-secret design named in Gotchas.grep -n "sparks_grant_drain\|prima_credit_grant_drain" services/scheduled_jobs.pyshows both job ids registered withIntervalTrigger(minutes=1)a few lines below each match.SELECT conname FROM pg_constraint WHERE conrelid = 'sparks_grants'::regclass AND conname LIKE '%kind_valid%'on a database at head shows the constraint accepting'team'(the migration20260824_0001_sparks_team_grants.pyhas been applied); attemptingINSERT INTO sparks_grants (..., kind) VALUES (..., 'bogus')fails the CHECK.- On the scratch database, inserting two rows into
prima_credit_grantswith the same(order_id, kind)through anything other thanenqueue_topup's own collision handling raisesIntegrityErroronuq_prima_credit_grants_order_kind; the equivalent forsparks_grantson a repeatedprovider_event_idraises onuq_sparks_grants_provider_event.
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.