Repository navigation
v0.16.0
Commerce bundles: item kits stocked through their components (issue #290, ADR-0036)
A shop can now sell a set - "two coffees and a syrup at one price" - without a stock counter on the set that somebody keeps in step by hand. A bundle is a product with kind = 'bundle': no variants, no stock of its own, not a service product, not flash-sale eligible, and made of 1-20 component lines (a product, a variant when that product has live variants, and a quantity per bundle). Nesting is refused by trigger, so a cycle cannot exist. Decision and rejected alternatives: docs/adr/0036-bundles-are-component-stocked-products-sold-as-one-line.md.
- New:
sql/953tosql/955underapps/cms-kind/bundle_pricing/bundle_discount_percentonawcms_commerce_products,awcms_commerce_bundle_components(composite tenant FKs, FORCE RLS, the nesting/variant/flash-sale triggers), the append-onlyawcms_commerce_order_item_componentssnapshot, and theawcms_workergrant the expiry restock needs. Existing tenants need no backfill; every product isstandard. - Pricing is
fixed(the product's own price) orderived(the components' list prices x quantity, less a percent, half-up to the cent). Availability ismin floor(component stock / quantity)and is reported as the bundle'sstock, so the storefront, POS search and barcode scan need no new field. - A bundle sells as one order line with an immutable component snapshot (units, text as sold, the line value split across components to the cent). The components - never the bundle - move stock: a counter decrement after locking and re-quoting in
countermode, asaleper component with source line<orderItemId>:c<position>through the inventory adapter inledgermode. Cancel, expiry and a whole-bundle return put exactly those components back from the snapshot; editing the bundle afterwards changes nothing about an old order. Tax is one line by the bundle's own category. - API:
POST/PATCH /api/v1/commerce/productsacceptkind,bundlePricing,bundleDiscountPercentandbundleComponents(ids or SKUs); product reads gainkind,bundlePricing,bundleDiscountPercentandbundle; a cart quote line for a bundle carriesbundle.components. No new permission (commerce.products.*). - Admin: a Bundle toggle, pricing strategy, discount and a
SKU x quantitycontents textarea on the product form. Storefront: an "Isi paket" list on the product page. - Not here (recorded in the ADR): per-component tax classes, partial component return, a bundle report, nested bundles, a cart-level combined component check in
countermode. - Backward compatible. Nothing changes for an existing product or order.
Commerce stock on the upstream inventory ledger (issue #282, ADR-0038)
A tenant's commerce stock was one integer on each product and variant. Upstream AWCMS now ships a multi-location stock ledger (module inventory, embedded by the v10.5.0 sync), and a store can move onto it without a second source of truth: in the new ledger mode the ledger is authoritative and stock becomes a write-through cache of it, so the storefront, cart quote, POS, barcodes and admin lists read exactly what they read before. The default, counter, is today's behaviour byte for byte. Decision and rejected alternatives: docs/adr/0038-commerce-stock-is-a-write-through-cache-of-the-inventory-ledger.md; operator runbook in docs/deployment.md.
- New:
apps/cms/sql/947_awcms_commerce_inventory_adapter.sql—inventory_mode/inventory_location_id(composite FK to the inventory location) /inventory_mode_changed_atonawcms_commerce_store_settings, twocommerce.inventory.{read,configure}permissions, and theawcms_workerinventory grants the expiry job and the cache projector need (a column-levelUPDATE (updated_at)on locations, never a table-level one). Existing tenants runbun run identity-access:permissions:backfillto receive the permissions. - New:
bun run commerce:inventory:cutover --tenant <id> --location <id> [--commit]— dry-run by default; one transaction posts anopeningper stock unit, flips the mode and verifies, behind an exclusive per-tenant mode lock. A second run is refused. - In
ledgermode an order or POS sale postssale, a cancel/expiry restock and a return's restock postsale_return, through the ledger port inside the caller's transaction, sorted against deadlocks and in a savepoint: an out-of-stock cart is still409 CART_CHANGED/PosCartChangedErrorwith nothing written, and eight parallel orders for the last unit sell exactly one. Any other ledger refusal is409 INVENTORY_UNAVAILABLE. - A movement made by something else (a receipt, an adjustment, a transfer) reaches the cache through the new
commerce.inventory_stock_cache_projectorconsumer, which re-reads the ledger and never trusts the payload. This is a recorded local divergence inconsumer-registry.ts(seeAGENTS.md). - A product/variant edit, a create with stock, or a CSV stock change that would change a count is
409 STOCK_MANAGED_BY_INVENTORYinledgermode (the unchanged value is accepted). - New endpoints:
GET /api/v1/commerce/inventory,GET …/inventory/reconciliation,POST …/inventory/resync,POST …/inventory/rollback(commerce.inventory.read/.configure).commercenow declares a dependency oninventory. - Low stock is upstream's
inventory.low_stockprojection, not a commerce report (supersedes the ADR-0035 D1 note for #282). - Backward compatible, opt-in per tenant. Nothing changes until an operator runs the cut-over;
POST …/inventory/rollbackreturns a tenant to the counter.
Procurement feeds commerce stock (issue #283, ADR-0038 addendum)
Upstream awcms's procurement module (suppliers, receiving, supplier returns, requisitions, transfers; upstream ADR-0128) already posts to the inventory ledger. This change makes it work with commerce stock on a tenant in ledger mode, with no new table or migration:
- Convention. A procurement line stocking commerce goods uses
itemTypecommerce.variant(variant uuid) orcommerce.product(uuid of a product with no live variant), unitunit, at the sales location (or another location and then atransfer). NewGET /api/v1/commerce/inventory/items?q=<sku or name>(commerce.inventory.read, keyset-paged, at most 50) resolves a SKU or name to{ itemType, itemRef, sku, name, variantName, unitCode }. - Orphan detection.
GET /api/v1/commerce/inventory/reconciliationnow also returns, on its first page,orphans: non-zerocommerce.*ledger balances at the sales location naming no live unit (not_found,product_has_variants,wrong_unit,unknown_item_type), capped at 100 withtruncated. - Counter mode. A procurement receipt on a
countertenant stays in the ledger and does not change commerce stock - cut over toledgerfirst (runbook indocs/deployment.md; existing tenants runidentity-access:permissions:backfillforprocurement.*). - Reporting. Upstream's
procurement.receiving/procurement.suppliersprojections are the receiving reports (supersedes the ADR-0035 D1 note for #283). - Backward compatible. The
orphansfield is additive; nothing changes for a tenant that never uses procurement.
Commerce tax moves onto the tax module, per tenant (issue #293, ADR-0039)
Until now a store's tax was one number, payment.tax.percent, applied once to subtotal - voucher discount. Upstream AWCMS's generic tax module (awcms ADR-0127, arrived with the v10.5.0 sync) can express what that number cannot — a rate that changes on a date, exempt and zero-rated categories, prices that already include tax, and a refund that takes back exactly the tax that was charged. This change is the adapter, behind a per-tenant mode so nobody moves until an operator moves them: flat (the default) is the existing arithmetic byte for byte, engine asks the module.
- New:
awcms_commerce_store_settings.tax_mode/tax_profile_code,awcms_commerce_products.tax_category_codeandawcms_commerce_orders.tax_snapshot_id(a composite FK to the tax snapshot ledger), all inapps/cms/sql/948_awcms_commerce_tax_adapter.sql— nullable or defaulted, nothing backfilled.commercenow declarestaxin its module dependencies. - In
enginemode the cart, POS and quotation quote use the module's calculator (the store'sAsia/Jakartabusiness date picks the effective-dated version); a voucher is allocated across the lines in cents so, with the same percentage, the engine equals the flat figure exactly — proven by a seeded property test. A storefront or POS order finalises exactly one tax snapshot in its own transaction and stores its id; a return reverses the returned units' tax, and a cancelled or expired order the rest, from the ORIGINAL snapshot, never from today's rule. A tax the engine cannot answer blocks checkout instead of selling untaxed. - New ops-only
bun run commerce:tax:cutover(dry-run by default;--commit,--tenant,--sample,--since,--rollback): derives thestore-defaultversion from the current settings, re-prices the tenant's recent orders in shadow and refuses to flip unless every one matches to the cent. The flip and its rollback are audited (tax_mode.update). Runbook:docs/deployment.md, includingbun run identity-access:permissions:backfillso existing tenants' owners hold thetax.*permissions. - Additive API shapes: the cart quote's
taxgainsmode,inclusive,errorandengine; admin store settingsGETgains the read-onlytaxMode/taxProfileCode; product create/update/admin read gaintaxCategoryCode. The product form has a Tax category field; store settings shows the mode. awcms_workergainsINSERT, UPDATEonawcms_tax_snapshotsso the expiry job can reverse an expired engine order's tax (the append-only trigger still refuses real updates); a settings reset no longer stamps an engine-mode row for purge.- Not here, by decision: no commerce tax report (the module's snapshot ledger and reconciliation report are the tax report, superseding ADR-0035 D1's note for #293); no country profile or rate (a rate change is a new effective-dated version authored in
/admin/tax); no change to refund arithmetic (a return still does not repay tax to the customer — it is reversed in the tax ledger only); Coretax / e-Faktur export is an upstream follow-up.
Sync the AWCMS subtree to v10.5.0 (1558faa)
apps/cms now embeds ahliweb/awcms at v10.5.0 (14 upstream commits since
cfc2df9a): three new upstream modules — inventory (multi-location stock
ledger, sql/169–170), tax (jurisdiction-neutral tax calculation,
sql/171–173) and procurement (supplier, receiving and transfer
documents, sql/174–175) with their admin screens — the Idempotency-Key
header as one shared OpenAPI parameter component, and a focusable named
.data-table-scroll region on every admin table. Merged with a merge commit,
as every subtree sync must be.
- The new modules are available in
apps/cmsbut not yet wired into
commerce; the adapters stay tracked in #282, #293 and #283. - Commerce OpenAPI fragments reference upstream's shared
Idempotency-Key
parameter instead of declaring their own, and every commerce admin data
table gained the focusable named scroll region. - Operator action: run
bun run db:migrate— seven new upstream migrations
(sql/169–175) apply; no renumbering is needed (this repo's media-library
migrations already sit in the880band). apps/cms/scripts/client-asset-budget.ts'sAPP_BUDGET_BYTESis 433,800 B
(measured 433,723 B on the merged build).apps/cms/tests/openapi-bundle.test.ts's idempotency test carries an explicit 60 s
timeout: two bundles of the merged document exceed bun's 5 s default under
the full-suite load.
Public store-settings tax field now discriminates between flat and engine tax modes
Issue #324 — the payment.tax field in the public store-settings DTO (GET /api/v1/commerce/store-settings/public) is now a discriminated union based on the tenant's tax mode (ADR-0039):
- Flat mode (default):
{ mode: "flat", active: boolean, percent: number }— additivemodefield; existing consumers remain unaffected. - Engine mode:
{ mode: "engine", inclusive?: boolean }— omitsactiveandpercentfields, exposes only the mode discriminator.
A storefront that needs to display the tax rate should check the mode field and render the percent only for mode: "flat". The OpenAPI schema for CommerceStoreSettingsPublic is updated to describe this discriminated union.
A return refunds the tax on the returned units (issue #323, ADR-0033 addendum)
A return's refund_total was goods less discount plus shipping, pinned by a CHECK with no tax term. The customer was never given back the tax charged on the units they returned (flat mode), and in engine mode (ADR-0039) the tax ledger reversed an amount the refund money did not include. A return now carries a tax_refund component and refund_total is goods_gross - discount_share + shipping_refund + tax_refund.
- New:
sql/1000_awcms_commerce_returns_tax_refund.sql(the first number of the four-digit continuation band, ADR-0037 amended). It addsawcms_commerce_returns.tax_refund(default0.00, so every existing return stays valid), re-adds the amountsCHECKby name with the extra term, addstax_refundto the immutable columns of the update guard, and makes the insert guard refuse a return that would refund more tax than the order was charged. - Flat mode (and an engine-mode order with no tax snapshot): the order's tax is prorated per unit with the same decomposition as goods and discount (
allocateOrderTaxreusesallocateOrderDiscount; units carry leftover cents first), so the parts of every return of an order add up to the order's tax to the cent and the order's whole total is back once every unit is returned. - Engine mode, exclusive pricing:
tax_refundis exactly the reversal snapshot's tax total, so the money refunded and the tax ledger agree. Inclusive pricing adds nothing (the tax is inside the goods value already refunded); the ledger still reverses it. - The reversal now happens before the refund is planned and, in engine mode, inside a savepoint that a refused refund rolls back, so a refusal leaves no reversal snapshot behind. A replayed
Idempotency-Keystill reverses and refunds nothing twice. - The refund legs, the payment-ledger reversals, the loyalty and affiliate compensations, the sales-report projections and the returns report (
returnedValueequalsrefundedTotal) all followrefund_total; no projection table changed. - API: return records and
awcms.commerce.return.recordedgaintaxRefund;refundTotalnow includes it. Admin: the returns panel shows "Tax refunded". No new client script. - Not changed: the insurance fee is still not refunded;
return_lines.refund_amountstays goods less discount. - Backward compatible for stored data. A client that recomputed
refundTotalas goods - discount + shipping must addtaxRefund.
apps/cms synced to upstream 526b3bbf: four-digit migration prefixes
Issue #329: the embedded AWCMS moves one commit past v10.5.0 to 526b3bbf (ahliweb/awcms#911, upstream ADR-0130). db-migrate now accepts a three- or four-digit prefix and applies files in numeric order. Existing three-digit order is unchanged.
Per ADR-0037 D3, apps/cms/tests/commerce-migrations-range.test.ts widens the commerce band to 900–9999 in the same change. A commerce migration that cannot take a gap number continues at 1000. No existing file is renamed, and no deployed database needs a ledger change.
Root overrides close four new transitive advisories
Advisories published on 6–7 October 2026 turned bun audit and apps/cms's deps:audit:check red on every branch, main included. Each is closed by a root package.json overrides entry, which is the only place a Bun workspace honours overrides:
sharp^0.35.5(GHSA-wq5f-xc86-pv6w, high, viaastro)shell-quote^1.12.0(GHSA-pqg4-j6r4-53mv, critical, via@changesets/cli›launch-editor; development-only)source-map-js^1.2.2(GHSA-68fv-2mgg-jv7q, high, viaastro›unifont)smol-toml^1.9.0(GHSA-r4xh-jqrq-34v2, moderate, via@astrojs/internal-helpers)
apps/cms/package.json is upstream's own file and is not edited. The same entries are worth proposing upstream to ahliweb/awcms, after which the root entries can go.