AstroBaaS

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:

  • --apply is 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.mjs reads db.json directly 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 no db.json at 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_enabled is absent, which means off, so /api/products and /api/orders answer 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_KEY into 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 as price_from_cents (with price_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 in meta.sort. Typo tolerance and facets are the paid search module’s; sorting works the same with or without it. ?category=clothing includes 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, include products) get draft and archived products too when they add ?all=true, here and on GET /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 are private, no-store.
  • GET /api/product-categories (public) — each category with parent_slug, product_count (tagged directly) and product_count_total (with every subcategory). POST creates, PUT /api/product-categories/{id} renames, moves and reorders, DELETE removes (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 unless apply: 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), columns price, 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/template downloads 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 (search appears, catalog and hidden do not).
  • POST /api/orders — checkout; anonymous same-origin (CSRF) or bearer key; prices/totals computed server-side, stock decremented, customer auto-created Optional terms_accepted / privacy_accepted booleans 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_RESOURCES in src/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) or DATABASE_DRIVER=relational for multi-writer (see STORAGE.md).
  • CORS_ORIGINS must include the storefront origin.
  • Product images are published against a media base, which is resolved from the public_site_url / site_url settings, then SITE_URL, then the request’s own origin (src/lib/media-base.ts). There is no MEDIA_BASE environment 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 extracted wp-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: null means untracked — always purchasable, never decremented.
  • Moving an order into cancelled/refunded returns 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 }
  • status is out-of-stock at a count of 0 or less (whatever the threshold), low-stock when 0 < count <= threshold_qty, and in-stock above it. A product with no count (stock: null) is always in-stock.
  • threshold_qty is the product’s own Low-stock threshold when it has one, otherwise the shop-wide default — Settings → Shop → Inventory, setting key inventory_low_stock_threshold_qty, default 5. A threshold of 0 means “never show low stock”.
  • purchasable is the checkout’s own answer for one unit, so a product that is out of stock but takes backorders reports out-of-stock with backorderable: 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 own stock_status. The stored in_stock flag is not reliable for such products, because it follows the product’s own count, which a variable product does not sell from.
  • available_qty is the number the product already publishes as stock; 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):

SettingKeyDefaultRange
Max units of one product per orderorder_max_qty_per_product31–1000
Max distinct lines per orderorder_max_items_per_order501–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.
  • 429 with reason: "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-After is set. See Unpaid orders.
  • 403 with reason: "checkout.captcha_failed" — the operator switched on proof-of-work for checkout and the request carried no valid pow_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):

TreatmentWhenRate
domesticdestination is the shop’s own country, or there is no destination yet, or the engine is offthe shop’s own ladder, by tax class
destinationEU B2C into another member state (OSS)that country’s rate for the class
reverse-chargeEU B2B, customer supplied a VAT id from a different member state0%
exportoutside the EU VAT territory0%
not-configureda rate is owed and none is on filecheckout 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_taxtotal_cents istax_cents is
true (the default)subtotal − discount + shippinghow much of that charge is VAT, extracted from it
falsesubtotal − discount + shipping + taxVAT 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_cents1240012400
discount_cents10001000
line net + tax = total9194 + 2206 = 1140011400 + 2736 = 14136
shipping (shipping_cents / its tax / shipping_total_cents)500 / 97 / 500500 / 120 / 620
tax_cents23032856
total_cents1190014756

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. false adds 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_class and tax_status are 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 destination with no row for the class is the not-configured case above and must not borrow the origin’s number.

Shipping

Methods live in shippingMethods, with three rate models:

RateShape
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.

kindvalueeffect
percentbasis points (1000 = 10%)that share of the eligible lines, rounded half-up once
fixedminor unitsthat 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/orders and /quote take {product_id, variant_id, qty}; a variable product without a valid variant_id is 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.

SettingDefault
orders_abandon_enabledtrue
orders_abandon_after_days3 (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:

SettingKeyDefaultMeaning
Payment holdorders_payment_hold_minutes120Unpaid ONLINE-payment orders are cancelled after this, and their stock returned. 0 = off. Otherwise clamped 30–1440.
Unpaid orders per buyerorders_max_unpaid_per_buyer5Open 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:

ProviderHow its page closes
StripeThe 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.
PayPalAn 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 no tags key at all. Written only through src/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_note is 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; orders API-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.timeline holds 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 any bulk.<action> a later feature records. At most 100 per order, newest kept. Written only through src/lib/commerce/order-timeline.ts (appendOrderTimeline / the never-throwing recordOrderEvent); bulk order actions are meant to call that, with an event code bulk.<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}) strip timeline; the only reader is GET /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 and staff_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 (or system:<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, logs LOST <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 bump updated_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; orders API-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 url is private: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 reaches processing or completed (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-hold grants 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.)

SettingDefaultWhat it does
checkout_terms_requiredoffWhen 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_labelempty (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_urlemptyWhere 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):

FieldMeaning
terms_acceptedWhat 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_atThe server’s clock when it recorded an acceptance. Only with terms_accepted: true. Never read from the request.
privacy_accepted, privacy_accepted_atThe 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.js keeps the tags in the first-party cookie astrobaas_attribution only after the visitor granted marketing, and deletes it otherwise. The checkout route re-checks the consent cookie before reading it. A headless storefront sends attribution in 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 or format=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.created webhooks.

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 the payment_instructions_bank_transfer / payment_instructions_cod settings; 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_no gets its record marked issued_externally and 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, export or exempt (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_cents is 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/quote for totals and to /api/orders at 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_configured instead 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_count and 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: true import 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-transfer and cod remain 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.

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: true with prescription_type: 'spectacles' | 'contacts' on a product, from the product editor or the API.
  • Stores the prescription and can render one. The Prescription type and summarisePrescription() live in src/core/models.ts deliberately: 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.25 is -225, so a grid check is value % 25 === 0 rather than a float comparison that is wrong about 0.1 + 0.2), PD in tenths of a millimetre (630 = 63.0 mm).
  • Offers two filters. order_line_extras runs per line at checkout, before any stock is reserved, and a module returning ok: false refuses 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_schema is what publishes a form’s rules.
  • Serves the schema routes. GET /api/commerce/prescription-schema?type=spectacles|contacts and GET /api/commerce/frame-schema are public and unauthenticated so a headless storefront can render and pre-validate a form. Both answer 404 when no active plugin serves commerce_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_prescription is 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.