Prima Get an API key

Module kit / Commerce and payments

Order fulfillment and infoproduct delivery

commerce-payments/order-fulfillment-and-delivery

Kind backend
Domain commerce-payments
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, alembic-1.16, pydantic-2.12
Depends on _shared/fastapi-project-layout, _shared/alembic-migrations, _shared/workspace-scoping, _shared/test-layout
Frontends commerce-payments/ui/storefront-preview
Reference implementation
  • alchemy_labs/backend/services/commerce/fulfillment_service.py
  • alchemy_labs/backend/models/commerce_models.py
  • alchemy_labs/backend/api/routers/commerce_router.py
  • alchemy_labs/backend/services/commerce/checkout_service.py
  • alchemy_labs/backend/services/storage/url_generator.py
  • alchemy_labs/backend/tests/test_commerce_phase1.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

When an order's status flips to paid (the Payrexx webhook's authoritative re-fetch, or a CHF 0 product's checkout short-circuit), the buyer gets exactly one thing per product kind: an infoproduct buyer gets one metered, short-TTL download link; every other Phase-1 kind gets one confirmation email; and when the order is later refunded or cancelled, an infoproduct's remaining downloads are zeroed so an already-issued link stops working. The invariant that makes this trustworthy is that fulfill_order/reverse_fulfillment never commit and never mint twice: they run inside the same transaction the caller already holds, and the one row this recipe mints, DownloadGrant, is idempotent under UNIQUE(order_id, product_id) - a re-delivered webhook re-uses the existing grant instead of minting a second one and sending a second email (module docstring, fulfillment_service.py 1 to 21).

When to use

Read it before touching fulfill_order, reverse_fulfillment, _mint_grant, _build_download_url/build_download_url, or the metered GET /commerce/download/{grant_id} endpoint's atomic UPDATE; when someone asks "why did this buyer get two download emails", "why does the link say limit reached", "does a refund kill the download", or "why didn't the confirmation email go out". Trigger phrases: "download grant", "DownloadGrant", "infoproduct fulfillment", "metered download", "commerce/download", "duplicate grant", "reverse_fulfillment", "fulfill_order", "product/workshop confirmation email".

Not this recipe: the credits branch inside both fulfill_order and reverse_fulfillment (enqueue_topup/enqueue_reversal, prima_credit_grants.py, billing-credits' territory - touched here only because it lives in the same kind-keyed if chain); subscription minting (mint_scheduled_subscription/cancel_subscription, subscription_service.py, ScheduledSubscription); the Spark provisioning hook (services/spark/provisioning.on_order_paid, flag-gated, best-effort, a no-op for every non-Spark order); the unified messaging dispatch for custom on_event confirmations (messaging_service.steps_for/dispatch, a separate recipe - cited here only because it sets has_custom_confirmation, which suppresses this recipe's own generic email); the Payrexx webhook's own order-matching, signature check and state-machine guard upstream of the paid transition (commerce_router.payrexx_webhook, that is the webhook recipe itself - this recipe starts at the two lines that call fulfill_order/reverse_fulfillment); the workshop seat-capacity backstop (services/commerce/capacity.py); the non-CHF0 Payrexx gateway-creation path in checkout_service.create_checkout (a neighbouring checkout recipe - this recipe only covers its CHF 0 short-circuit, the second caller of fulfill_order).

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), alembic==1.16.5, psycopg2-binary==2.9.10, pydantic==2.12.0, Postgres 15 in prod (docker-compose.prod.yml:9, postgres:15-alpine) / 14 in the test harness (docker-compose.test.yml:6,21,115, postgres:14). redis==6.4.0 is in requirements.txt but nothing in this recipe's files touches it. Foundations assumed: S4 (fastapi-project-layout) for app_fastapi.py's mount_router(app, commerce_router.router, prefix="/api", tags=["Commerce"], surfaces=LABS_SURFACES) (838); S8 (alembic-migrations) for the migration chain under Deploy & ops; S6 (workspace-scoping) for refund_order's VerifiedUser/WorkspaceID/IsolationService.verify_workspace_access dependencies - by contrast commerce_download and payrexx_webhook carry no auth dependency at all, workspace is resolved from the row, never a session; S9 (test-layout) for the db_session/client/test_user/test_workspace_id fixtures in tests/conftest.py this recipe's tests depend on.

Environment: FILE_TOKEN_SECRET (core/config.py 290 to 306), default "your-secret-key-change-in-production", a field_validator raises ValueError at settings load when ENVIRONMENT == "production" and the value is still the default - fails closed at boot, not at request time; API_BASE_URL (core/config.py 42), default "http://localhost:8000", the base the download link mailed to the buyer is built from.

Data model

download_grants, one row per (order_id, product_id), minted by _mint_grant (models/commerce_models.py 160 to 185):

download_grants (
  id                    uuid PRIMARY KEY,
  workspace_id          uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  order_id              uuid NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  product_id            uuid NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  file_id               varchar(255) NOT NULL,
  filename              varchar(255),
  downloads_remaining   integer NOT NULL DEFAULT 5,
  downloads_initial     integer NOT NULL DEFAULT 5,   -- refund-guard: refundable only while remaining == initial (comment, not enforced in these files)
  expires_at            timestamptz,
  last_downloaded_at    timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CHECK ck_download_grants_remaining_nonneg: downloads_remaining >= 0
UNIQUE uq_download_grants_order_product: (order_id, product_id)
INDEX  ix_download_grants_order: (order_id)

products.kind CHECK admits 'product','workshop','infoproduct','appointment','credits' (97); products.file_id/products.filename (37 to 38) are the infoproduct's single asset pointer, not the file itself. orders.status CHECK admits 'pending','paid','failed','refunded','cancelled','expired','paid_oversold' (153). downloads_limit and grant_expires_at for a new grant come from product.extra_metadata (JSONB), read once at mint time (152 to 160 in fulfillment_service.py): expires_at is parsed with datetime.fromisoformat and silently becomes NULL on anything malformed (157 to 160, no error surfaced).

What is NOT stored: the file's bytes (only file_id, a pointer into RenderStorageService); a balance of any kind; the download token itself (it is signed and returned in the email/response, never persisted - SecureURLGenerator is stateless).

API surface

RouteAuthTenancyCodesNotes
GET /api/commerce/download/{grant_id} (commerce_router.py 909 to 977)none - no VerifiedUser/WorkspaceID param at allgrant.workspace_id, read from the row, never from a session200 stream; 403 invalid/expired token (925), 403 token doesn't match grant (935), 403 order not paid (940), 404 grant not found (930), 403 download limit reached or link expired (955), 404 file not found in storage (961)Access control is the signed token plus the owning order's live status, not a session
POST /api/orders/{order_id}/refund (1266 to 1323)VerifiedUser + WorkspaceID, IsolationService.verify_workspace_access(..., required_role="member")Order.workspace_id == workspace_id, row locked with_for_update() (1287 to 1291)200 refunded; 404 not found / cross-workspace; 403 role below member; 409 not paid/paid_oversold or no captured transaction; 502 Payrexx rejects the refundCalls reverse_fulfillment(db, order) at 1322, after order.status = "refunded", before the single db.commit()
POST /api/orders/webhook/payrexx (1390 to 1738)none required; an optional X-Webhook-Signature/X-Payrexx-Signature is checked only if present AND PAYREXX_WEBHOOK_SECRET is configured (401 on mismatch)order resolved by tx id (auto-topup only) / referenceId / gateway id, never a workspace in the request200 always (various ok/matched/status shapes); 401 bad signature; 400 missing tx id or tx/order mismatch; 502 could not verify with zahlsThis recipe's primary call site: fulfill_order(db, order) at 1697 when new_status == "paid" (guarded by the amount/currency check at 1651 to 1663 and the re-locked, populate_existing() order fetch at 1618 to 1625); reverse_fulfillment(db, order) at 1704 when new_status is "refunded" or "cancelled" (1703). The trust boundary is the authoritative re-fetch: transaction = fresh at 1594 overwrites the untrusted POST body before any status mapping happens
POST /api/commerce/checkout (896 to 904 → checkout_service.create_checkout)noneworkspace resolved from the product row200 always (pending or paid shape); 404 product missing/inactive; 409 sold outThe CHF 0 short-circuit inside create_checkout (checkout_service.py 188 to 221) is the second caller of fulfill_order, at line 198, for a free one_time product - no gateway opened, fulfill_order runs, then db.commit(), then a download_url is built for the response if the product is an infoproduct

Two Python entrypoints, not routes, but the actual surface a rebuild reimplements: fulfill_order(db, order) -> None (fulfillment_service.py 224 to 367) and reverse_fulfillment(db, order) -> None (370 to 433). Neither commits (module docstring 19 to 21); the caller owns the single transaction.

Application logic

fulfill_order, in commit order:

  1. Load Product by order.product_id; missing product logs an error and returns, fulfilling nothing (230 to 233).
  2. Spark provisioning hook: flag-gated, best-effort try/except, no-ops for every non-Spark order (239 to 251) - out of scope, cited for context.
  3. Unified messaging dispatch (messaging_service.steps_for/dispatch) for ANY kind, running before the kind-specific branch so it also fires for infoproducts; sets has_custom_confirmation when a custom on_event step exists, which later suppresses this recipe's own generic confirmation to avoid a double send; best-effort try/except (259 to 287) - out of scope, cited because it changes whether step 6's email fires.
  4. billing_model in ("intro_then_sub", "subscription"): mint_scheduled_subscription (294 to 296) - out of scope, subscription_service.py.
  5. Kind fan-out:
    • kind == "credits": enqueue_topup, an optional persist_token_from_order when save_card was set, _send_credits_receipt, then return (298 to 341) - out of scope, billing-credits' territory.
    • kind == "infoproduct": no product.file_id logs an error and returns - no grant, no email (344 to 348); otherwise _mint_grant (349), _build_download_url (350), a German-copy email naming grant.downloads_remaining sent via _send_email, then return (351 to 358).
    • anything else (product/workshop/appointment): a generic confirmation email UNLESS has_custom_confirmation is set (362 to 367).

reverse_fulfillment, in commit order:

  1. Load Product; missing returns with nothing done (376 to 378).
  2. billing_model in ("intro_then_sub", "subscription"): look up the ScheduledSubscription by order_id, cancel_subscription if found (382 to 391) - out of scope.
  3. kind == "credits": enqueue_reversal, a best-effort refund-notice email only when extra_metadata.kind == "auto_topup", then return (393 to 424) - out of scope, billing-credits' territory.
  4. kind == "infoproduct": load every DownloadGrant for order_id, set downloads_remaining = 0 on each (426 to 433). No grant row is ever deleted here - the function simply falls off the end after the loop.

_mint_grant (138 to 174): idempotent by a plain SELECT on (order_id, product_id) before any INSERT (144 to 150) - not a database upsert; the actual serialization against a concurrent duplicate mint comes from the caller already holding the order row FOR UPDATE (the webhook re-locks it at 1618 to 1625), not from a lock this function takes itself. Reads downloads_limit/grant_expires_at from product.extra_metadata (152 to 160); db.flush(), not db.commit(), to populate grant.id before the signed URL is built (173).

_build_download_url (fulfillment_service.py 42 to 55) and build_download_url (checkout_service.py 250 to 267) are two independently written functions that do the same thing: resolve FILE_TOKEN_SECRET the same way, sign grant.id (not grant.file_id, so one buyer's token cannot validate against another buyer's grant for the same shared file), and build the same /api/commerce/download/{grant.id}?token=... shape with the same 600-second TTL - see Gotchas.

Invariants

  • A DownloadGrant is minted at most once per (order_id, product_id): enforced twice, the database's uq_download_grants_order_product (commerce_models.py 179) and _mint_grant's query-before-insert (test_download_grant_unique_order_product proves the constraint; test_webhook_idempotent_double_delivery proves the whole webhook path settles on exactly one grant and one email across two deliveries of the same payload).
  • fulfill_order/reverse_fulfillment never commit; the caller's transaction is the unit of atomicity (module docstring 19 to 21).
  • An infoproduct with no file_id fulfills nothing rather than partially (344 to 348) - no grant row, no email.
  • A refund/cancel zeroes downloads_remaining on every grant for the order unconditionally; it never deletes the grant and never touches downloads_initial (426 to 433; test_refund_webhook_reverses_fulfilment).
  • The metered download endpoint's per-grant limit is enforced by one atomic conditional UPDATE ... WHERE downloads_remaining > 0 AND (expires_at IS NULL OR expires_at > now()) RETURNING file_id (commerce_router.py 944 to 952), never a read-then-write in application code; test_download_atomic_decrement_no_over_download seeds downloads_remaining=1 and proves the first GET is 200, the second is 403, and the row lands at exactly 0.
  • The download endpoint checks the OWNING ORDER's live status == "paid" (938 to 940) independently of the token's own validity - a grant already zeroed by reverse_fulfillment, or one whose order never paid, cannot be downloaded even with a structurally valid, unexpired token (test_download_requires_paid_order).

Frontend

The module that would render this, by id:

commerce-payments/ui/storefront-preview   the buyer-facing surface this fan-out's effects reach: the
                                                                checkout response's download_url (CHF0 short-circuit)
                                                                and the confirmation/download email's link. At this
                                                                commit nothing in the read files renders a merchant-
                                                                facing admin panel for DownloadGrant state - the
                                                                admin surface below is the closest thing that exists

Minimum admin/debug surface (no dedicated route exists at this commit): SELECT id, order_id, product_id, downloads_remaining, downloads_initial, expires_at, last_downloaded_at FROM download_grants WHERE order_id = :order_id.

Security & tenancy

commerce_download: no auth dependency at all - access control is entirely the signed token (SecureURLGenerator, HMAC-SHA256 via FILE_TOKEN_SECRET) plus the owning order's live status. Every guard raises HTTPException (403 or 404) rather than falling through to a default-permit branch (925, 930, 935, 940, 955, 961). Workspace derivation: grant.workspace_id, read from the row; verify_download_token also returns a workspace_id from the decoded payload (url_generator.py 129), but commerce_router.py never compares it to grant.workspace_id - the only binding check performed is v.get("file_id") != str(grant_id) (934). Not independently exploitable through this endpoint alone (the token is signed to one specific grant UUID), but flagged in Gotchas as a value this endpoint decodes and never verifies. No rate-limit or slowapi decorator exists on this route or the webhook (grep of commerce_router.py); the only bound on repeated use of one token is downloads_remaining itself and the token's own expiry.

payrexx_webhook: unauthenticated by design, the trust boundary is the authoritative re-fetch, not the POST body - transaction = fresh (1594) overwrites the caller-supplied payload before tx_status is ever read, and fulfill_order runs only after that. A mismatched amount or currency blocks the paid transition (and therefore fulfill_order) before either is called (1651 to 1663; test_webhook_amount_mismatch_blocks_fulfilment proves no grant, no email, order stays pending).

refund_order: VerifiedUser + Order.workspace_id == workspace_id with a with_for_update() row lock (1287 to 1291), then IsolationService.verify_workspace_access(..., required_role="member") (1298 to 1303) - a viewer-role membership passes the WorkspaceID dependency itself but is refused here. No test in this file exercises refund_order for cross-workspace rejection directly; test_attach_asset_cross_workspace_rejected proves the same IsolationService pattern on a neighbouring admin route in this file, not this one - a genuine gap, not invented coverage.

Secrets at rest: none minted by this recipe beyond the ephemeral, unstored download token; FILE_TOKEN_SECRET is an env var never logged (grep of the five read files for a token being logged finds nothing). Attacker-controlled inputs: the token query param (structurally arbitrary, verified by signature and expiry only) and the grant_id path param (UUID-typed by FastAPI; an unknown id 404s at the DB load, 930).

Tests

tests/test_commerce_phase1.py, 25 def test_ functions (grep -c "^def test_"), 0 @pytest.mark.parametrize. Run 2026-09-12 against an isolated scratch Postgres created for this measurement (the shared default postgresql://test_user:test_password@localhost:5434/test_db deadlocked twice under Base.metadata.drop_all - see Gotchas - consistent with another agent's concurrent use of that same database):

TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/commerce_recipe_scratch \
    python -m pytest tests/test_commerce_phase1.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 25 passed (360 warnings: Pydantic v2 class-Config deprecations elsewhere in the app, plus a LegacyAPIWarning on db.query(...).get(...) at commerce_router.py 928 and 938 - see Gotchas). Schema comes from tests/conftest.py's session-scoped Base.metadata.create_all, not alembic upgrade head, for this file's tests.

Hermetic: mock_payrexx stubs gateway creation; the webhook's authoritative re-fetch is stubbed by the autouse _stub_webhook_refetch fixture against a module-level _TX_REGISTRY dict, never a live zahls call; mock_email stubs email_service._send_via_resend; stub_storage stubs RenderStorageService.file_exists/get_file_size/read_file_stream so the download endpoint streams without a real file. No live network anywhere in this file.

Tests this recipe's scope owns directly: test_download_grant_unique_order_product, test_download_grant_remaining_nonneg_constraint (the two DownloadGrant constraints); test_checkout_free_short_circuit (CHF0 → fulfill_order via checkout_service); test_webhook_idempotent_double_delivery, test_webhook_grant_mint_and_email, test_webhook_amount_mismatch_blocks_fulfilment (webhook → fulfill_order); test_download_atomic_decrement_no_over_download, test_download_requires_paid_order, test_download_bad_token_rejected (the metered endpoint); test_refund_webhook_reverses_fulfilment (reverse_fulfillment zeroing). The remaining 16 tests in the file exercise neighbouring recipes (migration identity, Product/Order CHECK constraints unrelated to DownloadGrant, checkout amount derivation, workshop capacity) and are not re-described here.

Known-answer vector: _payrexx_paid_payload's transaction id 999001 is the fixed literal _TX_REGISTRY keys on; the fixture's own comment (93 to 96) documents it as the test's substitute for zahls' authoritative re-fetch.

What a rebuild adds and this branch lacks: no test asserts the download endpoint's decoded workspace_id against anything (there is nothing to assert - see Gotchas); no test exercises refund_order specifically for cross-workspace rejection; no Playwright/E2E spec exists for the download link a buyer actually clicks (this is a webhook-plus-streaming-response surface, no browser flow in this repo).

Deploy & ops

Config keys: FILE_TOKEN_SECRET, API_BASE_URL (both above). No scheduler job: unlike a retry-driven engine, this fan-out runs synchronously inside the request that flips the order (the webhook or the CHF0 checkout), never on an APScheduler tick - grep of scheduled_jobs.py for fulfill_order/reverse_fulfillment/download_grants returns nothing. Migrations, in chain order (backend/migrations/versions/): 20260609_0001_commerce_kind_infoproducts.py (commerce_kind_infoproducts_001, down-revision review_original_snapshot_001) creates download_grants and products.kind/file_id/filename, widens ck_orders_status; 20260610_0004_commerce_subscriptions.py adds billing_model and its columns (not this recipe's table, cited for the chain); 20260903_0001_commerce_image_content_emdash.py and 20260903_0003_commerce_second_image.py add product image/content columns (out of scope). Verified 2026-09-12: alembic upgrade head from an empty database completes cleanly through 98 revision files to one head (prima_device_codes_001), no error beyond the INFO [alembic.runtime.migration] lines.

Argus-style check: GET /api/commerce/download/{grant_id}?token=garbage returns 403 (test_download_bad_token_rejected proves the shape). No dedicated health route exists for this fan-out at this commit. Rollback: nothing deployable beyond the code and its one owning migration; reverse_fulfillment never deletes a download_grants row (only zeroes it), so a code rollback needs no data unwind.

Gotchas

  • 2026-09-12 (measured at 62ba4fc): two independently written implementations of the signed download URL exist - fulfillment_service._build_download_url (42 to 55) and checkout_service.build_download_url (250 to 267) - same signing-key resolution, same signed field (grant.id), same DOWNLOAD_TOKEN_TTL_SECONDS = 600 defined separately in each file. fulfillment_service.py's own comment (194 to 196) explains why the import runs the way it does (a circular-import dodge: checkout_service.create_checkout imports fulfill_order lazily, not the reverse), but nothing explains why the URL builder itself is duplicated rather than shared. A change to the token shape in one (say, adding a claim) does not propagate to the other.
  • 2026-09-12 (measured at 62ba4fc, commerce_router.py 922 to 935): SecureURLGenerator.verify_download_token returns a workspace_id from the decoded payload (url_generator.py 129), but commerce_download never reads it or compares it to grant.workspace_id - the only binding check performed is v.get("file_id") != str(grant_id). Not independently exploitable through this endpoint today (the token is bound to one unguessable grant UUID), but a rebuild that starts trusting the token's workspace_id for anything would be trusting a value this endpoint currently never verifies against the row it names.
  • 2026-09-12 (measured at 62ba4fc): db.query(...).get(...) at commerce_router.py 928 and 938 is SQLAlchemy 2.0's legacy Query.get(), flagged by a LegacyAPIWarning observed directly in the test run above (Tests). Functionally correct at this commit; cited because it is exactly the kind of call that breaks on a future SQLAlchemy major bump.
  • 2026-09-12 (measured at 62ba4fc): two live-DB runs of this recipe's own test file against the repo's default shared scratch database (postgresql://test_user:test_password@localhost:5434/test_db) both ended in a Postgres DeadlockDetected during Base.metadata.drop_all (on a leads/users foreign-key drop), consistent with another process concurrently dropping/recreating the same schema. An isolated database gave a clean 25 passed. This is a property of the test harness's session-scoped drop_all/create_all under concurrent invocation, not a defect in this recipe's code or tests - worth knowing before reading a single flaky local run as a real failure.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/commerce/fulfillment_service.py (433 lines): module docstring (1 to 21); _build_download_url (42 to 55); _event_anchor (58 to 105, out of scope, cited for context); _message_variables (108 to 135, out of scope); _mint_grant (138 to 174); _send_email (177 to 184); _send_credits_receipt (187 to 221, out of scope, billing-credits); fulfill_order (224 to 367); reverse_fulfillment (370 to 433).
  • alchemy_labs/backend/models/commerce_models.py (270 lines): Product (20 to 107, kind/billing_model CHECKs at 97 to 101); Order (110 to 157, status CHECK at 153); DownloadGrant (160 to 185, all columns/CHECK/UNIQUE); ScheduledSubscription (187 to 246, out of scope); SubscriptionCancelOtp (248 to 270, out of scope).
  • alchemy_labs/backend/api/routers/commerce_router.py (1738 lines): imports (38 to 45); commerce_checkout (896 to 904); commerce_download (909 to 977); refund_order (1266 to 1323); payrexx_webhook (1390 to 1738 - this recipe's scope is the paid/refunded/cancelled transition and its fulfill_order/reverse_fulfillment calls at 1697/1704, not the order-matching/signature logic upstream of it).
  • alchemy_labs/backend/services/commerce/checkout_service.py (267 lines): create_checkout (37 to 247), the CHF0 short-circuit at 188 to 221 (the second caller of fulfill_order, at line 198); build_download_url (250 to 267, the duplicate builder, see Gotchas).
  • alchemy_labs/backend/services/storage/url_generator.py (330 lines, whole file read): SecureURLGenerator.__init__ (20 to 27); generate_download_token (29 to 79); verify_download_token (81 to 139); _sign_payload (283 to 299). generate_upload_token/verify_upload_token/generate_share_link/verify_share_password/generate_direct_download_url (141 to 282, 301 to 330) are unused by this recipe's call sites, cited for completeness only.
  • alchemy_labs/backend/migrations/versions/20260609_0001_commerce_kind_infoproducts.py: the one migration this recipe's schema (download_grants, products.kind/file_id/filename) is defined in.
  • alchemy_labs/backend/tests/test_commerce_phase1.py (831 lines, whole file read): fixtures (39 to 241); the 25 tests listed under Tests.

Deviations, all dated 2026-09-12: two independently-maintained download-URL builders instead of one shared function (Gotchas); the download endpoint decodes but never verifies the token's own workspace_id claim against the grant row; no admin/debug route exists for DownloadGrant state beyond the SQL query under Frontend; refund_order's cross-workspace rejection is untested in this file (a neighbouring route's test covers the same IsolationService pattern, not this endpoint).

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc against a scratch Postgres not shared with a concurrent writer. Expected observations are exact, as measured 2026-09-12.

  1. createdb commerce_recipe_check then SYNC_DATABASE_URL=postgresql://localhost:5434/commerce_recipe_check DATABASE_URL=postgresql://localhost:5434/commerce_recipe_check ENVIRONMENT=development WEBAUTHN_RP_ID_BACKFILL=localhost python -m alembic upgrade head completes with no error beyond the INFO [alembic.runtime.migration] lines, ending on head prima_device_codes_001.
  2. TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/commerce_recipe_check python -m pytest tests/test_commerce_phase1.py -q --no-cov -o addopts="" -p no:cacheprovider prints 25 passed.
  3. grep -c "^def test_" tests/test_commerce_phase1.py prints 25; grep -c "@pytest.mark.parametrize" tests/test_commerce_phase1.py prints 0 - the 25 defs are exactly the 25 collected and passed tests, no parametrize expansion.
  4. grep -n "UniqueConstraint('order_id', 'product_id'" models/commerce_models.py prints the DownloadGrant UNIQUE at line 179.
  5. grep -n "^def fulfill_order\|^def reverse_fulfillment" services/commerce/fulfillment_service.py prints 224 and 370; grep -rn "fulfill_order(db, order)" api/routers/commerce_router.py services/commerce/checkout_service.py prints exactly two call sites (commerce_router.py:1697, checkout_service.py:198), confirming the webhook's paid transition and the CHF0 short-circuit are its only two callers.
  6. grep -n "downloads_remaining = downloads_remaining - 1" api/routers/commerce_router.py prints the one atomic conditional UPDATE, at line 946, confirming the metered decrement is not read-then-write application code.

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.