Skip to content

Commerce

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

How the built-in shop works: switching it on, products and variants, pricing, tax, shipping, coupons, checkout, orders and receipts. It is for operators opening a shop and for developers building a storefront against the API.

Commerce is part of the GPL core, not a paid add-on. It is off by default, so a fresh install is a plain site until you switch it on. The full reference is COMMERCE.md; payments have their own page, Payments.

Switching the shop on

The switch is the commerce_enabled setting. Absent means off.

How What happens
Admin → Settings → Selling, tick "This site sells products" The public product, order and payment endpoints start answering. The Shop menu appears in the sidebar.
Import products or orders (npm run import:woo -- <dir> --apply) The import switches the shop on and says so.
Upgrading an install that already had products or orders Migration 12 sets commerce_enabled: true once, only if the setting was never touched. It never writes false.

While the shop is off:

  • GET /api/products and /api/orders answer 404 to anonymous callers, with the same body and headers as a path that does not exist. An install that does not sell does not advertise that it could.
  • Staff screens keep working, so you can stock the catalogue before opening.
  • Turning it off deletes nothing. Products and orders stay in the database.

The value is read strictly: only deliberate spellings of "on" count. A stored string "false" or a corrupt value resolves to off (src/lib/commerce-settings.ts).

Money

All amounts are integer cents (minor units). There are no floats in commerce code. Tax rates are basis points (2400 = 24%), and exchange rates are parts per million.

Products, variants and stock

A product has a price, optional sale price, SKU, stock, weight, images, categories, brand and custom fields. GET /api/products is public and takes ?category=, ?brand=, ?search=, ?featured=, ?on_sale= plus pagination. GET /api/products/{ref} accepts an id or a slug.

Variants

A product with variants is bought through one of them. A product without variants is bought directly.

{ "name": "Aviator", "price_cents": 12000,
  "attributes": [{ "name": "Colour", "values": ["Black", "Tortoise"] }],
  "variants": [
    { "options": { "Colour": "Black" }, "stock": 4 },
    { "options": { "Colour": "Tortoise" }, "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. Stock never inherits.
  • Checkout needs a choice. POST /api/orders and /quote take { product_id, variant_id, qty }. A variable product without a valid variant_id is refused, not defaulted.
  • Order lines freeze the chosen options, so renaming or deleting a variant later does not change an old order.
  • Variant ids are kept across edits when the option combination is unchanged.
  • type is derived (variable when variants exist). The API never accepts it from a client.

Stock rules

  • stock: null means untracked: always purchasable, never decremented.
  • Moving an order into cancelled or refunded returns its stock. Moving it out takes the stock again, and the reopen is refused with 409 if the stock is gone.
  • "Tell me when it's back" is POST /api/products/{ref}/notify-me. A stock_back email goes out once when the product returns.

Categories, brands and collections

Thing Endpoint Notes
Product categories GET /api/product-categories Separate from blog categories. Storefronts route on category slugs.
Brands GET /api/brands Every maker with active products, plus curated brand records.
Automatic collections Rules on a product category Membership is a rule, worked out when the category is read.

Brands. A product's brand is free text, so real catalogues collect spellings. ?brand= matches ignoring case, spacing and punctuation: Ray-Ban, Rayban and RAYBAN return the same products and are listed as one entry. It never merges across a word, so Solano Clips and SOLANO stay apart. Each entry carries a slug and a count. The count is exactly what GET /api/products?brand=<slug> returns. Take slugs from the listing rather than building them yourself. A curated brand record adds a logo, a stable id and translations. See INTEGRATION.md § Brands.

Automatic collections extend ProductCategory rather than adding a new entity. That way coupons, product counts and storefront routing all see them. A rule is match: all | any over at most 10 conditions. It can use fields like price_cents, stock, on_sale, in_stock, featured, categories, tags, brand and custom.<name>. Membership is worked out at read time and never written back to the product (src/lib/commerce/collections.ts).

Pricing and totals

POST /api/orders/quote and POST /api/orders both call one function, priceBasket(), and nothing else computes a total. The quote creates nothing: no order, no customer and no stock reservation. A cart page can call it on every change.

The order of operations (src/lib/commerce/totals.ts):

  1. Line amounts come from stored prices. A total the client sends is ignored.
  2. The discount is spread across lines to the cent.
  3. Tax is charged on the discounted amounts.
  4. Shipping is added, then tax on shipping.
  5. The total.
tax_prices_include_tax total_cents tax_cents
true (default) subtotal − discount + shipping the VAT already inside that charge
false subtotal − discount + shipping + tax VAT added on top

Every part is stored on the order, so an invoice can be reproduced later.

Taxes

VAT is off until tax_enabled is true. Rates are data in settings, one row per tax class. There is no Tax screen in the admin yet, so you write them through POST /api/settings/update:

{ "tax_enabled": true,
  "tax_prices_include_tax": true,
  "tax_default_class": "standard",
  "tax_shipping_class": "standard",
  "tax_rates": [
    { "class": "standard", "label": "Standard", "rate_bp": 2400 },
    { "class": "reduced",  "label": "Reduced",  "rate_bp": 1300 },
    { "class": "standard", "label": "DE standard", "rate_bp": 1900, "country": "DE" }
  ] }
  • An unknown tax class falls back to the default rate, never to zero.
  • Per-product tax_class and tax_status are honoured.
  • The seeded rates are a starting point, not a statement of current law.

Tax by destination

The destination engine stays off until both shop_country and tax_destination_mode: "oss" are set. Then one treatment is chosen for the whole basket, shipping included:

Treatment When Rate
domestic Shipping to the shop's own country, no destination yet, or the engine is off The shop's own rates
destination EU consumer in another member state (OSS) That country's rate for the class
reverse-charge EU business with a VAT id from another member state (tax_reverse_charge_enabled) 0%
export Outside the EU VAT area 0%
not-configured A rate is owed and none is on file Checkout refuses with 409 checkout.tax_not_configured

Core ships no foreign rate tables. Add rows with a country (and optionally postcodes) yourself. VAT ids are checked by shape only; core does not call VIES. tax_eu_countries holds the member list and can be edited.

AstroBaaS is not a fiscal device. It does not number legal invoices and does not send anything to myDATA. It records Address.tax_id and an external_receipt_no so your POS or fiscal equipment can reconcile.

Shipping methods

GET /api/shipping-methods is public. Staff manage methods through the same route. There are three rate models:

Rate Shape
Flat { kind: 'flat', amount_cents }
Per weight { kind: 'weight', base_cents, per_kg_cents }, billed per started kilogram
Free over { kind: 'free_over', threshold_cents, otherwise_cents }

Zones match on country and optional postcode patterns: "84600" (exact), "846*" (prefix) or "84000-84999" (range). The most specific matching zone wins. Products with requires_shipping: false or virtual: true add no weight, and an all-virtual basket skips shipping. The client sends a method id, never a price. A method that does not serve the destination is refused.

Coupons

GET/POST /api/coupons (staff). A coupon can be a percentage or a fixed amount. It can have a minimum subtotal, a validity window, total and per-customer usage limits, product or category restrictions, and free shipping. Automatic cart rules live here too.

  • Anyone who is not staff gets a generic reason: "invalid". That way a script cannot tell a real code from a made-up one. The exception is minimum-not-met, which comes with shortfall_cents.
  • The total usage limit is claimed atomically before the order is written. The per-customer limit is judged from past orders, so two checkouts at the same instant can both pass it.

Currencies and rates

The shop has one base currency and may quote others at rates the operator types in. Nothing fetches live rates.

  • GET /api/commerce/currencies is public. It returns base, available and each enabled rate with rate_ppm, updated_at and stale.
  • PUT /api/commerce/currencies is admin-only. The server stamps updated_at.
  • Settings shop_currency_rate_max_age_hours and shop_currency_stale_blocks_checkout control what happens when a rate gets old.
  • The rate is frozen onto each order. base_total_cents is an indicative accounting figure, not what the acquirer settled.

Carts and checkout

There is no server-side cart. A cart is per-visitor UI state and belongs in the storefront (local storage, context). Send { product_id, variant_id?, qty }[] to /api/orders/quote for totals, then to /api/orders to place the order.

POST /api/orders is public and needs no account. It computes prices on the server, reserves stock atomically and creates the order as pending / unpaid. It also creates the customer record if needed.

Limits, editable at Admin → Settings → Order limits:

Setting Key Default Range
Max units of one product order_max_qty_per_product 3 1–1000
Max distinct lines order_max_items_per_order 50 1–200

Storefronts should read the live values from meta.max_qty_per_product and meta.max_items_per_order on GET /api/products.

Checkout errors:

Status Meaning
400 The request is wrong (bad payload, unavailable product, limit exceeded).
409 Stock was taken first, or checkout.promotion_unavailable, or checkout.tax_not_configured.
429 checkout.too_many_unpaid The buyer already has the maximum number of unpaid orders.
403 checkout.captcha_failed Proof-of-work for checkout is on and pow_token was missing or invalid.

The specific code is in error.reason, with values in error.params.

Orders and status flow

status says whether the shop is working on the order. payment_status says whether the money arrived. They are separate fields.

From Allowed next
pending processing, on-hold, completed, cancelled
processing pending, on-hold, completed, cancelled, refunded
on-hold pending, processing, completed, cancelled
completed refunded, cancelled
cancelled pending, processing
failed pending, cancelled
refunded none (final)

A confirmed payment moves an order to processing, never straight to completed. Fulfilment stays a human decision. Status moves are claimed atomically, so two cancels at the same moment return the stock once (src/lib/commerce/order-status.ts).

Other order routes: POST /api/orders/{id}/ship records tracking and emails the customer once. GET/POST /api/orders/{id}/refund is admin-only (see Payments). POST /api/orders/{id}/tax-override lets staff with the override_tax capability (managers by default) depart from the tax engine's answer. It only appends, and the original breakdown is kept.

Unpaid and abandoned orders

Setting Default Meaning
orders_payment_hold_minutes 120 Unpaid online-payment orders are cancelled after this and their stock returned. 0 turns it off; otherwise 30–1440.
orders_max_unpaid_per_buyer 5 Open unpaid orders one buyer may hold. 0 turns it off.
orders_abandon_enabled / orders_abandon_after_days true / 3 Bank-transfer and cash-on-delivery orders left unpaid are cancelled after this many days.
orders_risk_hold_enabled off Orders scoring at least orders_risk_hold_score are placed on-hold for a person to check.

A paid order is never swept. The per-address part of the unpaid cap is skipped for loopback, private or unknown addresses. Set TRUST_PROXY=1 behind a reverse proxy, or every shopper looks like the proxy.

Customers and addresses

A customer record is created at checkout from the buyer's email. GET /api/customers is staff-only because it holds personal data.

  • A customer has an address book (addresses), capped at 20 saved addresses. The older flat fields (address, city, postcode, country) are kept. Migration 13 copied them into the address book.
  • An order stores shipping_address and billing_address as frozen copies. Editing a saved address later does not change past orders.
  • Addresses are never parsed from a one-line string into fields. A missing field stays missing.
  • Address.tax_id records a VAT id or Greek ΑΦΜ as given, without checksum validation.

Emails to buyers and staff

  • Order confirmation to the buyer is on by default (order_confirmation_enabled). It is limited to five per recipient per hour. Bank-transfer details come from the payment method's own instructions (payment_instructions_bank_transfer, payment_instructions_cod). A mail failure never fails a checkout.
  • Sale notification to staff stays off until sale_notify_enabled is on and sale_notify_recipients is set (up to 10 recipients).

Wording and transports are covered on Email.

Receipts and PDFs

The confirmation email links to a printable receipt at /receipt?token=… and a PDF at /receipt.pdf?token=…. The PDF is built from the same data as the page.

  • The token is signed, names one order and lasts two years. Order numbers and emails never go in the URL.
  • A receipt is refused for an order that has been through a GDPR erasure.
  • The PDF is drawn with pdf-lib and an embedded DejaVu Sans font, so Greek and other non-Latin text renders. No headless browser is involved.
  • It is a commercial receipt, not a fiscal invoice.

Concurrency, stock races and idempotency

  • Stock is reserved atomically. The lowdb driver uses a mutex. The relational driver uses a conditional UPDATE … WHERE stock >= ?. The smoke suite sends six concurrent orders at a stock-of-one product on all three drivers and expects exactly one to win.
  • If a later line fails, stock already reserved for earlier lines is rolled back.
  • Idempotency-Key on POST /api/orders: 1–255 printable ASCII characters with no spaces. Keys are scoped to the caller and stored as a hash.
Answer Meaning
201 + Idempotent-Replayed: true Already placed. The body is the original.
409 IDEMPOTENCY_IN_PROGRESS The first request is still running. Retry with the same key.
422 IDEMPOTENCY_KEY_REUSED Same key, different body.
400 IDEMPOTENCY_KEY_INVALID Malformed key.

Replays are served for 24 hours. A refused order (out of stock, bad coupon) releases the key.

Headless checkout from another origin

A storefront on a different origin can place orders straight from the shopper's browser:

  1. List the storefront origin in CORS_ORIGINS (not *). Cookie-less requests from a listed origin may place orders, start payments and post forms without a CSRF token.
  2. Send an Idempotency-Key per "Place order" click. The typed client does this for you.
  3. If proof-of-work for checkout is on, fetch /api/captcha/challenge?surface=checkout, solve it and send pow_token.
  4. Set Site URL to the storefront's address so payment providers send buyers back there.

A server-rendered storefront (a BFF) should call with an API key instead. Mark that key forward_client_ip and send X-AstroBaaS-Client-IP so rate limits and risk scoring apply per shopper. Local development with a demo catalogue:

npm install
npm run import:woo -- data/import.example --apply
node scripts/mint-storefront-key.mjs "../my-storefront/.env.local"
npm run dev

The mint script works only on the default lowdb (db.json) install. On libSQL, mint the key in Admin → API Keys. See Headless-and-REST-API and INTEGRATION.md § Checkout from a storefront.

Extending commerce

Plugin hooks include before_product_save, after_product_save, after_product_delete, product_price, before_order_save, after_order_create, after_order_status_change, order_line_extras and commerce_schema. Webhook events include product.*, order.* and customer.created. Trade-specific modules (for example optical prescriptions) load through these hooks and are not in this repository. See Plugins and Webhooks.

See also

Clone this wiki locally