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 |
|
| 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
| Route | Auth | Tenancy | Body | Codes | Notes |
|---|---|---|---|---|---|
POST /api/commerce/checkout (commerce_router.py 896 to 904) | none - public, unauthenticated, no VerifiedUser/WorkspaceID dependency | none 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:
| Function | Owns its own transaction | Raises | Returns |
|---|---|---|---|
create_checkout(db, payload, *, extra_order_metadata=None, tokenize=False) (checkout_service.py 37 to 247) | yes, one db.commit() per branch | HTTPException(404) (no/inactive product), HTTPException(409) (sold out); Payrexx errors propagate as HTTPException(500|503) from _make_request, uncaught here | dict shaped as CheckoutResponse |
Application logic
create_checkout, in commit order, mirroring the source's own numbered comments (checkout_service.py 37 to 247):
- 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_idis the ONLY source of tenancy for everything that follows. - 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,qtyforced to 1 (may be 0 = tokenize without charging);subscription->amount_cents = product.recurring_price_cents or product.price_cents(the full first period),qtyforced to 1; anything else (one_time) ->amount_cents = product.price_cents * qty, whereqty = max(1, min(payload.quantity, MAX_QUANTITY=100))(29, 84). - 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. - Capacity guard (109 to 120), BEFORE any order or gateway:
product_capacity(product)readsproduct.extra_metadata["capacity"](capacity.py);None= unlimited, unaffected. If set,seats_taken(db, product.id) + qty > capacity->HTTPException(409, SOLD_OUT_DETAIL);seats_takencounts confirmed (paid/paid_oversold) orders plus live pending ones (created withinPENDING_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. - Create the pending Order (122 to 142):
workspace_id/product_id/customer_email/customer_name/amount_cents/currencyfrom 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) populatesorder.idbefore it is used as the PayrexxreferenceId. - 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. - Tokenization branch (149 to 186): taken when
tokenizeisTrue(never true from this public route) ORproduct.billing_model in ("intro_then_sub", "subscription"). Openspx.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=1banks a reusable token,chargeOnAuthorization=1also captures now but ONLY whenamount > 0(payrexx_service.py266 to 267), so anintro_then_subproduct with a CHF-0 intro tokenizes without charging rather than taking the CHF-0 fulfill short-circuit below. Persistsorder.payrexx_gateway_id/order.checkout_url, onedb.commit()(178 to 180), returns{order_id, status: "pending", checkout_url, download_url: None}(181 to 186). - CHF-0 short-circuit (188 to 221), reached only for
one_timebilling (the two recurring models were already routed through step 7 above, even at amount 0): ifamount_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), thendb.commit()(202). For aninfoproduct, theDownloadGrantfulfill_orderminted is looked up and turned into a signed URL viabuild_download_url(204 to 214); returns{order_id, status: "paid", checkout_url: None, download_url}(216 to 221). - 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)})- nopreAuthorization, no token banked. - Persist and return (235 to 247):
order.payrexx_gateway_id/order.checkout_urlset 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 -CheckoutRequesthas no price field at all (test_checkout_amount_server_derived: a body claimingamount_cents: 1against 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_outasserts zero new orders and zero gateway calls on a 409). extra_order_metadata(server-side only) is merged intoorder.extra_metadataAFTER the body-derived keys, so a crafted request body cannot override a server-established fact (docstring 53 to 55,checkout_service.py139) - untested by name intest_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_urla 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.py33 to 34):FILE_TOKEN_SECRETdefaults to the literal string"your-secret-key-change-in-production"when the environment variable is unset - the exact same defaultapi/files/download_router.py:20andservices/commerce/fulfillment_service.pyuse, deliberately mirrored so all three can verify each other's tokens. Whether production actually setsFILE_TOKEN_SECRETis 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.py132 to 156): themock_payrexxfixture stubscreate_gatewayonly, notcreate_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.pycollided 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.py102 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):ProductBasebilling-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 bypayrexx-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 byproduct-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.
python -m pytest tests/test_commerce_phase1.py -q -k "checkout or capacity or unlimited"runstest_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(andtest_availability_counts_paid_and_live_pendingunder 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).grep -n "^def create_checkout" backend/services/commerce/checkout_service.pyprints line37.grep -n "^async def commerce_checkout\|^def commerce_checkout" backend/api/routers/commerce_router.pyprints line897(asdef commerce_checkout, a synchronous handler, notasync def).grep -n "price\|amount" backend/schemas/commerce_schemas.py | grep -A0 "class CheckoutRequest"finds no line - confirmsCheckoutRequest(374 to 414) declares no price/amount field of any name.grep -n "chargeOnAuthorization" backend/services/payrexx_service.pyprints one line (267), gatedif amount > 0, confirming a CHF-0intro_then_subtokenizes without charging.grep -c "^def test_checkout" backend/tests/test_commerce_phase1.pyprints4(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.pyprints 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.