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, feature/commerce branch)
npm install
npm run import:woo -- data/import.example # synthetic demo catalogue (idempotent)
node scripts/mint-storefront-key.mjs "../Roza Optics/OptikiGwnia-app/OptikiGwnia/web/.env.local"
npm run dev # → http://localhost:4321
# 2. Storefront (OptikiGwnia/web — new terminal)
npm install && npm run dev # → http://localhost:3000 (or next free port)
The mint script writes ASTROBAAS_URL + ASTROBAAS_KEY into the storefront’s
.env.local, which flips its data seam from WooCommerce to this backend.
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:
products|orders|customers : read|write|*
Extension points (plugins)
Filters/actions in PLUGIN_HOOKS: before/after_product_save,
after_product_delete, product_price (sale rules, member pricing),
before_order_save, after_order_create (emails, ERP sync),
after_order_status_change. Webhook events: product.*, order.*,
customer.created.
Money
Integer cents everywhere. No floats in commerce code — house rule.
Production notes
- Set
DATABASE_URL=file:./data/astrobaas.db(libSQL) orDATABASE_DRIVER=relationalfor multi-writer (see STORAGE.md). CORS_ORIGINSmust include the storefront origin.- Product images are served by the storefront from
/media/uploads/*(extracted from the WP backup intoweb/public/media/uploads). SetMEDIA_BASEwhen importing if they live elsewhere (e.g. a CDN).
Inventory guarantees
Checkout reserves stock atomically. The availability check and the decrement
are a single step — one mutex hold on the lowdb driver, a conditional
UPDATE … WHERE stock >= ? on the relational driver — so two concurrent
checkouts for the last unit cannot both succeed. (Checking stock and then
decrementing as separate awaits is a TOCTOU race: it oversells, and it did.)
- If a later line in a multi-item order fails, earlier reservations are rolled back, so a failed checkout never strands inventory.
stock: nullmeans untracked — always purchasable, never decremented.- Moving an order into
cancelled/refundedreturns its stock; moving it out re-takes it, and the reopen is refused with 409 if the stock is gone. The guard is on the PREVIOUS status, so re-cancelling can’t credit twice.
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), 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.
Money: VAT, shipping and discounts
What AstroBaaS is NOT
It is not a fiscal device. Owner decision, 2026-09-04. The shop issues its
receipts and τιμολόγια from its own certified equipment (ΦΗΜ), 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 ΦΠΑ on every line, and the
counterparty’s ΑΦΜ 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.
VAT is chosen by tax CLASS, not by destination. Correct for a domestic sale;
not correct for EU B2C past the OSS threshold, B2B reverse charge, or export
outside the EU. Multi-currency makes selling abroad easy, and the tax engine
does not yet follow — see lib/commerce/tax.ts for the four cases and the
roadmap for the destination-aware engine.
An order total is no longer the sum of its lines. subtotal − discount + shipping + tax is computed server-side, and the parts are stored on the order
so an invoice is reproducible years later.
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:
// Settings → Tax
{ "tax_enabled": true,
"tax_prices_include_tax": true, // Greek retail: prices shown incl. VAT
"tax_default_class": "standard",
"tax_rates": [
{ "class": "standard", "label": "Standard", "rate_bp": 2400 },
{ "class": "optical", "label": "Optical", "rate_bp": 1300 }
] }
rate_bp is basis points (2400 = 24%), so the rate is an integer like the
money it multiplies. The seeded values are a starting point, not an
assertion about current law — rates change, and which optical goods qualify for
a reduced rate is a question for your accountant.
prices_include_tax: true(the default) EXTRACTS the tax already inside the price. This is the normal EU retail case.falseadds it instead. Getting this backwards is a ~19% error on every order.net + tax === gross, exactly. One side is computed and the other subtracted, so an invoice always reconciles to the charge.- Tax is charged on the DISCOUNTED amount. VAT is due on what the customer actually pays; taxing the list price over-collects on every discounted order.
- Per-product
tax_classandtax_statusare honoured, including WooCommerce’s'shipping'— “shipping only”, meaning the goods are untaxed but the delivery charge is not. - An unknown tax class falls back to the default rate, never to zero: a typo must not silently stop charging VAT.
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 (expired, minimum-not-met,
usage-limit-reached, …) so the storefront can say why instead of “invalid
code”. not-found stays deliberately vague — enumerating codes is how people
find the staff discount.
A discount is allocated across lines to the cent (largest-remainder), because tax is computed per line and the parts must still sum to the whole.
Quoting
POST /api/orders/quote takes the same body as checkout and creates
nothing — no order, no customer, and no stock reservation. A cart page calls
it on every change, and a quote that held stock would let anyone empty the
catalogue by holding refresh.
Variants
A product with variants is bought THROUGH one; a product without them is
bought directly. Eyewear forced this: every frame ships in several colours and
often several sizes, and modelling those as separate products breaks inventory
(each colour has its own count), search (five near-identical rows) and the
product page (no colour picker).
{ "name": "Aviator", "price_cents": 12000,
"attributes": [{ "name": "Colour", "values": ["Black", "Tortoise"] }],
"variants": [
{ "options": { "Colour": "Black", "Size": "52" }, "stock": 4 },
{ "options": { "Colour": "Tortoise", "Size": "52" }, "stock": 2,
"price_cents": 13900, "sku": "AV-TORT" }
] }
- A variant overrides only what it sets. Price, SKU, barcode, weight and image fall back to the parent, so a shop varying only colour states the price once. Stock never inherits — “how many black ones are left” is the entire question a variant exists to answer.
- Checkout requires a choice.
POST /api/ordersand/quotetake{product_id, variant_id, qty}; a variable product without a validvariant_idis refused rather than defaulted to the first colour. - Stock is reserved atomically per variant on all three drivers — a mutex hold on lowdb, a compare-and-set with retry on SQL. Verified live: six concurrent buyers against a variant with stock 2 produce exactly two orders, and the other variant is untouched.
- Order lines freeze the chosen options. Renaming “Black” to “Matte Black” next year must not change what a customer ordered, and deleting a variant must not make an old order unreadable.
- Variant ids are preserved across edits when the option combination is unchanged, because every historical order line references them.
type is derived (variable when variants exist) and never accepted from a
client — a product claiming to be variable with no variants would be unbuyable.
Abandoned orders
Reserving stock at checkout is what stops two buyers taking the last unit. The
cost is that an order which is never paid holds its reservation — and with
bank-transfer, where nobody clicks anything, it holds it forever, so a shop
slowly runs out of stock it physically has.
An unpaid order is therefore cancelled after 3 days (configurable), which returns its stock through the same path a manual cancel uses. The sweep runs on the scheduler alongside scheduled posts.
| Setting | Default |
|---|---|
orders_abandon_enabled | true |
orders_abandon_after_days | 3 (clamped 1–90) |
The rules are deliberately conservative, because cancelling the wrong order
takes goods back from someone who paid: a paid order is never touched
whatever its age, nor is one a human has moved out of pending, nor one already
closed, nor one whose date will not parse. Cancelled orders record
cancelled_reason: "abandoned" and abandoned_at, so an operator can tell an
abandonment from a customer changing their mind.
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_centsis an indicative accounting figure, not a settlement. - No cart. By design: a cart is per-visitor UI state and belongs in the
storefront (localStorage/context). Send
{product_id, qty}[]to/api/orders/quotefor totals and to/api/ordersat checkout. - No tax by destination. One rate table applies to every order; there is no OSS/MOSS cross-border logic or reverse-charge handling for B2B. Correct for a domestic sale and wrong the moment goods cross a border — and multi-currency now makes crossing one easy, so this gap is closer than its roadmap position suggests.
- No lens configurator or prescription capture. Variants cover frame colour/size; an Rx (sphere, cylinder, axis, PD, ADD) and priced lens options are not modelled.
- Coupon redemption counting is best-effort.
used_countis incremented after the order exists, outside the transaction, so a crash between the two loses an increment — one extra redemption, which beats losing the sale. Per-customer limits are exact (they count the customer’s prior orders). replace: trueimport is not transactional — a failure mid-import can leave the catalogue partially wiped. Take a backup first.- Payments are documented separately in PAYMENTS.md. Stripe,
PayPal, and Klarna ship in core;
bank-transferandcodremain available with no configuration. All three providers use hosted checkout, so card data never reaches this server and a self-hosted install stays outside PCI scope. Session creation has not been round-tripped against the providers’ live sandboxes — run yours there before taking real money. - Refunds can be initiated from the admin (
POST /api/orders/{id}/refund, admin-only, partial amounts supported) as well as recognised from a provider webhook. See PAYMENTS.md.
Optical prescriptions
A general-purpose CMS cannot express a lens prescription, and an eyewear shop cannot trade without one. AstroBaaS captures it per line, validates it against clinical rules, and freezes it on the order.
Turn it on per product — Requires a prescription in the product editor, or
requires_prescription: true with prescription_type: 'spectacles' | 'contacts'
via the API. Checkout then refuses that product without a valid Rx, and refuses
a stray Rx on a product that does not take one.
Stored as integers, like money
SPH -2.25 is stored as -225, not -2.25. Lens powers exist only on a
0.25 dioptre grid, so a float lets -2.13 through and no lab can grind it.
Hundredths make the grid check value % 25 === 0 instead of a float comparison
that is wrong about 0.1 + 0.2. PD is tenths of a millimetre (630 = 63.0 mm).
Base curve and diameter are millimetres on a 0.1 grid, not the dioptre grid. Applying the power step to them rejects every real contact lens.
Rules that are not optional
| Rule | Why |
|---|---|
Axis required when cyl ≠ 0 | A cylinder with no axis is an order that will be made wrong. |
Axis refused when cyl = 0 | A stray axis usually means the cylinder was lost in transcription. |
| Axis is 0–180 | A lens meridian repeats every 180°. |
| ADD must be positive | It is a reading addition; a negative one is a sign flip. |
| Both eyes required | “Assume plano for the other eye” is not software’s call. |
| Powers on the 0.25 grid | Anything else is a lab rejection, discovered days later. |
Every error is returned at once with a dotted field path (od.axis), so a
customer fixing a ten-field form is not made to resubmit it ten times.
Free text (notes, issued_by) is refused when over-long, never truncated —
a lab reading an instruction that stops mid-sentence is worse than a customer
being asked to shorten it. A future-dated Rx is refused as a typed year.
Rendering the form
GET /api/commerce/prescription-schema?type=spectacles|contacts publishes the
ranges, steps and rules as data, unauthenticated, so a headless storefront
renders and pre-validates the form without hard-coding optometry. The server
re-validates everything regardless — the schema is a convenience for the client,
never the enforcement point.
Validation runs before stock is reserved, so a refused Rx never holds a lens out of stock for an order that was never going to ship.