Module kit / Commerce and payments
Payrexx webhook and order settlement
commerce-payments/payrexx-order-settlement
| Kind | backend |
|---|---|
| Domain | commerce-payments |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118.2, sqlalchemy-2.0.43-sync, postgres-15, requests-2.32.5, pydantic-2.12.0, alembic-1.16.5 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping |
| Frontends | commerce-payments/ui/storefront-preview |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
An order sits in pending the moment a customer opens a Payrexx checkout; this seam is what turns that row into paid (or refuses to, or turns a paid one into refunded) without ever trusting the HTTP POST that claims it happened. The invariant that makes it trustworthy: the webhook body is read only far enough to find WHICH order is being talked about, then the transaction is re-fetched straight from the workspace's own zahls account with that workspace's own API credential, and every money decision (status, amount, currency) is made from that re-fetched copy, never from the unauthenticated POST (commerce_router.py 1395 to 1397, 1528 to 1533).
When to use
Read it before touching payrexx_webhook, _parse_webhook_payload, _tokenizing_parent_of, refund_order, or the status_map/allowed_transitions tables; before changing what counts as a "paid" Payrexx status; and when someone asks "why didn't this order settle", "can the webhook be spoofed", "why is this order stuck pending after the customer paid", or "what happens if a webhook is delivered twice". Trigger phrases: "Payrexx webhook", "zahls webhook", "referenceId lookup", "authoritative re-fetch", "amount mismatch", "paid_oversold", "order state machine", "merchant refund".
Not this recipe: the transaction-id-first lookup branch and the _tokenizing_parent_of pre-auth/token-charge disambiguation, which exist ONLY for extra_metadata.kind == "auto_topup" orders (commerce_router.py 1444 to 1520) - a single narrow carve-out for Prima's credit top-up flow - and the card-token banking / save_card path off this same webhook; both are billing-credits/auto-topup-card-banking-and-refunds's subject, cited here only where the general referenceId-first path has to route around them. Also not this recipe: POST /commerce/checkout and create_checkout (gateway creation, capacity guard at checkout time - services/commerce/checkout_service.py), the workshop seat-capacity backstop's own capacity math (services/commerce/capacity.py, only its call site inside this webhook is covered here), fulfill_order/reverse_fulfillment's own fan-out logic (services/commerce/fulfillment_service.py, called but not detailed here), and the Prima credit-grant delivery this webhook fires after commit (services/commerce/prima_credit_grants.deliver_for_order, commerce_router.py 1708 to 1735, a separate outbox).
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), requests==2.32.5 for the outbound Payrexx/zahls calls in payrexx_service.py (not httpx, despite httpx==0.28.1 being pinned elsewhere in this backend for other seams), pydantic==2.12.0, psycopg2-binary==2.9.10, alembic==1.16.5, Postgres 15 (docker-compose.prod.yml). Foundations assumed: S4 (fastapi-project-layout) for mount_router/app_fastapi.py; S5 (auth-dependency-pattern) for VerifiedUser on the admin routes (get_order, refund_order); S6 (workspace-scoping) for WorkspaceID and IsolationService.verify_workspace_access (services/workspace/isolation.py 109 to 136), which refund_order calls directly for its member-role check.
Environment, all in core/config.py 595 to 598: PAYREXX_INSTANCE_NAME (central fallback instance), PAYREXX_API_SECRET (SecretStr, central fallback), PAYREXX_TEST_MODE (bool, informational only - never changes which Payrexx operation runs, payrexx_service.py 167 to 174), PAYREXX_WEBHOOK_SECRET (SecretStr, optional). Per-workspace override: PayrexxService.for_workspace(db, workspace_id) (payrexx_service.py 94 to 116) reads services.payrexx_config.get_payrexx_config and returns a service bound to the workspace's own instance_name/api_secret when configured, else the central payrexx_service singleton. Fail behaviour: _make_request raises HTTPException(503, ...) if api_secret or instance_name is unset (payrexx_service.py 49 to 53) before any HTTP call is attempted; a failed re-fetch inside the webhook is answered with 502 so Payrexx/zahls retries rather than the handler guessing a status (commerce_router.py 1542 to 1546).
Data model
orders (models/commerce_models.py 110 to 157), the row this whole recipe exists to settle:
orders (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
product_id uuid NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
customer_email varchar(255) NOT NULL,
amount_cents integer NOT NULL,
currency varchar(10) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
provider varchar(50) NOT NULL DEFAULT 'payrexx',
payrexx_gateway_id varchar(255), -- indexed, not unique
payrexx_transaction_id varchar(255), -- indexed, NOT unique (see Gotchas)
payrexx_subscription_id varchar(255),
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
paid_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
CHECK ck_orders_status: status IN ('pending','paid','failed','refunded','cancelled','expired','paid_oversold')
payrexx_transaction_id is deliberately indexed but not unique (migration prima_auto_topup_marker_001's comment, mirrored at commerce_models.py 137 to 143): a Payrexx transaction id is unique per zahls instance, and Labs runs one instance per workspace (PayrexxService.for_workspace), so two different workspaces can legitimately hold the same small integer. The webhook's own lookup logic is what scopes around that collision (Application logic, step 2).
What is NOT stored anywhere in this recipe's write path: a PSP response body on any column read here (only id, status, referenceId, amount, currency are read off the re-fetched transaction); the raw webhook POST body is parsed and then discarded, never persisted.
API surface
| Route | Auth | Tenancy | Body | Codes | Notes |
|---|---|---|---|---|---|
POST /api/orders/webhook/payrexx (commerce_router.py 1390 to 1398) | none - public, unauthenticated POST; an optional X-Webhook-Signature/X-Payrexx-Signature header is checked IF present and PAYREXX_WEBHOOK_SECRET is set (1416 to 1424), but its absence is not refused | none in the request; the order (and so the workspace) is resolved from the body's referenceId/transaction id, then re-verified against the workspace's own zahls account | form-urlencoded (PHP-bracket keys, transaction[id], transaction[invoice][referenceId]) or JSON, parsed by _parse_webhook_payload into one nested dict either way (1331 to 1357) | 200 always on a reachable, parseable request ({"ok": true, "matched": bool, ...}, never a failure shape for "we chose not to act"); 400 when the re-fetched transaction cannot be tied to the resolved order (missing tx id, id mismatch, referenceId collision, 1537, 1559, 1586, 1592); 401 when a presented signature is wrong (1424); 502 when the workspace's zahls account cannot be reached to verify (1546) | Never trusts the POST body for money; see Application logic for the full decision chain |
POST /api/orders/{order_id}/refund (commerce_router.py 1266 to 1326) | VerifiedUser + WorkspaceID, plus an explicit IsolationService.verify_workspace_access(user.id, workspace_id, required_role="member") re-check inside the handler (1298 to 1302) | Order row locked FOR UPDATE, filtered on workspace_id == WorkspaceID (1287 to 1292) | none (path param only) | 200 OrderResponse (now status="refunded"); 404 order not found or cross-workspace; 403 caller's workspace role is below member; 409 order not in ("paid","paid_oversold") or has no payrexx_transaction_id/provider != "payrexx"; 502 Payrexx rejects the refund | Merchant-initiated: the zahls refund call happens BEFORE the local status flip, and only a provider success (result["ok"]) is allowed to flip the row - a rejected refund leaves the order untouched (1317 to 1319) |
Application logic
payrexx_webhook, in commit order (commerce_router.py 1390 to 1738):
- Optional signature check (1416 to 1424): only enforced when a signature header IS present AND
PAYREXX_WEBHOOK_SECRETis configured; a wrong signature under those conditions is a 401 refusal. An absent header, or a header with no configuredPAYREXX_WEBHOOK_SECRET, falls through - this is deliberate, not a gap: the re-fetch in step 3 is the actual proof of authenticity (module comment, 1401 to 1415). - Locate the order, referenceId-first for every order EXCEPT
auto_topup-kind ones (1436 to 1522).reference_idis read fromtransaction.referenceIdortransaction.invoice.referenceIdand resolved asOrder.id(a UUID Labs minted at checkout); a transaction-id-first branch exists only whenOrder.extra_metadata["kind"] == "auto_topup"(out of this recipe's scope, see When to use); when neither anauto_topuptx-id match nor a referenceId match is found, the code falls back toOrder.payrexx_gateway_id == gateway_id(1521 to 1522). No match at all ->{"ok": true, "matched": false}, 200, nothing written (1524 to 1526). - Authoritative re-fetch (1528 to 1546):
PayrexxService.for_workspace(db, order.workspace_id).get_transaction(tx_id)using the WORKSPACE'S OWN credentials - this is what makes the endpoint safe to leave unauthenticated. A missingtransaction.idin the payload is a 400 before any fetch is attempted (1534 to 1537); a fetch that raises (network, config) is a 502, never a silent skip (1542 to 1546). - Re-confirm the match on the authenticated copy (1547 to 1592): a referenceId-matched order requires the re-fetched transaction's own
referenceIdto still equalorder.id(1589 to 1592) - this is the step that actually defeats a forged POST, since a forger cannot make the re-fetched copy say anything but the truth.transaction = fresh(1594): from here on nothing reads the original POST body again. - Map status (1596 to 1613):
confirmed/completed->paid;cancelled->cancelled;declined/expired->failed;refunded/chargeback->refunded.authorized(a pre-auth/reservation) maps to nothing and is explicitly commented as never-fulfill (1600 to 1602). An unmapped status returns{"ok": true, "matched": true, "status_unchanged": true}and touches nothing (1611 to 1613). - Re-lock the order row
FOR UPDATEwithpopulate_existing()inside this request's own transaction (1618 to 1624), so the state-machine check below sees fresh state against a concurrent webhook delivery, not the identity-map copy loaded in step 2. - State-machine guard (1628 to 1647):
allowed_transitionsis a fixed table -pending -> {paid, failed, cancelled, expired},paid -> {refunded},paid_oversold -> {refunded},failed -> {paid}(a retried, later-successful charge),expired -> {paid}(a late confirmation),cancelled/refunded->{}(terminal). The same target status as current is a no-op 200 (status_unchanged, 1640 to 1642, this is what makes double delivery idempotent); a transition not in the allowed set is logged and answered the same no-op shape rather than raising (1643 to 1647) - a late/out-of-order webhook cannot demote a paid order back to pending or resurrect a refunded one. - Fail-closed amount + currency check,
paidtransitions only (1651 to 1663):tx_amount is None or int(tx_amount) != order.amount_centsOR (tx_currencypresent and it disagrees withorder.currency, case-insensitive) blocks the transition - the handler returns{"ok": true, "matched": true, "amount_mismatch": true}and the order is left exactly as it was, no fulfillment, no status write. A missing amount is treated the same as a wrong one (fail closed, never "trust it because it's absent"). - Oversold backstop,
paidtransitions only (1665 to 1685): if the product carries a capacity and confirming this order would exceed it, the status is downgraded frompaidtopaid_oversold(money captured, but not sold-out safe) rather than blocked - capacity math itself belongs toservices/commerce/capacity.py, out of this recipe's scope. - Write + fan out (1687 to 1706):
order.status = new_status; onpaid/paid_oversold,paid_atand the confirmedpayrexx_transaction_id/payrexx_subscription_idare stamped; onpaidspecifically,fulfill_order(db, order)runs in the SAME transaction (idempotent - see Tests) and a best-effort server-side Meta Purchase CAPI fire follows; onrefunded/cancelled,reverse_fulfillment(db, order)runs. Onedb.commit()(1706). - Post-commit, best-effort Prima credit delivery (1708 to 1735): only for
kind == "credits"products, only after the commit, wrapped in its own try/except that can never raise back into the response - the obligation was already made durable inside step 10's transaction, this call only turns "appears within a minute" into "appears now" (comment, 1710 to 1718).
refund_order (commerce_router.py 1266 to 1326), merchant-initiated, in commit order:
- Lock the order
FOR UPDATE, workspace-scoped (1287 to 1292); 404 if absent or cross-workspace. - Explicit role check via
IsolationService.verify_workspace_access(..., required_role="member")(1298 to 1302) - theWorkspaceIDdependency alone admits a viewer, so this second check is what actually blocks a viewer from moving money. - Guard:
order.statusmust bepaidorpaid_oversold(1304 to 1308, 409 otherwise);order.provider == "payrexx"andorder.payrexx_transaction_idmust both be set (1309 to 1313, 409 otherwise, "no captured transaction to refund"). - Call
PayrexxService.for_workspace(db, order.workspace_id).refund_transaction(order.payrexx_transaction_id, order.amount_cents)(1315 to 1316) - a realPOST /Transaction/{id}/refund/against zahls. - Only on provider success (
result["ok"]) does the local write happen:order.status = "refunded",reverse_fulfillment(db, order), one commit (1317 to 1324). A provider rejection raises 502 and the order is left completely untouched - nothing local is speculatively flipped ahead of the provider's answer.
Invariants
- The webhook's POST body is never the source of truth for money: every
paid/refunded/cancelledwrite is made from the transaction re-fetched with the workspace's own zahls credentials, not from the parsed request (commerce_router.py1528 to 1594, module docstring 1395 to 1397). - A late or out-of-order webhook can never move an order backward: the
allowed_transitionstable has no edge out ofcancelledorrefunded, andpaid's only edge is torefunded(1630 to 1638). - A
paidtransition without a verifiable amount+currency match is refused, not assumed: a missingtx_amountfails the same way a wrong one does (1656 to 1658). - Double delivery of the same webhook is a no-op, not a duplicate side effect: the same-status short-circuit at 1640 to 1642 combined with
fulfill_order's own idempotency is whattest_webhook_idempotent_double_deliveryasserts end to end (exactly oneDownloadGrant, exactly one email across two deliveries). - A merchant refund never flips the local row ahead of the provider:
refund_transactionis called first, and onlyresult["ok"] is Truereaches theorder.status = "refunded"write (1317 to 1319); a provider-side rejection leaves the order exactly as it was, stillpaid.
Frontend
The module that would render this, by id:
commerce-payments/ui/storefront-preview the customer-facing surface whose checkout
ends in the order this webhook settles
(the buy page reads the order it created
at /commerce/checkout and polls/redirects
once this webhook flips it to paid); at
62ba4fc nothing in this repo's frontend
calls payrexx_webhook or refund_order
directly, they are reached only by
Payrexx/zahls itself and by the merchant's
Orders admin screen respectively
Minimum admin and debug surface: GET /api/orders/GET /api/orders/{id} (commerce_router.py 1227 to 1263, workspace-scoped, the merchant's own view of status/payrexx_transaction_id/paid_at); the log lines Payrexx webhook: no matching order for transaction %s ref %s (1525), Payrexx webhook: amount/currency mismatch or missing for order %s (tx=%s %s vs order=%s %s) (1659 to 1661), Payrexx webhook: rejecting transition %s -> %s for order %s (1644 to 1645), and commerce: order %s refunded by user %s (1325) on a successful merchant refund.
Security & tenancy
Auth dependency: payrexx_webhook has none - it is mounted with no VerifiedUser/WorkspaceID dependency at all, by design (module docstring, "Public endpoint", 1397); refund_order requires VerifiedUser + WorkspaceID PLUS the explicit IsolationService.verify_workspace_access(..., required_role="member") re-check (1298 to 1302), because the WorkspaceID dependency alone does not enforce a role floor. Workspace derivation on the webhook: never from a header or session, only from the Order row the referenceId/transaction-id lookup resolves, and every subsequent zahls call is made with THAT order's workspace_id's own credentials (PayrexxService.for_workspace), never the platform-central credential, so a wrong-workspace lookup would verify against the wrong account and fail the referenceId re-check rather than silently succeed.
Fail direction: the re-fetch is fail-closed on reachability (502 rather than trusting the POST when zahls cannot be reached, 1542 to 1546); the match is fail-closed on the authenticated copy (400 on any referenceId disagreement post-refetch, 1589 to 1592); the amount/currency check is fail-closed on absence (a missing tx_amount blocks exactly like a wrong one, 1656 to 1658); the state machine is fail-closed on an unknown edge (an unrecognized transition is a no-op, not an attempted write, 1643 to 1647); the optional signature check is fail-closed only when BOTH a signature is presented AND PAYREXX_WEBHOOK_SECRET is configured (1420), which is a narrower guarantee than "always checked" - see Gotchas.
Secrets at rest: PAYREXX_API_SECRET/PAYREXX_WEBHOOK_SECRET are SecretStr in core/config.py; per-workspace secrets are read through get_payrexx_config(..., with_secrets=True), not inlined in this router. Attacker-controlled inputs on the webhook, all of them, because the endpoint is unauthenticated: transaction.id, transaction.referenceId/invoice.referenceId, transaction.status, transaction.amount, transaction.currency, transaction.preAuthorizationId, invoice.paymentRequestId - none of these are trusted for a money decision after step 3 of Application logic; only the re-fetched copy is. Rate limits: none dedicated to this route at this commit (not found in commerce_router.py or app_fastapi.py's mount call for it); the endpoint's own defense against abuse is that no attacker input can move money without also passing the workspace's own zahls account's re-fetch.
Tests
Run against the transactional db_session/client fixtures in backend/tests/test_commerce_phase1.py (831 lines), using mock_email, stub_storage, and the autouse _stub_webhook_refetch fixture, which stands in for a live zahls account by returning whatever _payrexx_paid_payload registered in _TX_REGISTRY keyed by transaction id (98 to 126) - the harness never calls a real zahls endpoint, PayrexxService.get_transaction is monkeypatched directly (124 to 125).
Named tests covering this recipe: test_webhook_idempotent_double_delivery (453 to 477, two identical POSTs -> order paid, exactly one DownloadGrant, exactly one email); test_webhook_grant_mint_and_email (483 to 500, single valid webhook fulfils once); test_webhook_amount_mismatch_blocks_fulfilment (506 to 526, amount=1 against a 5000-cent order leaves the order pending, mints no grant, sends no email); test_refund_webhook_reverses_fulfilment (664 to 691, a paid order then a refunded-status webhook zeroes the download grant's downloads_remaining); test_webhook_over_capacity_marks_paid_oversold (809 to 831, a second confirmed order over a capacity-1 product lands paid_oversold, never paid).
Known-answer vectors: _payrexx_paid_payload (100 to 111) builds {"id": 999001, "status": <status_str>, "referenceId": str(order.id), "amount": <order.amount_cents unless overridden>, "currency": "CHF"}, registered so the stubbed re-fetch echoes it back.
Gap, stated plainly: test_commerce_phase1.py (the test file this recipe was measured against) has no test that drives POST /orders/{order_id}/refund itself - its only refund coverage is test_refund_webhook_reverses_fulfilment, which exercises the webhook's refunded-status branch, a different code path than refund_order. Tests for the merchant refund endpoint exist elsewhere in this backend (grep finds orders/{order.id}/refund calls in backend/tests/test_commerce_subscriptions.py and backend/tests/prima_credits/test_auto_topup_webhook_and_refund.py) but those files were not in this recipe's designated source set and are not cited as evidence here.
Deploy & ops
Config keys: PAYREXX_INSTANCE_NAME, PAYREXX_API_SECRET, PAYREXX_TEST_MODE, PAYREXX_WEBHOOK_SECRET (core/config.py 595 to 598), plus per-workspace overrides resolved through services/payrexx_config.get_payrexx_config (not itself in this recipe's reference set). Migrations touching the tables this recipe reads/writes: 20260426_add_commerce_module.py (creates orders/products), 20260609_0001_commerce_kind_infoproducts.py (revision commerce_kind_infoproducts_001, down-revision review_original_snapshot_001, pin asserted by test_migration_module_revision_pins), 20260610_0004_commerce_subscriptions.py. No dedicated migration governs payrexx_transaction_id's non-unique index for THIS recipe's own concern (the non-uniqueness is inherited from the prima_auto_topup_marker_001 migration cited in the model comment, which is billing-credits's territory).
Router mount: mount_router(app, commerce_router.router, prefix="/api", tags=["Commerce"], surfaces=LABS_SURFACES) (app_fastapi.py 838), inside docker-compose.prod.yml's backend service - no feature flag or surface_exempt marker gates payrexx_webhook or refund_order specifically; the webhook route's unauthenticated status comes from having no VerifiedUser/WorkspaceID parameter, not from a surface exemption.
Argus-style checks: POST /api/orders/webhook/payrexx with an unresolvable referenceId and no matching gateway returns 200 {"ok": true, "matched": false} (proves the route is reachable without needing a real order); a workspace whose PAYREXX_API_SECRET/PAYREXX_INSTANCE_NAME is unset answers 503 on any Payrexx call via _make_request (payrexx_service.py 49 to 53), which the webhook's own re-fetch step turns into the generic 502 "could not verify" response rather than a 503 leaking outward. Rollback: no destructive migration in this path; an order row is never deleted by this recipe.
Gotchas
- 2026-09-12 (measured at 62ba4fc,
commerce_router.py1401 to 1424): the webhook's signature check is conditional, not mandatory - a POST with NO signature header and a workspace withPAYREXX_WEBHOOK_SECRETunset passes straight to the re-fetch step with zero cryptographic proof of origin. This is a documented design choice (the re-fetch is called "strictly stronger" in the router's own comment) rather than an oversight, but a rebuild that assumes "signature verified" reads the wrong guarantee - the actual proof is the authoritated re-fetch, and it applies to every request, signed or not. - 2026-09-12 (measured at 62ba4fc,
commerce_models.py137 to 143):orders.payrexx_transaction_idcarries a comment creditingADR-0036 §5and "W1-fix" work, both of which belong to theauto_topupcarve-out this recipe explicitly excludes (see When to use) - a reader following that comment intogit log -S"TRANSACTION ID FIRST"lands on commits7e5479e(2026-09-10) and8b58f40(2026-09-11), both authored for the credit top-up seam, not for the general order-settlement path documented here. The non-unique index this recipe's Data model section cites is a side effect of that other work, not something this recipe's own logic requires on its own. - 2026-09-12 (measured at 62ba4fc): no dedicated test in
test_commerce_phase1.pydrivesPOST /orders/{order_id}/refund- see Tests. A rebuild that treats this file's suite as covering the merchant refund path is wrong; it only covers the webhook'srefunded-status transition. - 2026-09-12 (measured at 62ba4fc):
PayrexxService._make_requestusesrequests(synchronous, blocking) insideasync def payrexx_webhook, off no threadpool -get_transaction's call in step 3 of Application logic blocks the event loop for up to the(5, 30)-second timeout (payrexx_service.py67). Whether this has caused observed latency under concurrent webhook load is not determinable from the two files this recipe was measured against; no load test or profiling artifact was found in the cited test file.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/api/routers/commerce_router.py(1738 lines):_parse_webhook_payload(1331 to 1357);_tokenizing_parent_of(1360 to 1387, cited for context, owned bybilling-credits);payrexx_webhook(1390 to 1738);refund_order(1266 to 1326);get_order/list_orders(1227 to 1263, admin read surface).alchemy_labs/backend/services/payrexx_service.py(527 lines):PayrexxService.__init__(18 to 46);_make_request(47 to 92);for_workspace(94 to 116);get_transaction(310 to 322);refund_transaction(324 to 344);verify_webhook_signature(498 to 524, HMAC-SHA256,hmac.compare_digest); the central singletonpayrexx_service(527).alchemy_labs/backend/models/commerce_models.py(270 lines):Product(20 to 107, cited forkind/capacity context only);Order(110 to 157, this recipe's own table).alchemy_labs/backend/app_fastapi.py: the router mount (838).alchemy_labs/backend/core/config.py: the fourPAYREXX_*settings (595 to 598).alchemy_labs/backend/services/workspace/isolation.py:verify_workspace_access(109 to 136), the role checkrefund_ordercalls.- Tests:
alchemy_labs/backend/tests/test_commerce_phase1.py(831 lines, whole file in scope as the designated source; see Tests for the named cases and the stated refund-endpoint gap).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: the webhook's signature check is conditional rather than mandatory (Gotchas); the merchant refund endpoint has no test in the designated source file; the transaction re-fetch is a blocking call inside an async def handler with no measured evidence either way on its production impact.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc with the project's own transactional test database (per backend/tests/conftest.py, not itself in this recipe's reference set).
python -m pytest tests/test_commerce_phase1.py -q -k "webhook or refund"runstest_webhook_idempotent_double_delivery,test_webhook_grant_mint_and_email,test_webhook_amount_mismatch_blocks_fulfilment,test_refund_webhook_reverses_fulfilment,test_webhook_over_capacity_marks_paid_oversoldand prints all passing (exact count not independently re-run as part of authoring this recipe; treat this as the command to run, not a pre-verified pass count).grep -n "ck_orders_status" backend/models/commerce_models.pyshows the seven-value CHECK constraint list at line 153, matching thestatus_map/allowed_transitionsvalues used incommerce_router.py.grep -n "^def payrexx_webhook\|^async def refund_order" backend/api/routers/commerce_router.pyprints lines1391(asasync def payrexx_webhook) and1267(asasync def refund_order).grep -c "^ def \|^ async def " backend/services/payrexx_service.pyconfirms the method count onPayrexxService;grep -n "^def get_transaction\|^def refund_transaction" backend/services/payrexx_service.pyshould print no top-level match (both are instance methods, indented), confirming they are not free functions a naive rebuild might expect.
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.