Repository navigation
Commerce
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.
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/productsand/api/ordersanswer 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).
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.
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.
A product with variants is bought through one of them. A product without variants is bought directly.
- 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/ordersand/quotetake{ product_id, variant_id, qty }. A variable product without a validvariant_idis 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.
-
typeis derived (variablewhen variants exist). The API never accepts it from a client.
-
stock: nullmeans untracked: always purchasable, never decremented. - Moving an order into
cancelledorrefundedreturns its stock. Moving it out takes the stock again, and the reopen is refused with409if the stock is gone. - "Tell me when it's back" is
POST /api/products/{ref}/notify-me. Astock_backemail goes out once when the product returns.
| 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).
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):
- Line amounts come from stored prices. A total the client sends is ignored.
- The discount is spread across lines to the cent.
- Tax is charged on the discounted amounts.
- Shipping is added, then tax on shipping.
- 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.
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_classandtax_statusare honoured. - The seeded rates are a starting point, not a statement of current law.
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.
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.
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 isminimum-not-met, which comes withshortfall_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.
The shop has one base currency and may quote others at rates the operator types in. Nothing fetches live rates.
-
GET /api/commerce/currenciesis public. It returnsbase,availableand each enabled rate withrate_ppm,updated_atandstale. -
PUT /api/commerce/currenciesis admin-only. The server stampsupdated_at. - Settings
shop_currency_rate_max_age_hoursandshop_currency_stale_blocks_checkoutcontrol what happens when a rate gets old. - The rate is frozen onto each order.
base_total_centsis an indicative accounting figure, not what the acquirer settled.
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.
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.
| 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.
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_addressandbilling_addressas 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_idrecords a VAT id or Greek ΑΦΜ as given, without checksum validation.
-
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_enabledis on andsale_notify_recipientsis set (up to 10 recipients).
Wording and transports are covered on Email.
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-liband 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.
-
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-KeyonPOST /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.
A storefront on a different origin can place orders straight from the shopper's browser:
- 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. - Send an
Idempotency-Keyper "Place order" click. The typed client does this for you. - If proof-of-work for checkout is on, fetch
/api/captcha/challenge?surface=checkout, solve it and sendpow_token. - 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 devThe 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.
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.
- Payments: providers, webhooks, refunds
- Email: order confirmation wording and mail transport
- Headless-and-REST-API: keys, CORS and the SDK
- Importing: WooCommerce import
- Security: CORS, rate limits, proof-of-work
- COMMERCE.md
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project
{ "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" } ] }