Prima Get an API key

Module kit / Commerce and payments

Direct checkout and order creation

commerce-payments/checkout-and-order-creation

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/alembic-migrations, _shared/error-envelope, _shared/test-layout
Frontends commerce-payments/ui/storefront-preview
Reference implementation
  • alchemy_labs/backend/services/commerce/checkout_service.py
  • alchemy_labs/backend/api/routers/commerce_router.py
  • alchemy_labs/backend/schemas/commerce_schemas.py
  • alchemy_labs/backend/services/commerce/capacity.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A stranger with nothing but a product id and an email can turn that into a pending Order and an open Payrexx checkout page, or, for a free product, a paid order and a download link, in one POST. The invariant that makes it trustworthy: the amount is computed from the Product row the server itself loaded, never from anything in the request body, on every billing model, so a caller who posts "amount_cents": 1 alongside a real product id still pays what the catalog says (module docstring, checkout_service.py 1 to 12; CheckoutRequest carries no price field at all, commerce_schemas.py 381).

When to use

Read it before touching create_checkout, the commerce_checkout route, CheckoutRequest/CheckoutResponse, the _TRACKING_KEYS allowlist, or build_download_url; before changing how amount_cents is derived per billing_model, or what happens when a product's price is CHF 0; and when someone asks "why was this order created for a different amount than the buy page showed", "why did this checkout open a tokenization gateway instead of a normal one", "why didn't Payrexx get called for this order", or "can a caller smuggle an extra field into an order". Trigger phrases: "direct checkout", "commerce checkout", "server-derived amount", "CHF 0 short-circuit", "tokenization gateway", "create_checkout", "POST /api/commerce/checkout", "capacity guard at checkout".

Not this recipe: payrexx_webhook/refund_order and everything about turning a pending order into paid/refunded (commerce-payments/payrexx-order-settlement, a neighbouring recipe - this recipe stops at "gateway opened" or "CHF 0 fulfilled inline", it never reads a webhook); fulfill_order's own kind-keyed fan-out logic (services/commerce/fulfillment_service.py, called once, at the CHF-0 short-circuit, but not detailed here); the tokenize=True keyword's OTHER caller and its save-card/auto-topup outcome - POST /api/prima/v1/credits/topup sets tokenize=True with extra_order_metadata={"save_card": True, "credit_tenant_id": ...} so a card gets banked for later auto top-up (checkout_service.py 57 to 69), that whole path plus persist_token_from_order, auto_topup_service.py, and the ADR-0036 low-balance-alert engine are billing-credits's territory, this recipe only documents the tokenization gateway itself and the direct-checkout caller that reaches it via billing_model in ("intro_then_sub", "subscription"); the recurring-charge orchestration after checkout - services/commerce/subscription_service.py's _convert_one/run_due_conversions/convert_now/charge_token, the scheduler that actually charges the banked token on a schedule - also billing-credits's territory, cited here only because create_checkout opens the same tokenization gateway that engine later charges; product catalog CRUD, product images, and EmDash publishing (commerce-payments/product-catalog-crud); GET /commerce/public/product/{id}/availability itself (commerce_router.py 876 to 893) - this recipe covers only the checkout-time capacity guard that shares capacity.py with it, not the availability read endpoint.

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 inside PayrexxService (not httpx, despite httpx==0.28.1 being pinned elsewhere in this backend), 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, the router this endpoint lives on; S8 (alembic-migrations) for the two chained migrations under Deploy & ops; S7 (error-envelope) for the generic HTTPException handler (app_fastapi.py 724 to 725) that shapes the 404/409 this recipe raises; S9 (test-layout) for the db_session/client fixtures test_commerce_phase1.py uses. NOT assumed: _shared/auth-dependency-pattern or _shared/workspace-scoping - commerce_checkout carries no VerifiedUser/WorkspaceID dependency at all, workspace is derived solely from product.workspace_id inside create_checkout (checkout_service.py 47 to 48, 124 to 126).

Environment, all in core/config.py 595 to 598 plus one more: PAYREXX_INSTANCE_NAME/PAYREXX_API_SECRET (central fallback zahls account), PAYREXX_TEST_MODE (informational only, never changes which Payrexx operation runs), and FILE_TOKEN_SECRET (os.getenv, default "your-secret-key-change-in-production", checkout_service.py 34, mirrors api/files/download_router.py:20 - see Gotchas). Per-workspace override: PayrexxService.for_workspace(db, order.workspace_id) (checkout_service.py 147) resolves the workspace's own zahls account if configured, else the central singleton. Fail behaviour: an unconfigured Payrexx account raises HTTPException(503, ...) from inside _make_request before any gateway call is attempted (payrexx_service.py 49 to 53); a missing/inactive product is a 404 before any order row is written (checkout_service.py 80 to 81); a capacity breach is a 409 before any order row is written (118 to 120).

Data model

products and orders (models/commerce_models.py 20 to 157), the two tables this recipe reads and writes; only the columns this recipe touches are shown, full shape is commerce-payments/product-catalog-crud's table:

products (
  id                      uuid PRIMARY KEY,
  workspace_id            uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  price_cents             integer NOT NULL,               -- CHECK price_cents >= 0
  currency                varchar(10) NOT NULL DEFAULT 'CHF',
  is_active               boolean NOT NULL DEFAULT true,
  kind                    varchar(20) NOT NULL DEFAULT 'product',   -- product|workshop|infoproduct|appointment|credits
  billing_model           varchar(30) NOT NULL DEFAULT 'one_time',  -- CHECK one_time|subscription|intro_then_sub
  intro_price_cents       integer,                        -- intro_then_sub: charged at checkout, may be 0
  recurring_price_cents   integer,                        -- subscription: the full first-period amount charged at checkout
  success_url text, cancel_url text,
  metadata                jsonb NOT NULL DEFAULT '{}'      -- extra_metadata['capacity'] read by the checkout guard
);
orders (
  id                       uuid PRIMARY KEY,               -- minted by SQLAlchemy's uuid.uuid4 default, flushed before referenceId is used
  workspace_id             uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,  -- FROM product.workspace_id, never the caller
  product_id               uuid NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
  customer_email           varchar(255) NOT NULL,
  customer_name            varchar(255),
  amount_cents             integer NOT NULL,               -- SERVER-DERIVED, see Application logic step 2
  currency                 varchar(10) NOT NULL,            -- copied from product.currency
  status                   varchar(20) NOT NULL DEFAULT 'pending',
  provider                 varchar(50) NOT NULL DEFAULT 'payrexx',
  payrexx_gateway_id       varchar(255),
  checkout_url             text,
  metadata                 jsonb NOT NULL DEFAULT '{}'      -- {kind, quantity, billing_model, tracking}
);
CHECK ck_orders_status: status IN ('pending','paid','failed','refunded','cancelled','expired','paid_oversold')
CHECK ck_products_billing_model: billing_model IN ('one_time','subscription','intro_then_sub')

What is NOT stored anywhere in this recipe's write path: any body-supplied price/amount field (CheckoutRequest has none - price, price_cents, amount, amount_cents sent in a hostile body are simply not read, test_checkout_amount_server_derived); the tracking dict beyond an allowlisted, scalar-only, 512-char-clamped subset (_TRACKING_KEYS, commerce_schemas.py 365 to 371, _clamp_tracking 395 to 414); a card number or any PSP credential (the checkout path only ever sends amount/currency/customer contact fields to Payrexx, payrexx_service.py create_gateway/create_tokenization_gateway).

API surface

RouteAuthTenancyBodyCodesNotes
POST /api/commerce/checkout (commerce_router.py 896 to 904)none - public, unauthenticated, no VerifiedUser/WorkspaceID dependencynone in the request; workspace is resolved from product.workspace_id inside create_checkout, never from a header (checkout_service.py 47 to 48)CheckoutRequest (commerce_schemas.py 374 to 414): product_id: UUID, customer_email: EmailStr, customer_name: Optional[str], quantity: int = 1 (ge=1, le=100), success_url/cancel_url: Optional[str], tracking: Optional[Dict[str, Any]] (allowlisted, clamped)200 CheckoutResponse ({order_id, status: "pending"|"paid", checkout_url, download_url}); 404 product not found or is_active=False (checkout_service.py 80 to 81); 409 SOLD_OUT when a capacity-limited product has no seats left (118 to 120, capacity.SOLD_OUT_DETAIL)Calls straight into create_checkout(db, payload) (commerce_router.py 904) with no extra keywords - the two server-side-only keywords (extra_order_metadata, tokenize) are never reachable from this public route, only from the authenticated billing-credits caller

The one Python entrypoint this route (and one other, out-of-scope, caller) calls:

FunctionOwns its own transactionRaisesReturns
create_checkout(db, payload, *, extra_order_metadata=None, tokenize=False) (checkout_service.py 37 to 247)yes, one db.commit() per branchHTTPException(404) (no/inactive product), HTTPException(409) (sold out); Payrexx errors propagate as HTTPException(500|503) from _make_request, uncaught heredict shaped as CheckoutResponse

Application logic

create_checkout, in commit order, mirroring the source's own numbered comments (checkout_service.py 37 to 247):

  1. Resolve the product (74 to 81): db.query(Product).filter(Product.id == payload.product_id, Product.is_active.is_(True)).first(); None -> 404 "Product not found". product.workspace_id is the ONLY source of tenancy for everything that follows.
  2. Server-derive the amount (83 to 100), by billing_model, never from the body: intro_then_sub -> amount_cents = product.intro_price_cents or 0, qty forced to 1 (may be 0 = tokenize without charging); subscription -> amount_cents = product.recurring_price_cents or product.price_cents (the full first period), qty forced to 1; anything else (one_time) -> amount_cents = product.price_cents * qty, where qty = max(1, min(payload.quantity, MAX_QUANTITY=100)) (29, 84).
  3. Cross-workspace child-id pattern (102 to 107): Phase 1 carries no body-supplied child id, so there is nothing to validate here - the comment states the REQUIRED pattern for a later phase that adds one (re-load filtered by product.workspace_id, 404 on mismatch), not something this recipe's current code executes.
  4. Capacity guard (109 to 120), BEFORE any order or gateway: product_capacity(product) reads product.extra_metadata["capacity"] (capacity.py); None = unlimited, unaffected. If set, seats_taken(db, product.id) + qty > capacity -> HTTPException(409, SOLD_OUT_DETAIL); seats_taken counts confirmed (paid/paid_oversold) orders plus live pending ones (created within PENDING_TTL_MINUTES=30, capacity.py), so two tabs racing for the last seat can't both pass this check, and an abandoned pending checkout frees its seat after 30 minutes.
  5. Create the pending Order (122 to 142): workspace_id/product_id/customer_email/customer_name/amount_cents/currency from the resolved product and payload; status="pending", provider="payrexx"; extra_metadata={kind, quantity: qty, billing_model, tracking: payload.tracking or {}, **(extra_order_metadata or {})} - server-established keys are spread LAST so a crafted body cannot override them (139, docstring 50 to 55). db.add(order); db.flush() (141 to 142) populates order.id before it is used as the Payrexx referenceId.
  6. Resolve the Payrexx account (144 to 147): PayrexxService.for_workspace(db, order.workspace_id) - the workspace's own zahls account if configured, else the platform-central instance. Resolved once, shared by both branches below.
  7. Tokenization branch (149 to 186): taken when tokenize is True (never true from this public route) OR product.billing_model in ("intro_then_sub", "subscription"). Opens px.create_tokenization_gateway(amount=amount_cents, currency=order.currency, purpose=product.name, customer_email, customer_name, success_url, cancel_url, reference_id=str(order.id)) (168 to 177) - preAuthorization=1 banks a reusable token, chargeOnAuthorization=1 also captures now but ONLY when amount > 0 (payrexx_service.py 266 to 267), so an intro_then_sub product with a CHF-0 intro tokenizes without charging rather than taking the CHF-0 fulfill short-circuit below. Persists order.payrexx_gateway_id/order.checkout_url, one db.commit() (178 to 180), returns {order_id, status: "pending", checkout_url, download_url: None} (181 to 186).
  8. CHF-0 short-circuit (188 to 221), reached only for one_time billing (the two recurring models were already routed through step 7 above, even at amount 0): if amount_cents == 0, order.status = "paid", order.paid_at = now, fulfill_order(db, order) runs in the same transaction (196 to 198, kind-keyed fan-out, out of this recipe's scope), then a best-effort server-side Purchase CAPI fire (199 to 201), then db.commit() (202). For an infoproduct, the DownloadGrant fulfill_order minted is looked up and turned into a signed URL via build_download_url (204 to 214); returns {order_id, status: "paid", checkout_url: None, download_url} (216 to 221).
  9. Paid path, plain gateway (223 to 233): the remaining case - one_time, amount_cents > 0. px.create_gateway(amount=amount_cents, currency, purpose=product.name, customer_email, customer_name, success_url, cancel_url, metadata={"referenceId": str(order.id)}) - no preAuthorization, no token banked.
  10. Persist and return (235 to 247): order.payrexx_gateway_id/order.checkout_url set BEFORE the commit (235 to 239, "so a fast webhook can correlate against this order" - comment), then {order_id, status: "pending", checkout_url, download_url: None} (242 to 247).

build_download_url(grant) (250 to 267): signs a token with SecureURLGenerator(os.getenv("FILE_TOKEN_SECRET", _FILE_TOKEN_SECRET_DEFAULT)), bound to grant.id (NOT product.file_id - grants for the same infoproduct share one file_id, so signing the file id would let one buyer's token validate against another buyer's grant, comment 259 to 261), expires_in=DOWNLOAD_TOKEN_TTL_SECONDS=600 (31). Returns f"{settings.API_BASE_URL}/api/commerce/download/{grant.id}?token={token}".

Invariants

  • The amount charged is always f(product row, billing_model, clamped quantity), never a body-supplied value, on every branch - CheckoutRequest has no price field at all (test_checkout_amount_server_derived: a body claiming amount_cents: 1 against a 5000-cent product still creates a 10000-cent order for quantity 2).
  • A capacity-limited product's checkout is refused BEFORE any Order row or Payrexx gateway is created, never after (step 4 runs before step 5; test_checkout_blocked_at_capacity_returns_409_sold_out asserts zero new orders and zero gateway calls on a 409).
  • extra_order_metadata (server-side only) is merged into order.extra_metadata AFTER the body-derived keys, so a crafted request body cannot override a server-established fact (docstring 53 to 55, checkout_service.py 139) - untested by name in test_commerce_phase1.py (this keyword is unreachable from the public route this recipe documents; see When to use).
  • A recurring billing model (subscription/intro_then_sub) always tokenizes, even at amount 0 - it never takes the CHF-0 fulfill short-circuit, because a reusable token still has to be banked for the later recurring charge (comment 156 to 158, 165 to 166).
  • The Payrexx gateway id and checkout url are persisted to the Order row and committed BEFORE the function returns, on both the tokenization and plain-gateway branches (178 to 180, 237 to 239) - nothing here leaves a pending order without a checkout_url a caller could redirect to.

Frontend

The module that would render this, by id:

commerce-payments/ui/storefront-preview   the public buy page this endpoint is called from;
                                                                at 62ba4fc the only real caller in this
                                                                repo's own frontend is alchemy_labs'
                                                                own admin-built app, not a Prima surface:
                                                                frontend/src/pages/commerce/BuyPage.tsx
                                                                (1 to 14, docstring: "Buy -> POST
                                                                /api/commerce/checkout -> redirect to
                                                                checkout_url") calling
                                                                frontend/src/services/commerceService.ts
                                                                checkout() (579 to 619); the Prima-side
                                                                storefront-preview module this recipe
                                                                names is this checkout's Prima-native
                                                                renderer and is not this recipe's own
                                                                evidence

Minimum admin and debug surface: none dedicated to checkout creation itself - the closest is GET /api/orders/GET /api/orders/{id} (payrexx-order-settlement's territory, commerce_router.py 1227 to 1263), which shows a checkout's resulting Order row (status, payrexx_gateway_id, amount_cents) after the fact; no dedicated log line marks a successful create_checkout call (Payrexx request/response logging lives inside PayrexxService._make_request, payrexx_service.py 61 to 62, 90 to 91, generic to every caller, not checkout-specific).

Security & tenancy

Auth dependency: none - commerce_checkout carries no VerifiedUser/WorkspaceID dependency, mounted with everything else on commerce_router.router under surfaces=LABS_SURFACES (app_fastapi.py 838), and SurfaceGate passes an anonymous request straight through regardless (api/surface_gate.py module docstring, "many gated routers also carry public endpoints (checkout, embeds, webhooks)"). Workspace derivation: exclusively product.workspace_id, read from the DB row the caller's product_id resolves to - the caller supplies an id, never a workspace, so there is no cross-workspace input to reject at this endpoint (contrast payrexx-order-settlement's refund_order, which is workspace-scoped and authenticated).

Fail direction: closed on the product lookup (any non-active or non-existent product id is 404 before an order is written, 80 to 81); closed on capacity (409 before an order or gateway, 118 to 120); the amount derivation has no failure path exposed to the caller - it is pure arithmetic over a trusted row, never validated against caller input because none is accepted.

Secrets at rest: PAYREXX_API_SECRET/per-workspace secrets are read through PayrexxService/get_payrexx_config, never inlined in this module; FILE_TOKEN_SECRET signs the download token this recipe mints on the CHF-0 path (see Gotchas for its insecure default). Attacker-controlled inputs, all of them, because the endpoint is unauthenticated: product_id (must resolve to an active product or the request goes nowhere), customer_email/customer_name (stored verbatim, EmailStr-validated only), quantity (clamped 1..100, and forced to 1 for both recurring billing models regardless of what is sent), success_url/cancel_url (passed straight to Payrexx as redirect targets, not validated against the product's own domain - not determinable from this recipe's source set whether Payrexx itself restricts these), tracking (allowlisted key set, scalar values only, 512-char clamp, _clamp_tracking 395 to 414 - the schema's own comment states the pre-allowlist shape was "an unauthenticated arbitrary-write primitive into orders", 385 to 392). Rate limits: none dedicated to this route at this commit (not found in commerce_router.py or the app_fastapi.py mount call for it) - the same absence payrexx-order-settlement notes for the webhook; an attacker can open unlimited pending gateways against any active product (each a real, if uncharged, Payrexx Gateway/ API call) up to whatever capacity/seat limit the product itself carries, or none at all for an unlimited product.

Tests

Run against the transactional db_session/client fixtures in backend/tests/test_commerce_phase1.py (831 lines, the designated source), using the mock_payrexx fixture (stubs PayrexxService.create_gateway, records call count, 132 to 156 - it does NOT stub create_tokenization_gateway, see Gotchas) and, where fulfillment runs, mock_email/stub_storage.

Named tests covering this recipe: test_checkout_amount_server_derived (365 to 389, quantity 2 against a 5000-cent product yields amount_cents == 10000, ignoring hostile price/price_cents/amount/amount_cents body fields, one mock_payrexx call); test_checkout_free_short_circuit (394 to 417, CHF-0 infoproduct -> status: "paid", checkout_url: None, a download_url, one DownloadGrant, zero Payrexx calls); test_checkout_missing_product_404 (422 to 432); test_checkout_inactive_product_404 (436 to 444); test_checkout_blocked_at_capacity_returns_409_sold_out (725 to 750, capacity 2 already filled by two paid orders -> 409 SOLD_OUT in the response text, zero new orders, zero gateway calls); test_unlimited_product_unaffected_by_capacity (778 to 804, a product with no capacity metadata checks out normally regardless of how many prior paid orders exist).

Known-answer vectors: mock_payrexx's fake create_gateway returns a fixed {"gateway_id": 12345, "checkout_url": "https://pay.example.test/checkout/abc", ...} (137 to 146) - real for every one_time paid-path assertion in this file.

Gap, stated plainly: test_commerce_phase1.py has NO test that drives the tokenization branch (step 7 above) - no test in this file sets billing_model to subscription or intro_then_sub and checks out, and mock_payrexx never stubs create_tokenization_gateway, so a subscription/intro_then_sub checkout run against this file's fixtures would attempt a real, unstubbed Payrexx call. Tests for that branch exist in backend/tests/test_commerce_subscriptions.py (test_checkout_intro_then_sub_tokenizes 325, test_checkout_intro_zero_tokenizes_without_charge 354, test_checkout_pure_subscription_tokenizes_full_period 376, each using a separate mock_tokenization fixture) but that file was not in this recipe's designated source set and is not cited as verified evidence here - .claude/architecture.md:25 lists it alongside test_commerce_phase1.py under the same "Store" preflight gate row, so the tokenization branch is exercised before every prod deploy, just not by the file this recipe was measured against.

Gap, stated plainly: a full run of test_commerce_phase1.py on 2026-09-12 in this authoring environment hit sqlalchemy.exc.ProgrammingError: relation "users" does not exist on 24 of 25 tests, consistent with a concurrent session's Base.metadata.drop_all()/create_all() racing against this one on the SAME shared scratch Postgres (tests/conftest.py 56 to 108, session-scoped test_engine, port 5434) - this authoring session is one of several agents running in this environment at once. Individual, isolated invocations of the checkout-relevant tests named above (test_checkout_amount_server_derived, test_checkout_missing_product_404) passed cleanly (1 passed), but a trustworthy full-suite count is not determinable from a contended shared database and is not asserted here.

Deploy & ops

Config keys: PAYREXX_INSTANCE_NAME, PAYREXX_API_SECRET, PAYREXX_TEST_MODE (core/config.py 595 to 598, shared with payrexx-order-settlement), FILE_TOKEN_SECRET (os.getenv, no config.py entry - see Gotchas), API_BASE_URL (settings, used to build the download link). Migrations touching the tables this recipe reads/writes: 20260609_0001_commerce_kind_infoproducts.py (revision commerce_kind_infoproducts_001, down-revision review_original_snapshot_001, adds products.kind/file_id/filename, widens ck_orders_status to include paid_oversold, creates download_grants); 20260610_0004_commerce_subscriptions.py (revision commerce_subscriptions_001, down-revision contact_spine_001, adds billing_model/intro_price_cents/recurring_price_cents/recurring_interval/recurring_interval_count/convert_after_value/convert_after_unit to products, ck_products_billing_model, and the scheduled_subscriptions table this checkout does not itself write to).

Router mount: mount_router(app, commerce_router.router, prefix="/api", tags=["Commerce"], surfaces=LABS_SURFACES) (app_fastapi.py 838), same mount as every other Commerce route - no feature flag or surface_exempt marker gates commerce_checkout specifically; its unauthenticated status comes from carrying no VerifiedUser/WorkspaceID parameter, not from a surface exemption. docs/PREFLIGHT.md lines 1 to 29 name test_commerce_phase1.py and test_commerce_subscriptions.py under the "Store" row of the mandatory hermetic pre-deploy gate (scripts/preflight.sh), described there as proving "checkout (tokenization), paid webhook -> fulfillment" - so this endpoint, both billing branches, is treated as a deploy-blocking critical path even though (see Tests) the tokenization half is not in this recipe's own designated evidence file.

Argus-style checks: POST /api/commerce/checkout with an unknown product_id returns 404 (proves the route is reachable without a real product); a workspace whose PAYREXX_API_SECRET/PAYREXX_INSTANCE_NAME is unset answers 503 on the first Payrexx call (payrexx_service.py 49 to 53) before any partial order state is left behind - the order row itself was already committed at step 5, so a Payrexx outage leaves a pending order with no checkout_url, not a missing order (not determinable from this recipe's source set whether anything currently sweeps such stuck-pending rows). Rollback: no destructive migration in this path; an order row is never deleted by create_checkout.

Gotchas

  • 2026-09-12 (measured at 62ba4fc, checkout_service.py 33 to 34): FILE_TOKEN_SECRET defaults to the literal string "your-secret-key-change-in-production" when the environment variable is unset - the exact same default api/files/download_router.py:20 and services/commerce/fulfillment_service.py use, deliberately mirrored so all three can verify each other's tokens. Whether production actually sets FILE_TOKEN_SECRET is not determinable from the three files this recipe was measured against; if it does not, every CHF-0 infoproduct's download token is signed with a public, hardcoded string.
  • 2026-09-12 (measured at 62ba4fc, test_commerce_phase1.py 132 to 156): the mock_payrexx fixture stubs create_gateway only, not create_tokenization_gateway - see the Tests section's first stated gap. A rebuild that assumes this fixture covers every checkout branch is wrong.
  • 2026-09-12 (measured in this authoring environment): a full run of test_commerce_phase1.py collided with a concurrent session's schema teardown on the shared scratch Postgres - see the Tests section's second stated gap. This is an artifact of this authoring session's concurrent environment, not a defect in the recipe's source.
  • 2026-09-12 (measured at 62ba4fc, checkout_service.py 102 to 107): the cross-workspace child-id validation this comment describes as a REQUIRED PATTERN for later phases does not exist yet in this file - Phase 1 has no body-supplied child id to validate. A rebuild extending this endpoint with one (a cohort id, a meeting-type id) must add that re-load-and-404-on-mismatch check itself; nothing here does it for them.

Reference implementation

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

  • alchemy_labs/backend/services/commerce/checkout_service.py (267 lines, whole file in scope): module docstring (1 to 13); constants (29 to 34); create_checkout (37 to 247, its own docstring 44 to 73); build_download_url (250 to 267).
  • alchemy_labs/backend/api/routers/commerce_router.py (1738 lines): public_product_availability (876 to 893, cited for context only, not this recipe's subject); commerce_checkout (896 to 904).
  • alchemy_labs/backend/schemas/commerce_schemas.py (477 lines): ProductBase billing-model fields (20 to 37, validators 63 to 87, cited for context - product-catalog-crud owns writing them); _TRACKING_KEYS/_TRACKING_MAX_VALUE_LEN (365 to 372); CheckoutRequest (374 to 414); CheckoutResponse (417 to 421).
  • alchemy_labs/backend/services/commerce/capacity.py (whole file in scope for the checkout-time guard): module docstring (1 to 17); SOLD_OUT_CODE/SOLD_OUT_DETAIL (31 to 32); product_capacity (35 to 46); order_quantity (49 to 55); seats_taken (58 to 86); availability (89 to 104, cited for context, owned by the sibling GET endpoint, not this recipe).
  • alchemy_labs/backend/services/payrexx_service.py (527 lines, cited, owned by payrexx-order-settlement): for_workspace (94 to 116); create_gateway (132 to 231); create_tokenization_gateway (233 to 294).
  • alchemy_labs/backend/models/commerce_models.py (270 lines, cited, owned by product-catalog-crud): Product (20 to 107); Order (110 to 157); DownloadGrant (160 to 184).
  • alchemy_labs/backend/app_fastapi.py: the router mount (838).
  • alchemy_labs/backend/migrations/versions/20260609_0001_commerce_kind_infoproducts.py, 20260610_0004_commerce_subscriptions.py.
  • alchemy_labs/frontend/src/pages/commerce/BuyPage.tsx (475 lines, cited for the Frontend section): module docstring (1 to 14).
  • alchemy_labs/frontend/src/services/commerceService.ts (620 lines, cited for the Frontend section): checkout (579 to 619).
  • 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 two stated gaps).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: FILE_TOKEN_SECRET ships a public, hardcoded default (Gotchas); the tokenization checkout branch has no test in the designated source file (Tests); the cross-workspace child-id pattern is a comment, not code (Gotchas); no observed evidence either way on whether a Payrexx outage after order-creation leaves stuck pending rows that nothing sweeps.

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); run tests ONE AT A TIME (-k a single name) if the scratch Postgres is shared with another concurrent session, per the Tests section's stated gap.

  1. python -m pytest tests/test_commerce_phase1.py -q -k "checkout or capacity or unlimited" runs test_checkout_amount_server_derived, test_checkout_free_short_circuit, test_checkout_missing_product_404, test_checkout_inactive_product_404, test_checkout_blocked_at_capacity_returns_409_sold_out, test_unlimited_product_unaffected_by_capacity (and test_availability_counts_paid_and_live_pending under the "capacity" match, adjacent but out of this recipe's own scope) - expect all passing against an uncontended database (exact count not independently re-verified end to end in this authoring session; treat this as the command to run, per the Tests section's stated gap, not a pre-verified pass count).
  2. grep -n "^def create_checkout" backend/services/commerce/checkout_service.py prints line 37.
  3. grep -n "^async def commerce_checkout\|^def commerce_checkout" backend/api/routers/commerce_router.py prints line 897 (as def commerce_checkout, a synchronous handler, not async def).
  4. grep -n "price\|amount" backend/schemas/commerce_schemas.py | grep -A0 "class CheckoutRequest" finds no line - confirms CheckoutRequest (374 to 414) declares no price/amount field of any name.
  5. grep -n "chargeOnAuthorization" backend/services/payrexx_service.py prints one line (267), gated if amount > 0, confirming a CHF-0 intro_then_sub tokenizes without charging.
  6. grep -c "^def test_checkout" backend/tests/test_commerce_phase1.py prints 4 (test_checkout_amount_server_derived, test_checkout_free_short_circuit, test_checkout_missing_product_404, test_checkout_inactive_product_404); grep -n "create_tokenization_gateway" backend/tests/test_commerce_phase1.py prints no match, confirming the first stated Tests gap.

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.