Prima Get an API key

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
  • alchemy_labs/backend/services/commerce/prima_credit_grants.py
  • alchemy_labs/backend/services/commerce/sparks_grants.py
  • alchemy_labs/backend/services/commerce/prima_credit_sig.py
  • alchemy_labs/backend/models/prima_credit_grant.py
  • alchemy_labs/backend/models/sparks_billing.py
  • alchemy_labs/backend/services/commerce/fulfillment_service.py
  • alchemy_labs/backend/api/routers/commerce_router.py
  • alchemy_labs/backend/api/routers/webhooks_router.py
  • alchemy_labs/backend/services/scheduled_jobs.py
  • alchemy_labs/backend/migrations/versions/20260803_0003_prima_credit_grants.py
  • alchemy_labs/backend/migrations/versions/20260808_0001_sparks_billing.py
  • alchemy_labs/backend/migrations/versions/20260824_0001_sparks_team_grants.py
  • alchemy_labs/backend/tests/prima_credits/test_credit_grant_client.py
  • alchemy_labs/backend/tests/prima_credits/test_credit_topup_fulfilment.py
  • alchemy_labs/backend/tests/sparks/test_sparks_grant_client.py
  • alchemy_labs/backend/tests/sparks/test_sparks_team_grant.py
  • alchemy_labs/backend/tests/sparks/test_sparks_grants_db.py
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):

StatusMeaning hereRow outcome
200Granted, or {"duplicate": true} - both are terminal successstatus='granted', granted_at=now(), next_attempt_at=NULL, last_error=NULL
4xx except 408, 429Our own bug (bad body, bad signature, an amount out of range) - not retryablestatus='stuck' immediately, last_error='HTTP <n>', next_attempt_at=NULL
408, 429, 5xxTransient_fail(): backoff, or stuck once attempts >= PRIMA_CREDIT_GRANT_MAX_ATTEMPTS
transport exceptionTimeout, connection refused, etc._fail() with last_error=type(exc).__name__, same backoff/stuck rule
PRIMA_INTERNAL_BASE_URL or the credential unsetNot 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.py lines 251 to 264): {"tenant_id", "amount_cents", "currency", "provider", "provider_event_id", "order_id"} plus "bonus_credit_micro" when the product metadata set one. Never amount_micro.
  • CHF reversal (same function, lines 246 to 250): {"tenant_id", "order_id"} only - no amount.
  • Sparks welcome / team (sparks_grants.py lines 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):

FunctionCommitsRaisesReturns
enqueue_topup(db, order, product)noyes - no/malformed/unknown tenant, non-positive amountthe row, or the existing row on a (order_id, kind) collision
enqueue_reversal(db, order)nonothe 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)noon 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)nonothe row, or the existing row if one already exists for that tenant
enqueue_team_grant(db, *, tenant_id, sparks, reference)noValueError (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)neverTrue if the row reached granted
drain_once(db, limit=50)yes, per rownever{"due": n, "delivered": m}
deliver_for_order(db, order_id, *, kind="topup")yes, oncenever past its own logTrue/False
deliver_for_grant(db, grant_id)yes, oncenever past its own logTrue/False
stuck_grants(db, *, older_than_minutes=15)nonorows with status='stuck', plus pending older than the cutoff

Application logic

For a paid CHF top-up, in commit order:

  1. The Payrexx webhook (commerce_router.py::payrexx_webhook) authoritatively re-verifies the transaction and moves the order pending → paid inside its own transaction (a different recipe's concern). On paid, it calls fulfill_order(db, order) (line 1697), still inside that transaction, before the single db.commit() at line 1706.
  2. fulfill_order routes on product.kind == "credits" (fulfillment_service.py line 298) and calls enqueue_topup(db, order, product) (line 322). enqueue_topup reads order.extra_metadata["credit_tenant_id"] - never order.workspace_id, which is the selling merchant, not the buyer - parses it as a UUID, checks a workspaces row exists for it, checks order.amount_cents > 0, picks provider_event_id = order.payrexx_transaction_id or order.payrexx_gateway_id, reads an optional integer bonus from product.extra_metadata["credit_micro"] (a non-integer value is logged and dropped, not sent), and builds the row with status="pending", attempts=0, next_attempt_at=now() (prima_credit_grants.py lines 95 to 184). It does not call httpx and does not commit.
  3. 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.py lines 316 to 320).
  4. If the INSERT collides on uq_prima_credit_grants_order_kind (a redelivered webhook that got here twice), enqueue_topup rolls 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.
  5. db.commit() (commerce_router.py line 1706, the same commit that settles the order). After this, "the buyer is owed credit" is exactly as durable as "the buyer paid".
  6. INLINE, after that commit, on the same session: if the product kind was credits, deliver_for_order(db, order.id, kind="topup") runs through asyncio.to_thread (commerce_router.py lines 1719 to 1729, because httpx.post blocks and the endpoint is async). It locks the pending row FOR UPDATE SKIP LOCKED, calls deliver, and commits itself - wrapped in a try/except that only logs, because the row is already durable and a failure here costs a minute, not a customer (lines 1730 to 1735).
  7. A refund or chargeback (new_status in ("refunded", "cancelled")) calls reverse_fulfillment, which calls enqueue_reversal(db, order) (fulfillment_service.py line 401) if a topup grant 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 calls deliver_for_order(db, order.id, kind="reversal").
  8. DRAIN, every minute, from the leader-only scheduler (services/scheduled_jobs.py::run_prima_credit_grant_drain, registered at lines 337 to 345 with IntervalTrigger(minutes=1), coalesce=True, max_instances=1): selects up to 50 pending rows whose next_attempt_at is NULL or 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 to pending).

For a Sparks grant, the same three-verb shape with a different cause per kind:

  1. Allowance: a Payrexx webhook for a Sparks-tier subscription (first charge or a monthly renewal) resolves the Subscription row, sets its period, db.flush()es, then calls sparks_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(), then sparks_grants.deliver_for_grant(db, grant.id) inside its own try/except that only logs (lines 860 to 872) - the same commit-then-deliver-after shape as the CHF path, for the same reason.
  2. Welcome: services/primaapi/identity.py calls sparks_grants.enqueue_welcome(db, tenant_id=ws_id) at account creation (line 377) and deliver_for_grant after the commit (line 429), inside a block the surrounding code documents as never allowed to fail the signup.
  3. Team: an operator runs scripts/grant_team_sparks.py, which validates the sparks amount against MAX_TEAM_GRANT_SPARKS before calling the service (so the CLI's own error message names the ceiling rather than surfacing a raw overflow), calls enqueue_team_grant(db, tenant_id=..., sparks=..., reference=...), commits, then deliver_for_grant.
  4. DRAIN: services/scheduled_jobs.py::run_sparks_grant_drain, registered at lines 370 to 378, same IntervalTrigger(minutes=1) shape. This job's docstring (lines 812 to 831) states plainly that until it was added, sparks_grants.drain_once existed and nothing scheduled ever called it, so a failed inline delivery for a welcome, allowance or team grant stayed pending forever with no retry (Gotchas).
  5. _fail(row, exception_class, status), identical in both modules: last_error is the exception class name or "HTTP <n>", never a response body; if attempts >= PRIMA_CREDIT_GRANT_MAX_ATTEMPTS the row becomes stuck with next_attempt_at=NULL; otherwise status='pending' and next_attempt_at = now() + backoff(attempts), where _BACKOFF_SECONDS = (5, 15, 60, 300, 900, 3600) is the same literal tuple in both files, indexed by attempts - 1 and 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.py line 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.py line 187; test_every_attempt_carries_the_same_deterministic_event_id, test_sparks_grant_client.py line 217).
  • A redelivered webhook produces exactly one row, never a second: enqueue_topup collides on uq_prima_credit_grants_order_kind and returns the existing row (test_a_replayed_webhook_delivery_queues_one_grant); enqueue_allowance and enqueue_welcome collide on uq_sparks_grants_provider_event the 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_topup with no or an unknown credit_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_error is 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 team grant rides the purchase bucket, never allowance: allowance RESETS 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 welcome grant is exactly once per tenant, ever, by construction of its deterministic event id (sparks-welcome-{tenant_id}, tenant-only, no reference); a team grant 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_cents is the only unit on the wire for CHF; amount_micro is 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 of amount_cents/kind, since Prima's two-bucket contract refuses all three, per test_allowance_payload_uses_the_allowance_bucket_and_expiry's docstring).
  • Labs holds no ledger anywhere on this path: test_labs_has_no_credit_ledger_table asserts to_regclass('prima_credit_accounts') and to_regclass('prima_credit_entries') are both NULL, and test_no_labs_source_file_executes_anything_against_the_prima_ledger parses every .py file'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_path parses the AST of each listed file (including prima_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_retried parametrized on 408/429), the wire shape (test_the_wire_carries_cents_and_never_micro_chf, test_a_reversal_sends_no_amount), and test_the_backoff_grows_and_is_bounded.
  • tests/prima_credits/test_credit_topup_fulfilment.py - 15 tests, live against a scratch Postgres named by PRIMA_CREDITS_TEST_DATABASE_URL (module-level pytestmark skips 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, and test_backoff_schedule_matches_prima_credit_grants (asserts grants._BACKOFF_SECONDS == topup_grants._BACKOFF_SECONDS - one retry policy for the whole seam).
  • tests/sparks/test_sparks_team_grant.py - 7 tests, no database. What enqueue_team_grant refuses (non-positive sparks, a blank or None reference, an amount that would overflow the 32-bit amount_micro column) and the row it builds, including test_the_event_id_is_scoped_to_tenant_and_reference and test_a_team_grant_cannot_collide_with_the_welcome_grant.
  • tests/sparks/test_sparks_grants_db.py - 12 tests, live against SPARKS_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 only prima_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_drain was added to the scheduler, sparks_grants.drain_once existed as a function but nothing scheduled ever called it (services/scheduled_jobs.py lines 812 to 822, the job's own docstring names this explicitly as "the missing" half). A welcome, allowance or team grant whose inline deliver_for_grant failed - Prima down, a timeout, the process dying between commit and the inline call - sat pending indefinitely 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_SECRET is 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.py lines 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_once implementations 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 between prima_credit_grants.py and sparks_grants.py. test_backoff_schedule_matches_prima_credit_grants pins 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_modules in this recipe's frontmatter names billing-credits/ui/subscription-manager as 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 of stuck_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_grant and MAX_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 (SparksGrant 45 to 102, SparksLowBalanceAlert 105 to 125 - the low-balance table is out of scope for this recipe, see When to use).
  • backend/services/commerce/fulfillment_service.py: the credits-kind branch of fulfill_order (298 to 341) and of reverse_fulfillment (393 to 401).
  • backend/api/routers/commerce_router.py: the payrexx_webhook handler'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), the enqueue_allowance call (847) and inline deliver_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) and deliver_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) and run_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) and validate_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.

  1. From alchemy_labs/backend at 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).
  2. With PRIMA_CREDITS_TEST_DATABASE_URL and SPARKS_TEST_DATABASE_URL pointed 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.
  3. grep -n "_BACKOFF_SECONDS = " services/commerce/prima_credit_grants.py services/commerce/sparks_grants.py prints the same tuple (5, 15, 60, 300, 900, 3600) in both files.
  4. grep -n "PRIMA_CREDIT_GRANT_SECRET" services/commerce/sparks_grants.py services/commerce/prima_credit_grants.py | wc -l is greater than zero in both, confirming the shared-secret design named in Gotchas.
  5. grep -n "sparks_grant_drain\|prima_credit_grant_drain" services/scheduled_jobs.py shows both job ids registered with IntervalTrigger(minutes=1) a few lines below each match.
  6. 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 migration 20260824_0001_sparks_team_grants.py has been applied); attempting INSERT INTO sparks_grants (..., kind) VALUES (..., 'bogus') fails the CHECK.
  7. On the scratch database, inserting two rows into prima_credit_grants with the same (order_id, kind) through anything other than enqueue_topup's own collision handling raises IntegrityError on uq_prima_credit_grants_order_kind; the equivalent for sparks_grants on a repeated provider_event_id raises on uq_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.