Commerce
Commerce
Generated from COMMERCE.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.
First-class ecommerce: products, brands, product categories, orders, customers — across all storage drivers, with plugin hooks for everything beyond the basics.
Local dev — full shop in 4 commands
# 1. Backend (this repo)
npm install
npm run import:woo -- data/import.example --apply # synthetic demo catalogue (idempotent)
node scripts/mint-storefront-key.mjs "../my-storefront/.env.local"
npm run dev # → http://localhost:4321
# 2. Storefront (your own app — new terminal)
npm install && npm run dev # → http://localhost:3000 (or next free port)
The order of those two scripts matters, and so does --apply:
--applyis not optional. Without it the importer rehearses: it prints the plan and the records it would skip, and writes nothing.- The import is what creates the database.
mint-storefront-key.mjsreadsdb.jsondirectly and fails if it is not there; the import (like any first run) creates it, along with the bootstrap administrator below. On a libSQL or relational install there is nodb.jsonat all — the script refuses rather than writing one nothing reads, and you mint the key from Admin → API keys instead. - The shop is OFF on a fresh install.
commerce_enabledis absent, which means off, so/api/productsand/api/ordersanswer 404 to anonymous callers — deliberately, with the same body and headers as a genuinely unknown path, so an install that does not sell does not advertise that it could. Importing products or orders turns the shop on and says so; without an import, switch it on at Admin → Settings → Shop → Selling. Staff screens work either way, so a catalogue can be stocked before opening. - The mint script writes
ASTROBAAS_URL+ASTROBAAS_KEYinto whatever env file you point it at, and prints the key once. Read those two variables from your storefront’s own configuration.
Admin
- URL:
http://localhost:4321/admin - Bootstrap login: admin@local / admin — change it immediately (Admin → Users), then enable 2FA (TOTP) on your account.
- Screens: Dashboard · Posts (blog) · Products · Orders · Customers · Media · Users · Messages · Plugins · API keys · Webhooks · Audit log.
API surface
See /llms.txt and /openapi.json on a running instance. Highlights:
GET /api/products(public;?category= ?brand= ?search= ?on_sale=+ pagination).?sort=orders the whole result before it is paged:relevance(default),price_asc,price_desc,newest,name_asc,name_desc. Price is what a buyer is charged — the sale price while a sale is live, a variable product’s cheapest enabled variant. That figure is on every product response asprice_from_cents(withprice_to_cents, the dearest), so a client can show “from €X” and verify the order from the response itself. Ties keep relevance order, so paging is stable. An unknown value is a 422, and the order applied comes back inmeta.sort. Typo tolerance and facets are the paid search module’s; sorting works the same with or without it.?category=clothingincludes every subcategory beneath Clothing.GET /api/products/{ref}(public; id or slug) — active products only. Catalogue staff (admin, editor, manager, or an API key with such a role whose scopes, if it has any, includeproducts) get draft and archived products too when they add?all=true, here and onGET /api/products?all=true. Everyone else, and staff without?all=true, gets 404 for those, so a staff client must pass it (products.get(ref, { all: true })). Staff responses areprivate, no-store.GET /api/product-categories(public) — each category withparent_slug,product_count(tagged directly) andproduct_count_total(with every subcategory).POSTcreates,PUT /api/product-categories/{id}renames, moves and reorders,DELETEremoves (refused while it has subcategories; products are untagged, never deleted). A parent must exist, a category cannot sit inside its own subtree, and the tree is at most 5 levels deep. The slug is fixed once created — products are filed under it. All of this is also the Product categories screen in the admin.POST /api/product-bulk— change many products at once: status, featured, categories, tags, price (set / ± percent / ± amount on the regular price), sale (percent off / end), stock. Previews unlessapply: true, and applies through the ordinary product save, so webhooks, price history and the audit log see every product. A flat price is refused for products whose variants carry their own prices. In the admin: tick products in the list (or “Select all N matching” the search), queue one or more changes, optionally “Only where…” a condition, Preview, Apply. Every applied change can be undone from Recent bulk changes — only fields that still hold the change’s value are put back, so stock sold since is never returned.POST /api/product-bulk/csv— the same, from a spreadsheet: one row per product or variant (a variant’s SKU updates that variant), columnsprice,sale_price,stock,status,featured; an empty cell changes nothing, and a file with any bad row is refused as a whole.GET /api/product-bulk/templatedownloads the current values in that format.GET /api/search/suggest?q=(public) — a few matching products, categories, posts and pages for a search box that suggests as the shopper types. Products follow their catalogue visibility (searchappears,catalogandhiddendo not).POST /api/orders— checkout; anonymous same-origin (CSRF) or bearer key; prices/totals computed server-side, stock decremented, customer auto-created Optionalterms_accepted/privacy_acceptedbooleans are recorded on the order; see “Terms & Conditions at checkout”.- Staff-only (PII):
GET /api/orders(?tag=filters by staff tag; see Order tags),GET /api/customers(?search=; paged like orders:?limit=default 50, max 200,?offset=) - API-key scopes:
posts|content|media|products|orders|customers|messages : read|write|*(SCOPED_RESOURCESinsrc/lib/api-key-scopes.ts; a key with no scopes is governed by its role alone)
Extension points (plugins)
Filters/actions in PLUGIN_HOOKS: before/after_product_save,
after_product_delete, product_price (sale rules, member pricing),
before_order_save, after_order_create (emails, ERP sync),
after_order_status_change. Webhook events: product.*, order.*,
customer.created.
Money
Integer cents everywhere. No floats in commerce code — house rule.
Production notes
- Set
DATABASE_URL=file:./data/astrobaas.db(libSQL) orDATABASE_DRIVER=relationalfor multi-writer (see STORAGE.md). CORS_ORIGINSmust include the storefront origin.- Product images are published against a media base, which is resolved
from the
public_site_url/site_urlsettings, thenSITE_URL, then the request’s own origin (src/lib/media-base.ts). There is noMEDIA_BASEenvironment variable. When nothing usable is configured the API publishes the relative path only, rather than inventing an origin. - To bring a dump’s images in, pass
--media <dir>to the importer (the extractedwp-content/uploads). Each file goes through the same ingest pipeline an upload uses — sniffing, EXIF strip, derivatives — and the products are re-pointed at the stored copies. Storage is content-addressed, so re-running it is harmless.
Inventory guarantees
Checkout reserves stock atomically. The availability check and the decrement
are a single step — one mutex hold on the lowdb driver, a conditional
UPDATE … WHERE stock >= ? on the relational driver — so two concurrent
checkouts for the last unit cannot both succeed. (Checking stock and then
decrementing as separate awaits is a TOCTOU race: it oversells, and it did.)
- If a later line in a multi-item order fails, earlier reservations are rolled back, so a failed checkout never strands inventory.
stock: nullmeans untracked — always purchasable, never decremented.- Moving an order into
cancelled/refundedreturns its stock; moving it out re-takes it, and the reopen is refused with 409 if the stock is gone. The move is claimed atomically from the status it was decided on (transitionOrderStatus), and only the request that wins the claim moves stock — so re-cancelling can’t credit twice even when the two cancels arrive at the same moment (a double-click, an admin cancel meeting a refund webhook). The loser answers what a second request in sequence would: 200 with the order for a repeat, the state machine’s 409 for a contradiction.
The smoke suite asserts all of this against all three storage drivers, including firing six concurrent orders at a stock-of-one product and requiring exactly one to win.
Stock status (the “In stock / Low stock / Out of stock” badge)
Every product the public API returns carries a read-only stock_status, and
GET /api/products/{ref}/stock-status returns the same object for one product:
{ "status": "low-stock", "tracked": true, "available_qty": 3,
"threshold_qty": 5, "backorderable": false, "purchasable": true }
statusisout-of-stockat a count of 0 or less (whatever the threshold),low-stockwhen0 < count <= threshold_qty, andin-stockabove it. A product with no count (stock: null) is alwaysin-stock.threshold_qtyis the product’s own Low-stock threshold when it has one, otherwise the shop-wide default — Settings → Shop → Inventory, setting keyinventory_low_stock_threshold_qty, default 5. A threshold of0means “never show low stock”.purchasableis the checkout’s own answer for one unit, so a product that is out of stock but takes backorders reportsout-of-stockwithbackorderable: true, purchasable: true— show “Out of stock – backorder available” rather than hiding the buy button.- A product with variants is judged on its enabled variants: their counts are
summed (and the product is untracked if any variant is), it is purchasable when
any variant is, and each
variants[]entry carries its ownstock_status. The storedin_stockflag is not reliable for such products, because it follows the product’s own count, which a variable product does not sell from. available_qtyis the number the product already publishes asstock; the status adds no disclosure.- Derived on every read, never stored. Pull-based: anonymous responses share the public read cache (30 s by default), and nothing is reserved, so the answer can be stale by the time a shopper clicks. Checkout re-checks atomically either way.
Order limits
Checkout is a public, anonymous endpoint, so “how much can one request ask for” is an abuse control, not a UX preference: uncapped, a single request can drain a product’s inventory or inflate an order to an absurd size. Two limits apply, both editable at Admin → Settings → Order limits (no redeploy):
| Setting | Key | Default | Range |
|---|---|---|---|
| Max units of one product per order | order_max_qty_per_product | 3 | 1–1000 |
| Max distinct lines per order | order_max_items_per_order | 50 | 1–200 |
Defaults are deliberately conservative — a shop that wants bulk orders opts in,
rather than every shop being exposed by default. Resolution is pure and clamped
(src/lib/commerce-settings.ts), so a corrupt or
hostile settings row can never widen a limit past its ceiling or disable it: 0
clamps to 1, junk falls back to the default. Exceeding a limit is a 400 and
rolls back any stock already reserved for earlier lines.
Storefronts should read the live values rather than hard-coding 3:
// GET /api/products
{ "data": [ /* … */ ],
"meta": { "max_qty_per_product": 3, "max_items_per_order": 50 } }
Error statuses
Checkout distinguishes the two failure kinds, because they need different client behaviour:
400— the request is wrong (bad payload, unavailable product, limit exceeded). Retrying unchanged will fail again.409 CONFLICT— the request was fine but the world changed: another buyer took the units first. Refresh availability and retry with less.reason: "checkout.promotion_unavailable"means an automatic cart rule the quote applied has been used up meanwhile — re-quote and show the new total.429withreason: "checkout.too_many_unpaid"— this buyer (email, or for an anonymous shopper also the network address) already has the maximum number of unpaid orders waiting. Pay or cancel one first.Retry-Afteris set. See Unpaid orders.403withreason: "checkout.captcha_failed"— the operator switched on proof-of-work for checkout and the request carried no validpow_token.
The specific reason is always in error.reason (with error.params);
error.code stays the HTTP-derived code (BAD_REQUEST, CONFLICT,
RATE_LIMITED, …) it has always been.
Money: VAT, shipping and discounts
What AstroBaaS is NOT
It is not a fiscal device. The shop issues its receipts and τιμολόγια
(invoices) from its own certified fiscal device (ΦΗΜ), which numbers, signs and
reports them. AstroBaaS does not emulate that, does not transmit to myDATA, and
does not number anything as a legal document — /receipt says so in its own
words and is deliberately not called an invoice.
What it owes instead is two things: the correct ΦΠΑ (VAT) on every line, and
the counterparty’s ΑΦΜ (Greek tax id) recorded beside the order
(Address.tax_id), so whoever operates the fiscal equipment has both without
retyping them. That field is capture, not compliance — it is never
checksum-validated and never checked against VIES, because a refusal there would
block an order over a number this system does not act on.
Tax by destination
The rate is chosen by tax class and by where the goods are going, once the
engine is switched on. resolveTaxTreatment decides one treatment for the whole
basket and every line — including the shipping line, because transport ancillary
to a supply of goods follows the goods — is priced under it
(src/lib/commerce/tax.ts, called from pricing-service.ts and totals.ts):
| Treatment | When | Rate |
|---|---|---|
domestic | destination is the shop’s own country, or there is no destination yet, or the engine is off | the shop’s own ladder, by tax class |
destination | EU B2C into another member state (OSS) | that country’s rate for the class |
reverse-charge | EU B2B, customer supplied a VAT id from a different member state | 0% |
export | outside the EU VAT territory | 0% |
not-configured | a rate is owed and none is on file | checkout refuses — see below |
It stays OFF until both shop_country and tax_destination_mode: "oss" are
set. The whole engine is a comparison against where the shop is, so turning it
on without an origin would make every sale look cross-border, and defaulting the
origin would invent a tax position. tax_reverse_charge_enabled switches the
B2B case on; tax_eu_countries seeds the membership list and is editable, so a
country moving in or out does not need a release.
A VAT id is checked by shape only — two letters then 2–12 alphanumerics. Core does not call VIES, does not keep the evidence an audit asks for years later, and does not pretend to: zero-rating on shape alone is a policy an operator turns on deliberately.
The VAT id checkout and POST /api/orders/quote read is billing_address.tax_id,
falling back to the delivery address’s. Greece’s VAT prefix EL is read as its
ISO code GR everywhere a country is compared (the number, the EU list, the
origin, the destination, the rate rows). A plugin can verify the number through
PLUGIN_HOOKS.TAX_ID_VERDICT (for example against VIES): verified allows
reverse charge, invalid and unverified charge VAT, and an unverified number
never turns a sale into a refusal — see “Tax-id verdicts” in
PLUGIN_DEVELOPMENT.md.
The real gap is the rate tables, not the engine. Core ships zero foreign
rates. A member state’s numbers change continuously and getting one wrong is the
merchant’s liability, so maintained tables are a rate pack’s job and what core
owns is the resolution. A destination with no rate for a class on file is
not-configured, and checkout answers 409
reason: "checkout.tax_not_configured", naming the country and the classes to
add. It never falls back: 0% under-charges the buyer’s own tax authority and the
origin rate charges a German buyer Greek VAT, and both look entirely plausible
on the invoice.
A rate pack plugs in through PLUGIN_HOOKS.TAX_RATES (see “Tax rates from a
plugin” in PLUGIN_DEVELOPMENT.md). The operator’s own rows always win over a
pack’s, and the quote, the order and a manual tax override all price with the
same merged table. Foreign rows never reach a domestic sale: the domestic rate,
its fallback to the default class and the override’s class check read only the
shop’s own country’s rows. When shop_country is set and those rows have
neither a product’s class nor the default class, the sale is refused with the
same 409, naming the shop’s own country. A manual override to a tax class is
refused on lines taxed as destination, reverse charge or export; exempt
still applies to any line.
How a total is actually computed
An order total is not the sum of its lines, and on the default install it is
not subtotal − discount + shipping + tax either — that formula counts the
tax twice. It is computed server-side in one place
(calculateTotals, src/lib/commerce/totals.ts) and every part is stored on
the order, so an invoice is reproducible years later.
The order of operations is: line amounts from stored prices → discount, allocated across lines to the cent → tax, on the discounted amounts → shipping, then tax on shipping → total. Tax after discount is the step that matters legally: VAT is due on what the customer actually pays.
What reconciles then depends on the pricing convention. Per line
net + tax === gross exactly under both, because one side is computed and the
other subtracted:
prices_include_tax | total_cents is | tax_cents is |
|---|---|---|
true (the default) | subtotal − discount + shipping | how much of that charge is VAT, extracted from it |
false | subtotal − discount + shipping + tax | VAT added on top |
Worked, at 24% with a €10.00 discount on a €124.00 line and €5.00 shipping — the same basket through both conventions, in cents:
| inclusive (default) | exclusive | |
|---|---|---|
subtotal_cents | 12400 | 12400 |
discount_cents | 1000 | 1000 |
line net + tax = total | 9194 + 2206 = 11400 | 11400 + 2736 = 14136 |
shipping (shipping_cents / its tax / shipping_total_cents) | 500 / 97 / 500 | 500 / 120 / 620 |
tax_cents | 2303 | 2856 |
total_cents | 11900 | 14756 |
In the inclusive column the €5.00 shipping charge is €5.00, with €0.97 of VAT
already inside it. Adding tax_cents to that column gives 14203 — €23.03 more
than the shop charges, which is what the old formula in this file described.
totalsReconcile() asserts the parts against the whole and is exported so both
the tests and the runtime can call it.
One calculation, two callers. POST /api/orders/quote and placeOrder()
both call priceBasket(). Nothing else computes a total. A cart page that ran
its own arithmetic would eventually disagree with the charge, so there is no
second implementation to drift from — the smoke suite asserts the two agree.
VAT
Rates are data, in settings, per tax class. There is no Tax screen in the
admin yet — these are settings rows, written through POST /api/settings/update:
{ "tax_enabled": true, // off unless switched on
"tax_prices_include_tax": true, // EU retail: prices shown incl. VAT
"tax_default_class": "standard",
"tax_shipping_class": "standard",
// The seeded ladder. `country` absent means the shop's own country.
"tax_rates": [
{ "class": "standard", "label": "Standard", "rate_bp": 2400 },
{ "class": "reduced", "label": "Reduced", "rate_bp": 1300 },
{ "class": "super-reduced", "label": "Super-reduced", "rate_bp": 600 },
{ "class": "zero", "label": "Zero-rated", "rate_bp": 0 },
// A foreign row. Core ships none of these; you or a rate pack add them.
{ "class": "standard", "label": "DE standard", "rate_bp": 1900, "country": "DE" },
// A special territory, in the SAME postcode grammar shipping zones use and
// matched by the same function. A postcode row beats a country-wide one.
{ "class": "standard", "label": "Special territory", "rate_bp": 0,
"postcodes": ["630*"] }
],
// Destination resolution. Off unless the first two are BOTH set.
"shop_country": "GR",
"tax_destination_mode": "oss",
"tax_reverse_charge_enabled": true,
"tax_eu_countries": ["AT", "BE", "…"] }
rate_bp is basis points (2400 = 24%), so the rate is an integer like the
money it multiplies. The seeded values are a starting point, not an
assertion about current law — rates change, and which optical goods qualify for
a reduced rate is a question for your accountant.
prices_include_tax: true(the default) EXTRACTS the tax already inside the price. This is the normal EU retail case.falseadds it instead. Getting this backwards is a ~19% error on every order.net + tax === gross, exactly. One side is computed and the other subtracted, so an invoice always reconciles to the charge.- Tax is charged on the DISCOUNTED amount. VAT is due on what the customer actually pays; taxing the list price over-collects on every discounted order.
- Per-product
tax_classandtax_statusare honoured, including WooCommerce’s'shipping'— “shipping only”, meaning the goods are untaxed but the delivery charge is not. - An unknown tax class falls back to the default rate, never to zero: a typo
must not silently stop charging VAT. That fallback is domestic only — a
destinationwith no row for the class is thenot-configuredcase above and must not borrow the origin’s number.
Shipping
Methods live in shippingMethods, with three rate models:
| Rate | Shape |
|---|---|
| flat | {kind:'flat', amount_cents} |
| per weight | {kind:'weight', base_cents, per_kg_cents} — billed per started kilogram, like a courier |
| free over | {kind:'free_over', threshold_cents, otherwise_cents} |
Zones match on country and optional postcode patterns — "84600" exact,
"846*" prefix, "84000-84999" inclusive numeric range. That is not
over-engineering: Greece is one country whose island postcodes carry a
surcharge, and a country-only model forces you to overcharge Athens or
undercharge Rhodes. The most specific matching zone wins, so an island
address is never offered the cheaper mainland rate.
requires_shipping: false (and virtual: true) products are excluded from
weight and, if the whole basket is virtual, skip shipping entirely.
The client sends a method id, never a price. The cost is re-derived from the stored method and the actual basket, and a method that does not serve the destination is refused rather than falling back to free.
Coupons
Five kinds — percent, fixed, bogo50, free_shipping and gift_product —
with an optional minimum subtotal, validity window, total and per-customer usage
limits, single use, product/category/brand restriction, free shipping on top,
and binding to one buyer’s email. Edit them under Shop → Coupons.
| kind | value | effect |
|---|---|---|
percent | basis points (1000 = 10%) | that share of the eligible lines, rounded half-up once |
fixed | minor units | that amount, never more than the eligible lines |
bogo50 | — | pairs of eligible units, the cheaper of each half price (below) |
free_shipping | — | shipping costs 0; nothing off the goods |
gift_product | — | one unit of gift_product_id at price 0, if in stock |
How bogo50 pairs units. Every eligible line is split into its units, all
units are sorted from most to least expensive (ties keep basket order), and they
are paired 1st with 2nd, 3rd with 4th: the second of each pair is half price.
An odd unit left over is not discounted. The half is taken once over the total of
the discounted units, rounded half-up. One unit alone is refused
(coupon.not_applicable), never accepted at a discount of 0 — that would spend
a single-use code on nothing.
Restrictions. product_ids and category_slugs form one filter (a line
matching either qualifies); brands is a second filter that must ALSO hold.
A code whose restrictions exclude every line is coupon.not_applicable.
Add-ons and tax. A coupon discounts the line as priced, add-ons included
(COUPONS_DISCOUNT_ADDONS in line-addons.ts), and is taken off BEFORE tax;
with reverse charge the discounted net is what is charged.
The gift is the last line, coupon_gift: true, price 0, no tax, no share
of the discount. It must be a simple product with a unit free beyond what the
basket already takes; checkout reserves that unit atomically like any other
line, and a cancellation returns it.
Email binding and single use. A coupon with email set validates only when
the quote’s or the order’s email matches it, case-insensitively. single_use
caps total uses at one. usage_limit_per_customer is counted per buyer in the
same atomic claim as the total (keyed by a SHA-256 of the lower-cased address).
When a use is handed back. A use is counted when the order is placed. It is
handed back only when the order is cancelled before it was paid (payment
status anything but paid / refunded): the buyer never had the discount. A
cancellation after payment, and a refund, keep the use. Reopening a cancelled
order counts it again, without a limit check — the order carries the discount
whatever happened to the code meanwhile. Two cancellations at once hand it back
once: the release is recorded on the order in the same write as the status.
Refusal codes. Every refusal carries a stable code (coupon.not_found,
coupon.expired, coupon.not_started, coupon.min_subtotal,
coupon.not_applicable, coupon.email_mismatch, coupon.already_redeemed,
coupon.exhausted) beside the older reason word. Disabled and revoked codes
read as coupon.expired.
Plugin-issued codes. A plugin issues codes through host.coupons.issue()
(see PLUGIN_DEVELOPMENT.md). They are ordinary coupons with a source, checked
by the same rules.
Scope coupons:validate. A storefront key holding only this scope may call
POST /api/orders/quote and nothing else: not POST /api/orders, not
GET /api/coupons. Mint it with POST /api/keys and
scopes: ["coupons:validate"]; the quote itself checks no role, so the role
only matters for whatever else the same key is scoped to.
Rejections carry a reason — but only staff see the specific one.
expired, disabled, not-started, usage-limit-reached and
customer-limit-reached all say the code EXISTS, so an anonymous quote endpoint
answering them told a script which of the codes it tried were real. Anyone who
is not staff — an anonymous cart page, and an API key, which is a storefront
that repeats what it is told — gets reason: "invalid" with the sentence a
missing code gets. The one exception is minimum-not-met, which is kept with
its shortfall_cents because “spend 3 € more” is something a buyer can act on.
Staff previewing a basket from an admin session still see the real reason.
The same rule applies to a refused coupon at checkout: POST /api/orders
answers 400 with reason: "checkout.coupon_invalid", a generic message, and
params.shortfall_cents only for a minimum-spend refusal.
Usage limits are claimed atomically. A use is counted
(Storage.claimCouponUse, a conditional increment that only lands while
used_count < usage_limit) before the order is written, and handed back if the
order is not placed. Two checkouts with a one-use code can no longer both get
it.
A discount is allocated across lines to the cent (largest-remainder), because tax is computed per line and the parts must still sum to the whole.
Quoting
POST /api/orders/quote takes the same body as checkout and creates
nothing — no order, no customer, and no stock reservation. A cart page calls
it on every change, and a quote that held stock would let anyone empty the
catalogue by holding refresh.
Variants
A product with variants is bought THROUGH one; a product without them is
bought directly. Eyewear forced this: every frame ships in several colours and
often several sizes, and modelling those as separate products breaks inventory
(each colour has its own count), search (five near-identical rows) and the
product page (no colour picker).
{ "name": "Aviator", "price_cents": 12000,
"attributes": [{ "name": "Colour", "values": ["Black", "Tortoise"] }],
"variants": [
{ "options": { "Colour": "Black", "Size": "52" }, "stock": 4 },
{ "options": { "Colour": "Tortoise", "Size": "52" }, "stock": 2,
"price_cents": 13900, "sku": "AV-TORT" }
] }
- A variant overrides only what it sets. Price, SKU, barcode, weight and image fall back to the parent, so a shop varying only colour states the price once. Stock never inherits — “how many black ones are left” is the entire question a variant exists to answer.
- Checkout requires a choice.
POST /api/ordersand/quotetake{product_id, variant_id, qty}; a variable product without a validvariant_idis refused rather than defaulted to the first colour. - Stock is reserved atomically per variant on all three drivers — a mutex hold on lowdb, a compare-and-set with retry on SQL. Verified live: six concurrent buyers against a variant with stock 2 produce exactly two orders, and the other variant is untouched.
- Order lines freeze the chosen options. Renaming “Black” to “Matte Black” next year must not change what a customer ordered, and deleting a variant must not make an old order unreadable.
- Variant ids are preserved across edits when the option combination is unchanged, because every historical order line references them.
type is derived (variable when variants exist) and never accepted from a
client — a product claiming to be variable with no variants would be unbuyable.
Abandoned orders
Reserving stock at checkout is what stops two buyers taking the last unit. The
cost is that an order which is never paid holds its reservation — and with
bank-transfer, where nobody clicks anything, it holds it forever, so a shop
slowly runs out of stock it physically has.
An unpaid order is therefore cancelled after 3 days (configurable), which returns its stock through the same path a manual cancel uses. The sweep runs on the scheduler alongside scheduled posts.
| Setting | Default |
|---|---|
orders_abandon_enabled | true |
orders_abandon_after_days | 3 (clamped 1–90) |
The rules are deliberately conservative, because cancelling the wrong order
takes goods back from someone who paid: a paid order is never touched
whatever its age, nor is one a human has moved out of pending, nor one already
closed, nor one whose date will not parse. Those rules are applied again to the
order as it is at the moment of cancelling, with its payment status pinned in
the write — the sweep’s list is read earlier, and a payment or a staff cancel
can land in between. Cancelled orders record
cancelled_reason: "abandoned" and abandoned_at, so an operator can tell an
abandonment from a customer changing their mind.
Unpaid orders: the payment hold and the per-buyer cap
Checkout is public and anonymous, and every order it accepts takes stock off the shelf until it is paid or cancelled. Three days is right for a bank transfer and absurd for a card, so a script could empty a catalogue for days. Two controls, both in Admin → Settings → Unpaid orders:
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Payment hold | orders_payment_hold_minutes | 120 | Unpaid ONLINE-payment orders are cancelled after this, and their stock returned. 0 = off. Otherwise clamped 30–1440. |
| Unpaid orders per buyer | orders_max_unpaid_per_buyer | 5 | Open unpaid orders one buyer may hold. 0 = off. |
The hold applies to orders whose payment method is an online provider —
every id in the payment registry, so a plugin gateway is included. Bank
transfer and cash on delivery keep the day-based sweep above. A separate
scheduler sweep cancels them through the same setOrderStatus path (the
decision re-checked on the order at the moment of the write, the payment status
pinned), records cancelled_reason: "hold-expired" and hold_expired_at, and
audits order.hold_expired.
The clock starts when the order is placed. Re-opening the payment page does not
restart it. An order placed with a manual method and then sent to a provider is
different: its clock starts at its first payment start (payment_started_at).
Once the hold is over, POST /api/payments/start answers 409
reason: "payment.window_closed" rather than open a page for an order about to
be cancelled.
The hold matches the provider session:
| Provider | How its page closes |
|---|---|
| Stripe | The Checkout Session is created with expires_at = hold end, clamped to Stripe’s 30 min – 24 h window. That is why the hold is clamped to the same range. The accepted expiry is stored as payment_expires_at, and the sweep waits for it plus 5 minutes for the last webhook. |
| PayPal | An Orders v2 order stays payable for 3 hours (PayPal’s default; only PayPal can extend it, up to 72 h). The create call has no expiry parameter, so a hold under 3 h can be outlived by the PayPal page. PayPal’s approval moves no money. AstroBaaS captures it, and only after taking back the stock of an order the hold already cancelled. It never captures when that stock is gone, or when staff cancelled the order (PAYMENTS.md, “PayPal: approve, then capture”). |
| Klarna (HPP) | The payment session lasts 48 h and the hosted page closes 1 h before that. No expiry parameter either. |
A payment that arrives after the order was cancelled is not dropped. The
order is reopened, and its stock taken again, when the stock is still there.
Otherwise the order stays cancelled. It records payment_status: "paid" and
needs_refund: true, writes a payment.needs_refund audit entry, and the owner
gets an email (the sale-notification recipients, else admin_email). The
admin order list shows a “Needs refund” badge, and the dashboard shows a
“Needs fixing” card that cannot be dismissed. A full refund clears the flag.
Nothing ships without stock behind it.
The cap counts open unpaid orders (pending, or risk-held) twice:
- by the normalised email, over the abandonment window;
- separately, by the hashed client address order-risk already stores, over a shorter window: the payment hold (at least an hour; two hours when the hold is off). Many real buyers share one address — an office, and above all a mobile carrier’s NAT — so the per-address count only catches bursts.
The next checkout is 429 checkout.too_many_unpaid.
The per-address count is skipped when the address is loopback, private or
unknown. On a proxy that is not trusted (TRUST_PROXY unset), or a container
bridge, that is what every shopper looks like, and counting it would refuse the
whole shop after five open orders. Set TRUST_PROXY=1 behind a reverse
proxy. Behind a CDN, also restore the real client IP (deploy/README.md);
otherwise the count is per CDN edge address.
The cap applies to:
- anonymous shoppers: by email and by address;
- API-key callers (a storefront’s server): by email only, because every shopper behind the server shares its address;
- staff sessions (phone orders): not at all.
The check runs before any stock is reserved. It reads one indexed, bounded
window of recent orders (Storage.getRecentOrders), and risk scoring reuses the
same read.
Declined cards
A declined card on Stripe Checkout (payment_intent.payment_failed) is an
attempt. The buyer can try another card on the same page, so it no longer
cancels the order or releases its stock. Before this change, card testing
cycled the shop’s stock. Each decline is counted (payment_declines,
payment_declined_at). At five, the order is flagged with the risk signal
card_testing. The session expiring (checkout.session.expired), and
checkout.session.async_payment_failed, still fail and cancel the order.
Risk hold (opt-in)
Risk scoring flags orders and never refuses them (see
src/lib/commerce/order-risk.ts for why). With
orders_risk_hold_enabled (default off), an order scoring at least
orders_risk_hold_score (default 5, the flag threshold) is placed as
on-hold with risk_held: true and its reasons stored. No buyer is refused
and its stock is held. The buyer can still pay, but a payment does not move
it to processing, so a person looks first. Unpaid, it expires like any
unpaid order, so a flagged hoarder cannot keep the stock.
Idempotency-Key
POST /api/orders accepts an Idempotency-Key header: 1–255 printable ASCII
characters with no spaces (\x21–\x7e, which is what every client library
generates). A space, a tab or an empty value is a 400
IDEMPOTENCY_KEY_INVALID. Keys are scoped to the caller and the route, and
stored only as a hash. The first request claims it durably, on all
three drivers. A retry while that request runs gets 409
IDEMPOTENCY_IN_PROGRESS. A retry after it succeeded gets the same 201 body
with Idempotent-Replayed: true, for 24 h. A refused request (out of stock,
bad coupon, …) releases the key, so fixing the cause and retrying works. The
same key with a different body is 422 IDEMPOTENCY_KEY_REUSED. See
INTEGRATION.md.
Order tags
Staff can label orders (“VIP”, “damaged”, “needs review”) to group them without touching any order field.
- Where it lives.
Order.tags, a list of strings on the order itself; an order with none has notagskey at all. Written only throughsrc/lib/commerce/order-tags.ts— the order timeline and bulk actions are meant to call that module, not re-spell its rules. - What a tag is. Trimmed, whitespace collapsed, hidden and bidi-override
characters removed, at most 40 characters,
<and>refused. “VIP”, “vip” and ” Vip ” are one tag (the first spelling stays). An order holds at most 10; the limit is enforced by the server, not the screen. - Colour is derived from the tag (a fixed palette), so there is no colour field to validate and a tag looks the same everywhere.
- Staff only. No receipt, email, public lookup or checkout response carries
a tag. A data-subject erasure clears them (a tag is free text and may be a
name); the shop’s own
staff_noteis kept. - Concurrency. Adding and removing are decided against the list as it is when written, on all three drivers, so two people tagging one order both land. There is deliberately no “replace the whole list” call.
- API (staff;
ordersAPI-key scope):GET /api/orders?tag=,GET /api/orders/tags,GET|POST /api/orders/{id}/tags,DELETE /api/orders/{id}/tags?tag=,PATCH|DELETE /api/orders/tags. - Global rename / remove rewrite every order that carries the tag, one atomic write per order. They are not one transaction: if interrupted, some orders are renamed and some are not, and running it again finishes.
- Cost. The tag list and counts read every order, like the admin Orders page already does; on a very large shop expect them to be as slow as that page. There is no index on tags.
Order timeline
One chronological answer to “what happened to this order?” — placed, status changes, payments and refunds with amounts, shipping with tracking, staff notes and comments, tag edits, tax overrides. On Admin → Orders, open Timeline on an order.
- Derived where it can be. What an order’s own fields already say (placed, shipped, each refund, tax overrides, abandoned, a declined payment, consent) is computed from those fields every time the timeline is read, so it cannot disagree with them and an order placed before this existed still has it.
- Stored only where nothing else records it.
Order.timelineholds status changes (who, from, to), a provider’s payment verdict with when, staff note edits and comments, tag edits, shipments with the staff member, and anybulk.<action>a later feature records. At most 100 per order, newest kept. Written only throughsrc/lib/commerce/order-timeline.ts(appendOrderTimeline/ the never-throwingrecordOrderEvent); bulk order actions are meant to call that, with an event codebulk.<action>. - No back-fill. Status and payment history starts at the release that added this. An older order shows what its own fields say (placed, shipped, refunds) but not the status moves it made before. The audit log has some of those, but it is capped on the document drivers and not indexed by order, so it is not folded in.
- Staff only. The order endpoints (
GET /api/orders,GET|PUT /api/orders/{id}) striptimeline; the only reader isGET /api/orders/{id}/timeline. No email, receipt, public lookup, checkout response or webhook payload carries it. A data-subject erasure clears the free text (note and comment text, tag names) and keeps the fact that something happened, by whom and when. The data-subject export (/api/privacy/subject) is the person’s own record and includes their orders whole, stored timeline included — as it already includes tags andstaff_note. That is a decision for the shop owner: say if staff notes and comments should be left out of a subject export. - Actors. A status move made by a payment webhook or a sweep has no user
behind it and shows as
system(orsystem:<job>); a provider verdict shows as the payment provider. - A full list evicts context first. At 100 stored entries the oldest comments, note edits, tag edits and bulk lines go before any status move or payment verdict.
- Erasure and the change feed. An erasure scrubs the timeline before it touches the order and then deletes every change-feed row about that order.
- Concurrency. An append is a compare-and-set on all three drivers
(
compareAndSetOrderTimeline); two requests appending at once both land (an append gives up only after ten lost rounds in a row — a comment then answers 409, and an automatic event retries a few more times with a short pause and, if it still cannot, logsLOST <event> on order <id>). An append is bookkeeping, not an edit of the order as the API presents it: it writes no change-feed row and does not bumpupdated_at. A status change is recorded by the request that won the move, just after it: if the process dies in that gap the status has changed and the entry is missing. - API (staff;
ordersAPI-key scope):GET /api/orders/{id}/timeline(?filter=status|payments|shipments|notes|other,?limit=,?offset=,?format=csv),POST /api/orders/{id}/timeline{ text }. - Cost. The timeline is built from the one order; nothing scans other orders or the audit log. The admin screen loads it when a row is opened, 20 at a time.
Downloadable products
A product with Downloadable ticked and at least one entry in Downloads
sells files. Code: src/lib/commerce/download-entitlements.ts.
- Where a file lives. A download’s
urlisprivate:pf_<id>(uploaded with Upload a private file… /POST /api/downloads/upload, kept in the private uploads directory, streamed by the CMS — its location is never sent),https://…(hosted elsewhere: the buyer is redirected there after the checks pass, so they do see that URL), or/…(this site’s public media: anyone with the path can fetch it — use a private file instead). The public catalogue lists only the names; staff see the URLs. - Entitlements. One per order line of a downloadable product, on the order
(
Order.entitlements). Granted when the order reachesprocessingorcompleted(an online payment moves it there; for bank transfer, staff do), revoked when it is cancelled or refunded in full, lifted again if staff reopen it. A staff revocation sticks.on-holdgrants nothing. - Checked live. A link works only while the order is in a granting status, so a status written by some other path still fails closed.
- Limit and expiry. Shop defaults in Settings → Shop → Downloads
(
downloads_limit_default, 0 = unlimited, the default;downloads_expiry_days_default, 90;downloads_link_ttl_hours, 24); each product can override the first two (download_limit,download_expiry_days; blank = default, 0 = unlimited / never). The limit is PER FILE: a limit of 3 lets the buyer fetch each file 3 times. Changing a default or an override affects new entitlements only. An order paid BEFORE this feature, or imported, is granted the first time its buyer (or staff) looks, and its expiry counts from then. - Counting. A download is counted when it is granted, before any byte is sent, with a compare-and-set on the order — two at once never both take the last one. An interrupted transfer uses a download; staff can Reset count. A private file that has gone from disk is refused before it is counted. There is no HTTP Range support, so a download manager that resumes makes a new, counted request.
- Links.
/api/downloads/{token}: HMAC-signed (AUTH_SECRET), naming the order, entitlement and file, never stored. The receipt page mints fresh ones on every view. HEAD checks a link without counting. - Emails. When downloads are granted the buyer is emailed the file names and
a link to their receipt’s downloads section — never a counted link, because
mail scanners fetch every link in a message. The same customer-email switch
as the confirmation (
order_confirmation_enabled) and a 5-per-hour per-address budget apply. - Files are resolved by name. A download uses the product’s current file of the same name (so fixing a broken URL reaches existing buyers) and falls back to the copy taken at purchase (so a deleted product’s buyers keep theirs). Renaming a file therefore points existing buyers at the old copy.
- Not covered: partial refunds do not revoke (revoke by hand); deleting a
private file’s bytes from disk breaks existing entitlements (
404 downloads.file_missing); no licence keys, DRM or streaming. - GDPR. Exported with the order. Erasure removes the IPs from the log and revokes; counts and times stay.
Terms & Conditions at checkout
Core stores the buyer’s answer; the storefront draws the box. (Core has no checkout page: checkout is headless, so there is no theme-owned markup to configure. The label and the link are shop settings, read by the storefront.)
| Setting | Default | What it does |
|---|---|---|
checkout_terms_required | off | When on, POST /api/orders from a shopper or an API key without terms_accepted: true is 400 with reason: checkout.terms_not_accepted, before anything is priced or reserved. |
checkout_terms_label | empty (storefronts show “I agree to the Terms & Conditions”) | The checkbox text. Plain text, at most 200 characters, no < or >; a storefront must render it as text. |
checkout_terms_url | empty | Where the full terms live: a path starting with /, or an https:// address. Nothing else is accepted. |
All three are under “Terms & Conditions at checkout” in the shop settings form, and the storefront reads them from the
public GET /api/settings/get.
Why it is off by default. The requirement could not be on by default
without refusing every checkout of every storefront that has never heard of the
field. Switch it on once your storefront shows the box and sends the answer. The
starter in examples/storefront already does, so on a shop that publishes a
terms link or requires the box, it appears with no code change.
What is recorded on the order (frozen, never retro-fitted):
| Field | Meaning |
|---|---|
terms_accepted | What the buyer sent, as a real boolean. true accepted. false is an explicit “no”, which can only be stored while the requirement is off. Absent means unknown (an order from before this, a Woo import, a staff-placed order, a storefront with no box) and is never read as accepted. |
terms_accepted_at | The server’s clock when it recorded an acceptance. Only with terms_accepted: true. Never read from the request. |
privacy_accepted, privacy_accepted_at | The same for an optional privacy-policy box. Never required. |
A value that is not a boolean ("true", 1) is a 422 on that field, whether
or not the requirement is on. Staff placing an order are never refused for it.
The 201 response carries terms_accepted and terms_accepted_at when they
were recorded. GDPR erasure keeps them: they identify nobody, and they are the
evidence the shop holds the order for.
What it does not do. It records that terms were accepted and when, not which version of the text. Keep the terms page’s own history if you need that.
Campaign attribution
order.attribution records which campaign brought the customer: the first
and the last campaign visit (utm_* tags and Google/Meta/Microsoft ad click
ids), each with its time. Code: src/lib/commerce/attribution.ts.
- Marketing consent or nothing. The CMS’s
/consent.jskeeps the tags in the first-party cookieastrobaas_attributiononly after the visitor grantedmarketing, and deletes it otherwise. The checkout route re-checks the consent cookie before reading it. A headless storefront sendsattributionin the order body and is responsible for its own banner (INTEGRATION.md). - Absent means nothing was kept — no consent, no campaign, an older order,
a staff-placed order, or the switch (
attribution_enabled) is off. Never{}. - Report: Insights → Where paid orders came from, and
GET /api/orders/attribution(JSON orformat=csv). Paid orders only, revenue in the base currency, first- or last-touch. - GDPR: exported with the order; an erasure removes it (the order’s figures
stay). It is part of the order, so it also reaches
order.createdwebhooks.
What it does not do. No referrer or landing URL is stored, no custom parameters, no split credit across several visits (multi-touch), no cross-device matching, and nothing is sent back to the ad platforms. It does not fire a purchase conversion into Google Ads or the Meta Pixel either: the tags load after consent and count page views, but the conversion event on the receipt page is a follow-up.
The order confirmation email
The buyer gets one, and it is on by default (order_confirmation_enabled;
only an explicit false turns it off, for a storefront that sends its own).
This is not the staff sale notification — that one mails the shop and stays off
until an admin sets recipients. The buyer’s version leads with the order number
and what happens next, because that is the question they actually have.
- Bank-transfer details come from the payment method’s own
instructions, not from a setting belonging to this email, so the checkout page and the email cannot disagree about the IBAN. For the built-in manual methods those are thepayment_instructions_bank_transfer/payment_instructions_codsettings; a plugin-declared method supplies its own. With nothing configured the email says a human will follow up rather than promising details it does not have. - At most five confirmations per recipient per hour. Checkout is public and anonymous, so without a budget it is a way to flood somebody else’s inbox. The counter is keyed on a hash of the normalised address — the shared rate-limit store should not double as a list of customer emails — and the log line records the order number, not the address. Like every counter here it fails open: a store outage sends the email.
- A mail failure never fails a checkout. The order is already stored and the customer has already paid or committed to pay. Losing the confirmation is bad; losing the order is worse. Every failure is caught and logged.
The subject is templatable (order_confirmation); the body is not. CR/LF is
stripped from anything that reaches a header, because the order number and the
operator-authored shop title both land in the subject.
Code: src/lib/commerce/order-confirmation.ts, wired in
src/lib/commerce-service.ts.
Invoicing and AADE / myDATA
AstroBaaS does not transmit to myDATA, and deliberately so. Greek e-invoicing is a legal obligation, and an integration nobody has round-tripped against AADE’s own sandbox has no business claiming compliance.
The supported path is the one most small retailers already use: issue the
receipt or invoice on your POS, whose certified fiscal mechanism is what
transmits to AADE. Record its document number against the order in
external_receipt_no so the webshop and the till reconcile.
If you later want automated transmission, do it through an accredited e-invoicing provider (πάροχος) rather than hand-rolling the AADE API.
Invoice and credit-memo records
What core does keep is the record every issuer needs: per order a frozen
invoice record (lines, VAT per rate including shipping, seller, buyer with
tax_id, currency; draft → final, immutable once final), and per refund in
the ledger a credit memo against it. A partial refund is credited across the
record’s lines in proportion, to the cent; the last refund credits exactly what
is left, so a record and its memos net to what the customer kept.
- Created by itself on payment (default; completion counts, for bank transfer
and cash on delivery), on shipping, or only by hand — Settings → Invoices
(
invoice_record_on). - Issued by an issuer plugin (
INVOICE_ISSUERS, see PLUGIN_DEVELOPMENT.md), called once per record. Without one the PDF says it is not a tax document and shows no invoice number; the optional internal reference (invoice_internal_reference_format) is labelled as not an invoice number. - An order with
external_receipt_nogets its record markedissued_externallyand is never sent to an issuer; staff can mark any record that way. - A correction is “credit in full” plus a new record, never an edit.
- A refund made in the provider’s dashboard never reaches the refund ledger;
when the provider’s webhook marks the payment refunded, a memo is made for
whatever the ledger does not cover (
refund_id: "provider-refund"). - A credit memo is issued where its sale was: against a record issued elsewhere it is issued elsewhere too, and against a record this issuer never issued it is not sent (the memo says why).
- Issuer calls from the payment, refund, shipping and status paths run after those paths return, so a slow issuer never holds up a webhook or a checkout.
- An issuer may decline a record (“not mine”). That is not a failure: with
a chosen issuer (
invoice_issuer) the next active one is asked, and a record every issuer declines is treated as with no issuer (internal reference). - A failure the issuer marks retryable, a timeout, or a claim left by a
crash is retried by the scheduler’s sweep, by the same issuer and under the
same key (
astrobaas:<record id>), backing off from 2 minutes to 6 hours, 10 calls at most. A success that lands after the timeout is accepted only while the record is still unissued and still that issuer’s. - Each record line carries its tax treatment, frozen at finalise:
standard,reverse-charge,exportorexempt(a staff override). It is absent when the order cannot say (a 0 % line the engine stamped nothing on). - Creating, finalising or crediting records by hand is admin-only, like refunds.
- An imported order that was already complete gets no record until something happens to it; create one by hand.
Code: src/lib/commerce/invoice-records.ts (write path, issuers),
invoice-records-rules.ts (the arithmetic), invoice-pdf.ts (the PDF).
Known limits (not yet solved)
- Multi-currency is presentation, not settlement. The shop has a base
currency and may quote additional ones at operator-entered rates; the rate is
frozen onto each order so a later change never rewrites what was charged. What
the acquirer actually converts at is its own rate, which this system never
sees —
base_total_centsis an indicative accounting figure, not a settlement. - No cart. By design: a cart is per-visitor UI state and belongs in the
storefront (localStorage/context). Send
{product_id, qty}[]to/api/orders/quotefor totals and to/api/ordersat checkout. - No foreign VAT rate tables. The destination engine itself ships and is
wired into checkout — OSS for EU B2C, 0% reverse charge for an EU business
with a VAT id, 0% export (see Tax by destination).
What core does not ship is a rate table for anywhere but your own country,
because keeping twenty-seven member states’ ladders current is a standing
obligation rather than a constant. A destination with no rate on file stops
checkout with
409 checkout.tax_not_configuredinstead of guessing, so the failure is loud and names what to add. - No lens configurator, and no prescription enforcement in core. Variants cover frame colour/size. Core stores the flags and the Rx and can render one, but the clinical rules that decide whether a prescription is fillable are not in core, and priced lens options are not modelled at all — see Paid vertical modules.
- Coupon usage limits are atomic, per customer too.
used_countand the buyer’s own count are claimed in one conditional write before the order is written. If the process dies between the claim and the order write, one use is counted without an order, which errs against the shop. A per-customer limit is matched on the EMAIL given at checkout: a buyer with two addresses is two customers. - The unpaid cap reads a bounded window. It counts at most the newest 5000 orders in the abandonment window and fails open beyond that. A buyer who rotates both email and network address is not stopped by it; the hold is what bounds how long their orders keep the stock.
- The hold cannot close a PayPal or Klarna page early. A Klarna payment that arrives after the hold reopens the order, or flags it for a refund (see above). A late PayPal approval is captured only if the order can be reopened first; otherwise nothing is taken.
- A PayPal capture that PayPal holds as
PENDING(eCheck, review) is not money yet, and the hold may release the order before it completes. It is then handled as a late payment. replace: trueimport is not transactional — a failure mid-import can leave the catalogue partially wiped. Take a backup first.- Payments are documented separately in PAYMENTS.md. Stripe,
PayPal, and Klarna ship in core;
bank-transferandcodremain available with no configuration. All three providers use hosted checkout, so card data never reaches this server and a self-hosted install stays outside PCI scope. Session creation has not been round-tripped against the providers’ live sandboxes — run yours there before taking real money. - Refunds can be initiated from the admin (
POST /api/orders/{id}/refund, admin-only, partial amounts supported) as well as recognised from a provider webhook. See PAYMENTS.md.
Paid vertical modules
A general-purpose CMS cannot express a lens prescription, and an eyewear shop cannot trade without one. Core’s answer is a seam, not an implementation: the optical module is a paid plugin and is not in this repository, and an install without it behaves like one that never had it.
What core does
- Stores the flags.
requires_prescription: truewithprescription_type: 'spectacles' | 'contacts'on a product, from the product editor or the API. - Stores the prescription and can render one. The
Prescriptiontype andsummarisePrescription()live insrc/core/models.tsdeliberately: the admin must still display an Rx that a past order carries on an install where the module has been removed. That formats data; it decides nothing clinical. Values are integers for the same reason money is — powers in hundredths (SPH -2.25is-225, so a grid check isvalue % 25 === 0rather than a float comparison that is wrong about0.1 + 0.2), PD in tenths of a millimetre (630= 63.0 mm). - Offers two filters.
order_line_extrasruns per line at checkout, before any stock is reserved, and a module returningok: falserefuses the line with its own code and message — so a refusal never holds a lens out of stock for an order that was never going to ship.commerce_schemais what publishes a form’s rules. - Serves the schema routes.
GET /api/commerce/prescription-schema?type=spectacles|contactsandGET /api/commerce/frame-schemaare public and unauthenticated so a headless storefront can render and pre-validate a form. Both answer 404 when no active plugin servescommerce_schema— publishing clinical rules that checkout will not enforce advertises a capability that does not exist. - Lets staff delete one prescription.
DELETE /api/orders/{id}/prescription?line=<n>, admin-only and audited. A prescription is Article 9 health data that a data-subject erasure deliberately retains, because an optician has a professional obligation to keep it. A retention obligation is not forever, so removal is one person, one record, one decision — there is no bulk endpoint.
What core does not do
- It does not enforce. With no module serving
order_line_extras, the initial value passes straight through:requires_prescriptionis a stored flag that nobody checks, checkout accepts the line, and whatever Rx was sent is kept as plain data rather than validated, corrected or rejected. That degradation is the required one — an install that removes the module must not start 500ing on a field it no longer understands. - It does not know what a dioptre is. Which powers are on the grid, whether an axis is required, whether both eyes must be present, what a lab will reject: those are the module’s rules, and core asks one generic question and knows nothing about the answer.
These two seams are not optical-specific. Any vertical with line rules and a form to publish uses the same pair.