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, 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) or DATABASE_DRIVER=relational for multi-writer (see STORAGE.md).
  • CORS_ORIGINS must include the storefront origin.
  • Product images are served by the storefront from /media/uploads/* (extracted from the WP backup into web/public/media/uploads). Set MEDIA_BASE when 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: 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 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):

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.

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

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

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/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. 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_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 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_count is 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: 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.

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

RuleWhy
Axis required when cyl ≠ 0A cylinder with no axis is an order that will be made wrong.
Axis refused when cyl = 0A stray axis usually means the cylinder was lost in transcription.
Axis is 0–180A lens meridian repeats every 180°.
ADD must be positiveIt 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 gridAnything 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.