Module kit / Commerce and payments
Workshop seat capacity and availability
commerce-payments/workshop-seat-capacity
| Kind | backend |
|---|---|
| Domain | commerce-payments |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, psycopg2-2.9, pydantic-2.12, requests-2.32 |
| Depends on | _shared/fastapi-project-layout, _shared/workspace-scoping, _shared/error-envelope, _shared/alembic-migrations, _shared/test-layout |
| Frontends | commerce-payments/ui/storefront-preview |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
A workshop-type product with a seat cap (product.extra_metadata["capacity"], a bare int) refuses a new checkout once seats are full, so a customer cannot pay for a seat that no longer exists, and an embedding site can poll a public endpoint to render "noch X Plätze" / "Ausgebucht" before a visitor ever reaches checkout. The invariant that makes it trustworthy at Phase-1 scale: the checkout guard counts confirmed orders plus only live pending ones (created within PENDING_TTL_MINUTES = 30, so an abandoned tab cannot squat a seat forever, capacity.py:27) before a Payrexx gateway is ever opened, and the webhook that later confirms payment repeats a confirmed-only version of the same count and lands a confirmation that lands over capacity as paid_oversold rather than silently paid - the module's own docstring is explicit this backstop is best-effort and NOT atomic, not a hard guarantee (capacity.py:1-17).
When to use
Read it before touching product_capacity, order_quantity, seats_taken, or availability (capacity.py); before touching the step-3b guard inside create_checkout (checkout_service.py:109-120); before touching the capacity backstop block inside the Payrexx webhook (commerce_router.py:1665-1685); before changing what counts as "a seat" (order_quantity, defaults to 1, capacity.py:49-55) or what counts as "live" (PENDING_TTL_MINUTES). Trigger phrases: "sold out", "SOLD_OUT", "seat capacity", "ausgebucht", "paid_oversold", "noch X Plätze", "workshop capacity", "overbooked workshop", "why did two people get charged for the last seat".
Not this recipe: the Phase-4 cohort model the module docstring names as the eventual replacement - a Cohort row, FOR UPDATE row locking, and a hard database CHECK backstop (capacity.py:1-6); none of that exists at this commit, this recipe is the interim count-based version only. Also not this recipe: the rest of create_checkout - amount derivation, the CHF-0 short-circuit, tokenization gateways for subscription/intro_then_sub products (checkout_service.py:83-247) - and the rest of the Payrexx webhook - order matching by transaction id vs referenceId, amount/currency verification, fulfillment fan-out, Prima credit delivery (commerce_router.py:1390-1738) - both are read here only for where the capacity guard and backstop sit inside them. Also not this recipe: product.extra_metadata["workshop"] ({capacity, booked}), a completely separate, operator-authored display block the live buy page reads for its own "seats left" widget - see Gotchas, it is NOT this recipe's mechanism and is not kept in sync with it.
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), psycopg2-binary==2.9.10, pydantic==2.12.0 for CheckoutRequest/CheckoutResponse (schemas/commerce_schemas.py:374-422), requests==2.32.5 reached transitively through PayrexxService (services/payrexx_service.py:7) once a checkout under capacity actually opens a gateway. Postgres per _shared/alembic-migrations' own pin. No Redis, no APScheduler, no background job of any kind: both the guard and the backstop run synchronously inside the request that triggers them - there is no reconciliation sweep for a seat oversold once, unlike the retry-job pattern in billing-credits/auto-topup-charge-and-retry.
Foundations assumed: S4 (fastapi-project-layout) for the app and mount_router, which mounts commerce_router unconditionally at app_fastapi.py:838 - grep of the router and the app module for a feature-flag guard on this path found none; S6 (workspace-scoping) for the pattern both public endpoints follow - workspace is resolved from the product row (product.workspace_id) and never accepted from the caller, exactly the rule checkout_service.py's own docstring states (lines 47-48), and _shared/workspace-scoping's own reference list cites checkout_service.py for it; S7 (error-envelope) for the 409 → RESOURCE_CONFLICT (RES_003) mapping the SOLD_OUT response rides on (app_fastapi.py:740, schemas/error_schemas.py:37) - there is no capacity-specific error code, SOLD_OUT is a string prefix inside the generic 409 detail body, by explicit design (capacity.py:29-32, comment: "the global HTTPException handler maps 409 -> RESOURCE_CONFLICT generically, so we encode the specific reason in the detail"); S8 (alembic-migrations) for the one migration that widened ck_orders_status to admit paid_oversold (see Deploy & ops).
No environment variable is specific to this recipe. product_capacity() reads product.extra_metadata["capacity"], a Product row value set by whoever creates/edits the product - there is no config toggle to turn seat capping on or off globally, and no per-product flag beyond the presence of that one key.
Data model
No new table. Capacity lives entirely on two existing tables' JSONB metadata column (Python attribute extra_metadata):
products (
...
metadata jsonb NOT NULL DEFAULT '{}'::jsonb -- capacity lives at metadata->>'capacity' (int); absent, non-numeric,
-- or negative all read as unlimited (`product_capacity`, capacity.py:35-46)
)
CHECK ck_products_kind: kind IN ('product','workshop','infoproduct','appointment','credits') -- (commerce_models.py:97)
-- capacity is NOT gated by `kind`: any product carrying a `capacity` key is capped regardless of kind.
-- 'workshop' is simply the kind this recipe's own tests use (test_commerce_phase1.py:731,760,817);
-- test_unlimited_product_unaffected_by_capacity (line 778) uses kind='product' with no capacity key.
orders (
...
product_id uuid NOT NULL REFERENCES products(id) ON DELETE RESTRICT -- ⚠️ NOT INDEXED (commerce_models.py:116).
-- workspace_id, lead_id, form_submission_id, contact_id, customer_email,
-- payrexx_gateway_id, payrexx_transaction_id and status all carry
-- index=True or an explicit ix_orders_*; product_id has neither, and
-- migrations/versions/20260426_add_commerce_module.py:84-89 (the
-- original index set) never added one either. seats_taken() filters
-- Order.product_id == product_id on every checkout AND every
-- /availability poll (capacity.py:74) with no index backing that predicate.
status varchar(20) NOT NULL DEFAULT 'pending' -- indexed; CHECK ck_orders_status admits 'paid_oversold'
-- (commerce_models.py:153)
created_at timestamptz NOT NULL DEFAULT now() -- the pending-order age clock seats_taken() compares against
-- PENDING_TTL_MINUTES (capacity.py:73,81)
metadata jsonb NOT NULL DEFAULT '{}'::jsonb -- order_quantity() reads metadata->>'quantity' (int, default 1
-- if absent or unparseable, capacity.py:49-55)
)
CHECK ck_orders_status: status IN ('pending','paid','failed','refunded','cancelled','expired','paid_oversold')
-- (commerce_models.py:153)
What is NOT stored: no seats/cohort table, no per-seat row, no waitlist, no record of which named seats a sold-out product handed to which order beyond that order's own quantity. A "seat count" is computed at read time as SUM(orders.metadata->>'quantity') filtered by product_id and status (seats_taken, capacity.py:58-86) - never persisted anywhere as a running counter.
API surface
| Route | Auth | Tenancy | Status codes | Notes |
|---|---|---|---|---|
GET /api/commerce/public/product/{product_id}/availability (commerce_router.py:876-893, handler public_product_availability at line 877) | none - DatabaseSession only, no VerifiedUser/WorkspaceID dependency; docstring says so explicitly ("NO auth, NO WorkspaceID", line 878) | workspace derived from the resolved Product row, never from the caller | 200 {"capacity": int|null, "taken": int, "remaining": int|null, "sold_out": bool}; 404 Product not found if the id doesn't resolve to an is_active=True product | The embedding site's poll target. capacity: null means unlimited (remaining: null, sold_out: false); this is the ONLY endpoint that surfaces the real, order-derived seat count |
POST /api/commerce/checkout (commerce_router.py:896-904, handler commerce_checkout) | none - public, unauthenticated | workspace derived from product.workspace_id inside create_checkout | 200 CheckoutResponse; 404 Product not found; 409 SOLD_OUT: Dieser Workshop ist ausgebucht. (capacity.py:31-32) when the capacity guard trips | Body is CheckoutRequest (schemas/commerce_schemas.py:374-393): product_id, customer_email, quantity (Field(ge=1, le=100), line 378), no price field. The 409 fires BEFORE any Order row or Payrexx gateway is created (step 3b, checkout_service.py:109-120) |
POST /api/orders/webhook/payrexx (commerce_router.py:1390-1738, handler payrexx_webhook) | none required; an optional X-Webhook-Signature/X-Payrexx-Signature is verified IF present AND PAYREXX_WEBHOOK_SECRET is set, otherwise the authoritative re-fetch from zahls is the real proof (lines 1401-1424) | resolved per-order from order.workspace_id, which gates which zahls credential the re-fetch uses (PayrexxService.for_workspace) | 200 always ({"ok": true, ...} variants); 400/502 only on a re-fetch/identity failure unrelated to capacity | Capacity only matters on a new_status == "paid" transition (lines 1651-1685): after the amount/currency check passes, a confirmed-only seat count that would exceed capacity flips new_status from "paid" to "paid_oversold" (line 1685) - the row is still persisted paid (paid_at set, payrexx_transaction_id recorded, lines 1687-1693), just not fulfilled |
The three capacity functions themselves, the actual surface a rebuild reimplements (not routes - called from the router and the service):
| Function | Returns | Notes |
|---|---|---|
product_capacity(product) (capacity.py:35-46) | int or None | None for a missing product, a missing/non-numeric capacity key, or a negative value - all read as unlimited |
seats_taken(db, product_id, *, confirmed_only=False, exclude_order_id=None) (capacity.py:58-86) | int | default counts paid+paid_oversold orders PLUS pending orders newer than PENDING_TTL_MINUTES; confirmed_only=True (used by the webhook) drops the pending half entirely |
availability(db, product) (capacity.py:89-104) | dict | {capacity, taken, remaining, sold_out}; remaining = max(0, cap - taken); the function the /availability route returns verbatim |
Application logic
Checkout guard (create_checkout, step 3b, checkout_service.py:109-120), in commit order:
- Quantity is clamped
qty = max(1, min(payload.quantity, MAX_QUANTITY))whereMAX_QUANTITY = 100(line 29, 84) - this happens BEFORE the capacity check, so the guard evaluates against the same clamped quantity the order will actually carry. product_capacity(product)is read; ifNone(unlimited), the guard is a no-op and execution falls straight through to order creation (line 118).- If capacity is set:
seats_taken(db, product.id) + qty > _capacityraisesHTTPException(409, SOLD_OUT_DETAIL)(lines 119-120) - no row is locked for this read, and nothing is written before the check runs, so a404on a bad product and a409on a full product both leave zero database writes. - Only past that guard does the function proceed to create the
pendingOrderrow (lines 124-142) and, for a paid product, open the Payrexx gateway (lines 223-239).
Webhook backstop (payrexx_webhook, capacity block, commerce_router.py:1665-1685), reached only on a transaction the authoritative zahls re-fetch says is confirmed/completed, only after the amount/currency verification at lines 1651-1663 has already passed, and only for a pending→paid transition (the allowed-transitions state machine at lines 1630-1638 - paid_oversold itself only ever transitions further to refunded):
- Re-load the
Productrow fresh (line 1672) and read itscapacity(line 1673). - If capacity is set, count CONFIRMED seats only (
confirmed_only=True, excluding the very order being confirmed, lines 1675-1677) - pending orders are irrelevant here, only money already captured counts against the cap. - If
confirmed + order_quantity(order) > capacity, log a warning naming the order, product, confirmed count, requested count and cap (lines 1679-1684), and reassignnew_status = "paid_oversold"(line 1685) - this overwrites thenew_statusthe amount check had already validated as"paid". - Either way,
order.status = new_status(line 1687) and, since both"paid"and"paid_oversold"are in("paid", "paid_oversold"),paid_atandpayrexx_transaction_idare recorded for both (lines 1688-1693) - the money-received facts are identical. - Only
new_status == "paid"triggersfulfill_orderand the Purchase CAPI fire (lines 1694-1702);paid_oversoldgets neither. Only("paid", "refunded", "cancelled")trigger the post-commit Prima credit delivery attempt (line 1719);paid_oversoldis excluded from that list too.
Invariants
- A checkout that would push seats past capacity never creates an
Orderrow and never opens a gateway:test_checkout_blocked_at_capacity_returns_409_sold_outassertslen(mock_payrexx) == 0and exactly the two pre-existing orders remain (test_commerce_phase1.py:725-749). availabilitycounts confirmed seats plus only pending orders younger thanPENDING_TTL_MINUTES; an older pending order is excluded as expired (test_availability_counts_paid_and_live_pending,test_commerce_phase1.py:753-774, assertingtaken == 2against one paid + one fresh pending + one 31-minutes-old pending).- A product with no
capacitykey is unaffected regardless of how many orders already exist against it, and a checkout still opens a gateway normally (test_unlimited_product_unaffected_by_capacity,test_commerce_phase1.py:778-803). - A webhook confirmation that would push CONFIRMED seats over capacity is recorded
paid_oversold, neverpaid(test_webhook_over_capacity_marks_paid_oversold,test_commerce_phase1.py:809-830) - the money is captured either way; only the bookkeeping status differs, per the module docstring's own framing ("best-effort, NOT atomic",capacity.py:13). paid_oversoldis a legalorders.statusvalue at the database level, not just in application code:ck_orders_statusadmits it directly (test_order_new_statuses_insert,test_commerce_phase1.py:310-316).- Neither enforcement point takes a row lock (
capacity.py's own module docstring: "No Cohort model, no FOR UPDATE, no hard CHECK backstop - those are Phase 4", lines 4-6) - there is no test in this file that races two concurrent checkouts against the last seat the waybilling-credits/auto-topup-charge-and-retry'stest_two_concurrent_deliveries_of_one_crossing_charge_oncedoes for its own guard; the concurrency safety property this recipe actually has is narrower than that one's, see Gotchas.
Frontend
The module that would render this, by id:
commerce-payments/ui/storefront-preview does not exist yet in this kit at 62ba4fc - required placeholder per
authoring policy (frontend_modules is never empty on a backend recipe).
The nearest existing pattern in alchemy_labs is the live public buy page,
frontend/src/pages/commerce/BuyPage.tsx, mounted at /buy/:productId
(frontend/src/App.tsx:230-237), which fetches the product via
getPublicProduct() (commerceService.ts:326, i.e.
GET /commerce/public/product/{id}) and renders a Buy button that posts
to checkout() (commerceService.ts:579, POST /commerce/checkout) -
but see Gotchas: this page's own "seats left" widget does NOT call this
recipe's /availability endpoint at all.
Minimum admin and debug surface: none dedicated exists at this commit - no admin route lists near-capacity products. The two facts an operator has to find by hand are: SELECT id, product_id, status, created_at FROM orders WHERE status = 'paid_oversold' (every oversold order, each needing a manual refund per the webhook comment at commerce_router.py:1667-1668), and the log line Payrexx webhook: order %s confirmed OVER capacity for product %s (%s confirmed + %s requested > capacity %s) -> paid_oversold; manual refund needed (commerce_router.py:1679-1684), emitted once per oversold confirmation.
Security & tenancy
Both public routes take DatabaseSession only - no VerifiedUser, no WorkspaceID - and are reached by any caller with a valid product_id; both docstrings say so verbatim ("NO auth, NO WorkspaceID", commerce_router.py:855,878). Tenancy is enforced by construction rather than by a dependency: the product query filters Product.id == product_id, Product.is_active.is_(True) (lines 866-869, 885-888), so an inactive or deleted product 404s regardless of workspace, and the workspace a checkout eventually charges against is whatever product.workspace_id says, never a value either endpoint's caller supplies.
Fail direction: closed on the money-moving side (a capacity trip 409s before any order or gateway exists, checkout_service.py:119-120), best-effort/open on the confirmation side (the webhook backstop can only demote an already-captured payment to paid_oversold after the fact - it cannot prevent the charge itself, since by the time it runs Payrexx has already captured funds). No row lock backs either check (see Invariants), so this recipe's actual guarantee is narrower than "never oversold": it is "an overselling event is recorded distinctly and never silently fulfilled", not "an overselling event cannot happen" - the module docstring frames this as the explicit, accepted scope of the interim version pending Phase 4 (capacity.py:1-17).
Attacker-controlled inputs: quantity on CheckoutRequest is bounded 1..100 at the schema layer (schemas/commerce_schemas.py:378) and re-clamped identically in the service (checkout_service.py:29,84) before it ever reaches the capacity arithmetic - a crafted large quantity cannot push the > capacity comparison out of bounds or wrap. No rate limit is specific to either public route (grep of commerce_router.py for a limiter decorator on these handlers: none) - the 409 path is cheap (a COUNT-shaped query, no writes) but is not otherwise throttled beyond whatever applies to the router as a whole. No PII or credential is read or written by anything in this recipe: capacity, taken, remaining, sold_out are all plain integers/booleans.
Tests
test_commerce_phase1.py (831 lines total, 25 def test_ functions per file-wide grep) carries a dedicated section, # 13. Workshop seat-capacity (interim) - checkout guard, availability, webhook (lines 719-830, 4 of the file's 25 tests):
test_checkout_blocked_at_capacity_returns_409_sold_out(@pytest.mark.critical @pytest.mark.payment @pytest.mark.api, line 725) - capacity=2, both seats alreadypaid, a third checkout gets 409 with"SOLD_OUT"in the body, zero Payrexx calls, zero new orders.test_availability_counts_paid_and_live_pending(@pytest.mark.api, line 753) - capacity=5, one paid + one fresh pending + one 31-minute-old pending; assertstaken == 2,remaining == 3.test_unlimited_product_unaffected_by_capacity(@pytest.mark.api, line 778) - nocapacitykey, 3 existing paid orders, availability reportscapacity: null, and a 4th checkout still opens a gateway (mock_payrexxcalled once).test_webhook_over_capacity_marks_paid_oversold(@pytest.mark.critical @pytest.mark.payment @pytest.mark.api, line 809) - capacity=1 already filled, a second order's webhook confirmation lands aspaid_oversold, notpaid.
Plus, outside that section but directly relevant: test_order_new_statuses_insert (@pytest.mark.database, lines 310-316) proves ck_orders_status admits 'expired' and 'paid_oversold' at the database level, independent of any application code path.
Hermetic: mock_payrexx (lines 132-156) stubs PayrexxService.create_gateway, and the autouse _stub_webhook_refetch fixture (lines 114-126) stubs PayrexxService.get_transaction against an in-process _TX_REGISTRY - nothing in this suite touches a real zahls account. Run against tests/conftest.py's standard fixtures (db_session, client, test_workspace_id), not a dedicated conftest.py the way billing-credits/auto-topup-charge-and-retry's prima_credits suite is.
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
python -m pytest tests/test_commerce_phase1.py -q --no-cov -o addopts="" -p no:cacheprovider
Gap, stated plainly: this session attempted the run above against the shared postgres-test scratch database (docker-compose.test.yml, the same container other parallel authoring sessions in this wave are also using) and it did not complete cleanly - psycopg2.errors.UndefinedTable: relation "tracking_contacts" does not exist during a cross-test ALTER TABLE ... DROP CONSTRAINT, a pre-existing dirty-schema state from concurrent use of the shared container, not a failure in this recipe's code. An exact "N passed" count for this file is therefore NOT independently confirmed in this session; the 4 capacity tests and their assertions above are read directly from source, not from a green run. A verifier with exclusive access to the scratch database should get a clean pass and record the real count here.
What a new app adds and this branch lacks: no concurrency test racing two checkouts against the last seat (unlike the auto-topup recipe's own suite); no test asserting /availability's shape when product_id does not exist as a UUID at all (only the 404-for-inactive-or-missing-row path is covered, via the shared _make_product/404 pattern used elsewhere in the file).
Deploy & ops
Config keys: none specific to this recipe (Stack assumptions). Migration: migrations/versions/20260609_0001_commerce_kind_infoproducts.py (revision commerce_kind_infoproducts_001, down-revision review_original_snapshot_001) widened ck_orders_status to admit 'expired' and 'paid_oversold' and widened ck_products_kind to admit 'workshop'/'appointment'/'infoproduct' (module docstring lines 7-10) - this is the only schema change this recipe depends on; the capacity.py module itself shipped in a later, separate commit (6c1a926, 2026-06-17, "feat(commerce): interim workshop seat-capacity") that added no migration at all, since it reads an existing JSONB column.
No scheduler job, no Argus check specific to capacity exists at this commit. A useful one to add: SELECT count(*) FROM orders WHERE status = 'paid_oversold', expected 0 in steady state, the same "needs a human" signal pattern billing-credits/auto-topup-charge-and-retry uses for its own marker rows. Rollback: nothing beyond the code and the one migration; a paid_oversold row is never deleted or auto-reverted by this recipe, so rolling the code back does not need to unwind data - the row just stops being reachable by name until the next deploy.
Gotchas
- 2026-09-12 (measured at 62ba4fc): two unrelated "capacity" concepts coexist on the same
product.extra_metadataobject and are never reconciled. This recipe's real enforcement reads the bare int atmetadata["capacity"](capacity.py:35-46). Separately,product.extra_metadata["workshop"]is an operator-authored object shaped{capacity, booked, ...}that the live public buy page (frontend/src/pages/commerce/BuyPage.tsx:131,233,271-286, mounted at/buy/:productId) reads for its own "Nur noch X von Y Plätzen frei" widget and sold-out button state -PublicProductResponse's own code comment names this explicitly: "the buy page reads glyph/accent (and workshop/infoproduct blocks) out of metadata; it is operator-authored display data, not internal state" (schemas/commerce_schemas.py:228-229), and the frontend adapter round-trips it verbatim (adapter.ts:237,276). A repo-wide grep for a frontend call to/availability(grep -rn "public/product.*availability" frontend/src) returns nothing - the real, order-derived seat count this recipe computes is not displayed anywhere in this repo's own frontend at this commit. A merchant who only fills inworkshop.capacity/workshop.bookedgets a seats-left number that can say anything while the real enforcement (or lack of it, if the separatecapacitykey was never set) runs underneath, invisibly. - 2026-09-12 (measured at 62ba4fc):
orders.product_idcarries no index at all (models/commerce_models.py:116; confirmed against the original index set inmigrations/versions/20260426_add_commerce_module.py:84-89, which indexedworkspace_id,lead_id,form_submission_id,customer_email,payrexx_gateway_idandstatusbut notproduct_id).seats_taken()filters on exactly that unindexed column on every checkout and every/availabilitypoll (capacity.py:74). - 2026-09-12 (measured at 62ba4fc): neither enforcement point takes a row lock - stated as a deliberate Phase-1 limitation in the module's own docstring ("No Cohort model, no FOR UPDATE, no hard CHECK backstop - those are Phase 4",
capacity.py:4-6). Two checkouts racing for the last seat can both pass the guard's> capacitycomparison before either commits; the webhook backstop is what actually catches the resulting overselling after both payments capture, by marking the loserpaid_oversoldrather thanpaid. There is no test in this file that exercises that race directly (Tests). - 2026-09-12 (measured at 62ba4fc): a
paid_oversoldorder captures real money (order.paid_atandorder.payrexx_transaction_idare set identically to a normalpaidorder,commerce_router.py:1688-1693) but receives NO fulfillment, NO Purchase CAPI event, and NO Prima credit delivery (new_status == "paid"gates the first two at lines 1694-1702;("paid", "refunded", "cancelled")gates the third at line 1719 -paid_oversoldis in none of those sets). The only trace is the warning log line atcommerce_router.py:1679-1684and whatever an operator finds by queryingorders.statusdirectly; there is no dedicated admin view (Frontend).
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/commerce/capacity.py(104 lines, whole file in scope): module docstring (1-17);PENDING_TTL_MINUTES,SOLD_OUT_CODE,SOLD_OUT_DETAIL(27-32);product_capacity(35-46);order_quantity(49-55);seats_taken(58-86);availability(89-104).alchemy_labs/backend/services/commerce/checkout_service.py(267 lines):create_checkout(37-247), specifically step 3b (109-120);MAX_QUANTITY = 100(29); quantity clamp (84).alchemy_labs/backend/api/routers/commerce_router.py(1738 lines):public_product_availability(876-893);commerce_checkout(896-904);payrexx_webhook(1390-1738), specifically the status map and allowed-transitions state machine (1596-1647), the amount/currency guard (1649-1663), and the capacity backstop block (1665-1685).alchemy_labs/backend/models/commerce_models.py:Product(extra_metadata/metadatacolumn,ck_products_kind, 86-102);Order(product_idunindexed,status,ck_orders_status, 110-157).alchemy_labs/backend/schemas/commerce_schemas.py:PublicProductResponse(192-243, theworkshopmetadata comment at 228-229);CheckoutRequest/CheckoutResponse(374-422).alchemy_labs/backend/migrations/versions/20260609_0001_commerce_kind_infoproducts.py: theck_orders_status/ck_products_kindwidening this recipe depends on.alchemy_labs/backend/app_fastapi.py:commerce_routermount (838); the globalHTTPExceptionhandler's 409 →RESOURCE_CONFLICTmapping (724-744).alchemy_labs/backend/tests/test_commerce_phase1.py(831 lines): the workshop-capacity section (719-830);test_order_new_statuses_insert(310-316); fixtures_make_product/_make_order/mock_payrexx/_stub_webhook_refetch(52-156).- Frontend, cited for the Gotchas finding, not this recipe's own surface:
alchemy_labs/frontend/src/pages/commerce/BuyPage.tsx(1-286, the seats-left widget at 271-286);alchemy_labs/frontend/src/components/commerce/adapter.ts(237, 276);alchemy_labs/frontend/src/App.tsx(230-237, the/buy/:productIdroute).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: no row lock on either enforcement point (by design, deferred to Phase 4); no index on orders.product_id; no admin surface for paid_oversold orders beyond a hand-run query; the live buy page's own seats-left display is disconnected from this recipe's real availability computation.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc. Checks 1-4 are static and need no database; check 5 needs the scratch Postgres and carries the caveat noted under Tests.
grep -c "^def test_" tests/test_commerce_phase1.pyprints25.sed -n '719,831p' tests/test_commerce_phase1.py | grep -c "^def test_"prints4(the workshop-capacity section).grep -n "paid_oversold" models/commerce_models.pyshows it insideck_orders_statusat line 153.grep -rn "public/product.*availability" ../frontend/src(run fromalchemy_labs/backend) prints nothing - confirms no frontend in this repo consumes the/availabilityroute at this commit.TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db python -m pytest tests/test_commerce_phase1.py -k "capacity or sold_out or oversold or availability" -q --no-cov -o addopts="" -p no:cacheprovidershould print4 passedagainst an exclusively-held scratch database; this session's own attempt hit shared-database contention (Tests) and could not confirm the count independently.
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.