# AstroBaaS Commerce
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
```bash
# 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)
- `POST /api/orders` — checkout; anonymous same-origin (CSRF) or bearer key;
prices/totals computed server-side, stock decremented, customer auto-created
- Staff-only (PII): `GET /api/orders`, `GET /api/customers`
- 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
`** 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.
## 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`](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`:**
```jsonc
// 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](#unpaid-orders-the-payment-hold-and-the-per-buyer-cap).
- **`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`):
| 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 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.
### 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`:
```jsonc
{ "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:
| 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
Percentage or fixed, with optional minimum subtotal, validity window, total and
per-customer usage limits, product/category restriction, and free shipping.
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).
```jsonc
{ "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.
| 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.
## 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.
## 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](#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](#paid-vertical-modules).
- **Coupon usage limits are atomic; per-customer limits are not.** `used_count`
is claimed atomically 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. `usage_limit_per_customer` is still judged from
the customer's prior orders. Two checkouts by the same customer at the same
instant both see the same count, so that limit can be exceeded by concurrent
orders.
- **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](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.
## 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: 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=`, 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.