Skip to content

v0.15.0

Choose a tag to compare

@ahliweb ahliweb released this 04 Oct 04:54
· 699 commits to main since this release
f10f05f

Close the devalue/fast-uri advisories; accept the unfixable http-cache-semantics one with a date

New advisories against transitive dependencies turned main red on
local-ci/check-toko (bun audit) and local-ci/check-cms
(deps:audit:check) without any change in this repository — every open PR
inherited the failure.

  • devalue 5.9.2 → 5.9.3 and fast-uri 3.1.7 → 3.1.8, both inside every
    dependent's declared range, so bun audit fix closed them in bun.lock
    alone.
  • http-cache-semantics (GHSA-ch52-4w7c-c8xp) has no fixed release at all.
    Its only consumer is astro's build-time remote-image cache, never a shared
    request-time cache, so it is accepted as a dated, reasoned exception (review
    by 2026-11-03) in apps/cms's deps:audit:check list and in a new root
    tools/ci/dependency-audit-exceptions.json, which local-ci/check-toko and
    local-ci/security (the two legs that run bun audit) now pass to
    bun audit --ignore, and which fails either leg once an entry's review date
    has passed.

Typed catalog attributes, safe attribute filtering, validated CSV import/export

Issue #291 (epic #281, OSPOS parity). The catalog gains tenant-defined, typed custom attributes, attribute filtering that cannot be turned into SQL injection, and a CSV import/export that validates before it writes. Everything is inside the existing commerce module (ADR-0008) and takes migrations 960–964 of the reserved range (ADR-0015). The decisions and the measured query plans are in ADR-0027.

  • Attributes are typed columns, not a JSON blob. Definitions (key, labels, a closed per-type constraint schema, searchable/filterable/visibility flags, product/variant applicability) and values (text, whole number, decimal, boolean, date, choice list) with a strict ASCII grammar: 1234.5 is a number, 1,5 is a 400 — a persisted number never depends on a locale.
  • Filtering is allowlisted by construction. attr=<key>:<op>:<value> resolves the key to a definition id in the database, parses the operand with the same typed grammar, and maps each (type, operator) pair to one literal SQL template with bound parameters. There is no code path from a request to a SQL identifier or expression, and a test throws injection payloads at keys, operators and values.
  • The storefront API only ever sees public attributes. Additive attributes[] on products and variants, filterable && visible_public filtering, and q search over searchable attributes of the same audience; existing fields are untouched.
  • Import is dry-run → report → apply, all-or-nothing and replay-safe. Same planner for both, one savepoint, Idempotency-Key, expectedSha256 to bind apply to the reviewed file, matched on sku, no media column, no network. Export neutralises spreadsheet formulas.
  • A new import access action (high-risk, beside export), permissions commerce.attributes.read|manage and commerce.products.import|export.
  • Operators: run bun run db:migrate (adds five migrations, no data change). Grant the new permissions to the roles that manage the catalog; existing tenants do not gain them automatically.
  • Review hardening. The admin q search and the CSV export only reach non-public attributes for a caller who holds commerce.attributes.read; the import accepts exactly text/csv (no CORS-safelisted text/plain); an apply claims its Idempotency-Key before writing, so concurrent applies replay or 409 instead of failing with a 500.

Held sales, quotations, work orders, and numbered receipts/invoices (issue #286, ADR-0029)

The sale had no documents around it: no way to park a cart, quote a price, track a service job, or print a numbered receipt or invoice, and the OSPOS way of getting those (one overloaded sale row) was ruled out. They now exist as separate records around the unchanged order, with seven tables (awcms_commerce_document_sequences, …_held_sales, …_quotations, …_quotation_versions, …_work_orders, …_work_order_events, …_documents — migrations 980–982 under apps/cms/sql/, starting at apps/cms/sql/980_awcms_commerce_documents_schema.sql). The "why", the invoice-versus-accounts-receivable boundary and the options weighed are in ADR-0029.

  • New: park a POS cart and resume it (lines only — no price, no stock reserved, owned by the cashier, expires after 24 hours by default, single-use resume); create a priced, versioned quotation with a validity period, accept it (which pins the version shown) and convert it into an order through the ordinary POS path (created at today's price with the balance due; a changed total is refused unless accepted; exactly one order per quotation, race-safe and idempotent); run work orders through a status machine with an append-only history, optionally from an accepted quotation; issue a numbered, immutable receipt or invoice for a finalized order (INV-2026-000042 — gapless per tenant, type and UTC year, allocated in the same transaction as the row) and render it as JSON, a 42-column plain-text receipt, or a print-ready, script-free HTML page under a locked-down CSP. All mutations take an Idempotency-Key; events awcms.commerce.quotation.{accepted,converted}, awcms.commerce.work_order.status_changed, awcms.commerce.document.issued; admin screen /admin/commerce-documents and hold/resume controls on /admin/commerce-pos.
  • No second monetary authority: a document's money is a copy a database trigger verifies against the order, and a quotation never writes an order itself. A commercial document is not an accounts-receivable invoice — that stays a future, separate record that will reference a document.
  • Thirteen new permissions, none implied by commerce.pos.create or by each other: commerce.held_sales.{read,create,update,approve}, commerce.quotations.{read,create,update}, commerce.quotation_conversions.create (a conversion also needs commerce.pos_due.create), commerce.work_orders.{read,create,update}, commerce.documents.{read,create}. Existing tenants do not gain them retroactively.
  • Backward compatible, opt-in. The whole surface is behind the new documents feature flag (commerce settings → Features), which defaults OFF: with it off every new route answers 409 FEATURE_DISABLED, the sidebar entry is hidden, and checkout, POS and the order APIs behave exactly as before (no existing table gained a column).
  • Hardening: awcms_app loses DELETE on all seven tables (and UPDATE on versions, events and documents); composite tenant-safe FKs; guard triggers freeze identity/provenance and allow only legal status moves; security-readiness.ts asserts the exact privilege sets.
  • Not here yet (ADR-0029 Deferred): an accounts-receivable invoice and credit notes, voiding or replacing a document, a receipt per payment, e-mail/WhatsApp/PDF delivery, a tenant-timezone numbering year, stock reservation by a quotation.

Loyalty points: an append-only ledger with earn, redeem, expiry, reversal and reconciliation (#289)

commerce gains a loyalty/rewards capability designed as an auditable points ledger, not a mutable points column (ADR-0026). The "why": the epic's rules (#281) are exact arithmetic, no destructive edits, payments and points as separate ledgers, idempotency on every high-risk write — so a balance is the sum of immutable facts, kept in the same transaction as each insert under a row lock, and every correction is a compensating row.

  • Migrations sql/950–952: awcms_commerce_loyalty_programs (versioned, effective-dated earn rules), _accounts (projected balance) and _ledger (append-only — awcms_app is revoked UPDATE/DELETE, a trigger rejects every UPDATE; per-tenant unique idempotency key; composite tenant-safe foreign keys), FORCE RLS, worker grants, four permissions.
  • Earn and reversal are driven by the order.paid / order.cancelled domain events (two new domain_event_runtime consumers), so no order, POS, pricing or payment-webhook code changed. Exactly once per order, from the order row's subtotal - discount in integer cents with FLOOR rounding, under the program version effective at paid_at. Walk-in customers never earn.
  • commerce:loyalty:expire (hourly, append-only, idempotent) and a read-only commerce:loyalty:reconcile (daily); an audited repair that rewrites only the projection.
  • Owner API (programs, accounts, ledger, redeem, adjust, summary, reconcile), a bearer-secured customer balance/history endpoint, and the /admin/commerce-loyalty screen. OpenAPI and AsyncAPI updated.
  • Behaviour change to know about: a sixth feature flag, features.loyalty, defaults to off — nothing changes for a tenant until it opts in.
  • Permissions are commerce.loyalty.{read,manage}, commerce.loyalty_adjustments.create and commerce.loyalty_redemptions.create, not loyalty.adjust|redeem, so the upstream-owned AccessAction union is not widened (a new subtree divergence).
  • awcms_worker gains SELECT on awcms_module_settings (the earn consumer reads the feature flag).
  • Deferred, with reasons in the ADR: redemption into checkout pricing/POS tender (needs #285), tiers, per-line return compensation (#287), campaign eligibility (#280), a storefront balance page.
  • Review hardening. Reconcile repair now rewrites only complete ledger histories (an account whose early rows were purged is reported unrepairable_history_purged), and an earn whose key belongs to another account is a recorded skipped_conflict instead of an endless retry.

Commerce payments become an append-only allocation ledger (issue #285, ADR-0025)

An order's payment was one column (payment_method) plus a payment_status the status machine overwrote to paid. It is now a ledger — awcms_commerce_payment_allocations (migrations 940–943 under apps/cms/sql/, starting at apps/cms/sql/940_awcms_commerce_payment_allocations_schema.sql) — so one sale can be settled by several tenders, left partly due, and refunded by compensating rows, with settlement DERIVED from the rows and payment_status kept as a cache of it, independent of the order lifecycle. The "why" is in ADR-0025.

  • New: staff record a payment (POST /api/v1/commerce/orders/{id}/payments, commerce.payments.create) or a reversal (.../payments/{paymentId}/reversals, commerce.payments.revoke) under Idempotency-Key; GET .../payments and GET /api/v1/reports/commerce/{tender-mix,outstanding-balances} (commerce.payments.read); events awcms.commerce.payment.{recorded,reversed}.
  • New: POS accepts an explicit tenders[] (split QRIS + cash + bank transfer; change computed from the cash leg only) and, with the separate permission commerce.pos_due.create, allowDue for an explicit balance due. The legacy single-tender payment payload keeps working unchanged. The order detail screen shows the ledger with record/reverse forms; the POS screen takes split tender lines and prints every tender; the reports screen shows tender mix and outstanding balances.
  • Behaviour changes to know about: accepting a storefront manual-transfer confirmation now records a ledger leg instead of flipping the order — a confirmation for less than the total leaves the order pending_payment / partially_paid with the balance explicit (a down-payment order is still released on its down payment, now shown as dp_paid); a manual PATCH .../status -> paid on an order that has ledger legs but is not settled is refused with 409 PAYMENT_NOT_SETTLED (record the payment instead), while on an order with no leg at all it records one full-amount manual leg (audited, idempotent) and releases the order, so COD / offline tenants keep working; a storefront gateway session is refused with 409 ORDER_PARTIALLY_SETTLED for an order that already holds received money; the expiry job never expires (or restocks) such an order; a reused Idempotency-Key against another order or with a different payload is 409 IDEMPOTENCY_CONFLICT at the ledger level too; orders.payment_status gains partially_paid; the Midtrans webhook/reconcile paths and createGatewaySession now write the ledger (a pending leg at checkout, resolved idempotently by the verified outcome).
  • Hardening: awcms_app loses DELETE on the ledger and a trigger freezes every column but the pending-gateway-leg resolution; composite tenant-safe FKs; every writer locks the order row (FOR NO KEY UPDATE).
  • Migration: sql/943 backfills one deterministic backfill leg per already-paid order (idempotent; a down-payment order's cached status is corrected to dp_paid).
  • Not here yet (ADR-0025 Deferred): store-credit/gift-card tenders, automatic provider refunds, a gateway leg for less than the whole total.

POS register sessions, drawer movements and cash-up (issue #284, ADR-0028)

POS could ring up and settle a sale (ADR-0025) but could not answer "does the drawer hold what it should?". It can now: six tables (awcms_commerce_registers, …_register_sessions, …_register_movements, …_register_close_requests, …_register_close_lines, …_register_corrections — migrations 970–973 under apps/cms/sql/, starting at apps/cms/sql/970_awcms_commerce_register_schema.sql) model a till, a shift on it, its drawer movements, the count-and-close attempts and post-close corrections. The "why" is in ADR-0028.

  • New: staff define registers (/api/v1/commerce/registers), open a session with a counted float (POST /api/v1/commerce/register-sessions, one active session per register — two concurrent opens, exactly one wins), record cash in / cash out / safe drop / expense / transfer / correction movements, hand the drawer over, close it by counting each tender, approve or reject a close whose variance exceeds a tenant-set threshold, correct a closed session, and download the cash-up as CSV (formula-neutralised). All mutations take an Idempotency-Key; events awcms.commerce.register_session.{opened,movement_recorded,closed,corrected}; admin screens /admin/commerce-registers and /admin/commerce-registers/{id}, and a register banner on /admin/commerce-pos.
  • The expected closing amount per tender is DERIVED from the payment-allocation ledger (opening float + the session's stamped cash legs + movements for cash; payments − reversals for the rest) via a new register_session_id stamp on POS orders and ledger legs — a cash-up never rewrites a sale or a payment. A closed session is immutable (trigger); a correction is a compensating row and the session becomes corrected. The approval threshold is cashUp.approvalThreshold in the commerce module settings (default 0.00: any discrepancy needs a supervisor).
  • Ten new permissions, none implied by commerce.pos.create: commerce.registers.{read,create,update}, commerce.register_sessions.{read,create,update,export}, commerce.register_cash_ups.{create,approve}, commerce.register_corrections.approve. Existing tenants do not gain them retroactively — grant them to the cashier and supervisor roles that need them.
  • Backward compatible, opt-in. The whole surface is behind the new register feature flag (commerce settings → Features), which defaults OFF: with it off POS behaves exactly as before and nothing is stamped. With it on, POST /api/v1/commerce/pos/orders requires a registerId whose register has an open session of the acting cashier (409 REGISTER_SESSION_REQUIRED | REGISTER_SESSION_CLOSING | NOT_SESSION_CASHIER); naming a registerId while the flag is off is 409 FEATURE_DISABLED. The POS 201 gains registerSessionId.
  • Hardening: awcms_app loses DELETE on all six tables (and UPDATE on the three append-only ones); composite tenant-safe FKs; security-readiness.ts asserts the exact privilege sets.
  • Not here yet (ADR-0028 Deferred): the expenses domain (#294) and its typed reference on an expense movement, a "recorded after close" figure for late ledger activity, a per-register threshold, a cashier picker for handover.
  • Review fixes. Separation of duties is now the default on a cash-up: the closer holding commerce.register_cash_ups.approve no longer self-approves a variance above the threshold, and close-decision refuses an approval by the user who requested the close (409 SOD_MAKER_IS_CHECKER) unless the tenant sets cashUp.allowSelfApproval (commerce settings, default false; a single-operator shop opts in). The reversal API accepts an optional registerSessionId (an open session of the caller) so a cash refund for a sale from an earlier, closed shift lowers the drawer that actually paid it out (migration apps/cms/sql/974_awcms_commerce_register_reversal_stamp.sql lets a reversal name a session other than its order's); a counter/admin payment on a sale whose session is closing is refused (409 REGISTER_SESSION_CLOSING) instead of falling into no cash-up.

Petty cash and register-linked expenses (issue #294, ADR-0031)

A shop could record a drawer payment only as a free-text expense movement (ADR-0028): no category, no approval, no receipt, no way to undo it without rewriting a shift. It can now keep expenses properly: two tables (awcms_commerce_expense_categories, awcms_commerce_expenses — migrations 990–993 under apps/cms/sql/, starting at apps/cms/sql/990_awcms_commerce_expenses_schema.sql) plus a typed expense_id on the register movements they produce. The "why" — and the decision that this is commerce-local petty cash, not a general ledger/AP system, absorbable by a future upstream finance module through an adapter — is in ADR-0031.

  • New: staff define expense categories, record an expense (category, exact amount, payment method, date, reason, optional payee, optional private receipt, and — for cash — the open drawer session it came out of), post it, and reverse it. A posted drawer expense appends ONE register expense cash-out movement; a reversal appends a compensating correction cash-in (to the same session if open, else the open session of the same register; refused with 409 REGISTER_SESSION_REQUIRED if none). A cash-up total is never edited — the derived expected cash moves because the movement exists, exactly once (row lock + expense:<id>:post source key + a partial unique index). Endpoints under /api/v1/commerce/expense-categories and /api/v1/commerce/expenses (.../{id}/post, /decision, /reverse, /cancel, /receipt, /receipt-url, /summary, /export.csv); every mutation takes an Idempotency-Key; events awcms.commerce.expense.{posted,reversed}; admin screen /admin/commerce-expenses.
  • Approval: the tenant setting expenses.approvalThreshold (commerce module settings; default 0.00 — every expense needs a second person, so a single-owner shop should set it). Above it only a poster who holds commerce.expense_postings.approve and did NOT create the expense posts in one step; otherwise it waits pending_approval for an approver who is neither its creator nor its submitter (403 SEGREGATION_OF_DUTIES, also a schema CHECK). A rejection needs a note and returns the expense to a draft. workflow_approval was evaluated and not used (ADR-0031 D4).
  • Receipts reuse the media library's PRIVATE visibility class and presigned GET (PR #278): attach a verified private object you uploaded; read it back only through GET .../receipt-url under its own commerce.expense_receipts.read permission (not implied by reading expenses), no-store, audited as media.download, re-verified on every call. Employee scope: only a draft's creator or a supervisor may edit, discard or attach to it.
  • Twelve new permissions, none implied by commerce.register_sessions.update or commerce.pos.create: commerce.expense_categories.{read,create,update}, commerce.expenses.{read,create,update,export}, commerce.expense_postings.{create,approve}, commerce.expense_reversals.approve, commerce.expense_receipts.{read,create}. Existing tenants do not gain them retroactively — grant them to the roles that need them.
  • Backward compatible, opt-in. The whole surface is behind the new expenses feature flag (commerce settings → Features), which defaults OFF (a drawer expense also needs register). One deliberate behaviour change, applying ONLY while expenses is on: POST .../register-sessions/{id}/movements with movementType: "expense" is refused with 409 EXPENSE_REQUIRES_EXPENSE_RECORD, because a raw one would bypass the threshold. With the flag off nothing changes. Reporting: GET .../expenses/summary and a formula-neutralised CSV export (commerce.expenses.export, 366 days / 10,000 rows).
  • Hardening: awcms_app loses DELETE on both tables; composite tenant-safe FKs; a lifecycle trigger refuses illegal transitions and freezes a non-draft expense's content; security-readiness.ts asserts the exact privilege sets.
  • Not here yet (ADR-0031 Deferred): a general ledger / AP / vendors / taxes (non-goals), a typed payee/party reference, several receipts per expense and an upload control on the screen (attach by media object id), recurring expenses, per-category thresholds.

Gift cards and store credit as a closed-loop liability ledger (issue #288, ADR-0030)

Commerce had no way to sell value ahead of goods. It does now, as a LIABILITY ledger rather than a mutable balance field or a discount: three tables (awcms_commerce_stored_value_programs, …_accounts, …_ledger — migrations 985–988 under apps/cms/sql/, starting at apps/cms/sql/985_awcms_commerce_stored_value_schema.sql) hold the per-tenant configuration, the cards/credits and the append-only signed entries (issue, load, redeem, refund, adjust, expire, disable, enable). The "why", the threat model, the privacy and the regulatory boundary are in ADR-0030.

  • New: staff configure a program per kind, ISSUE a card or credit (the code is shown once, in the issuing response, and printed on a receipt from it — it is never stored), load value onto it, adjust it with a reason, disable/enable it, and run an expiry sweep and a consistency check; a liability report (GET /api/v1/reports/commerce/stored-value) shows issued, loaded, redeemed, refunded, adjusted (up and down kept apart), expired and what is outstanding. Admin screen /admin/commerce-stored-value; a "Gift cards and store credit" toggle in the commerce Features section; event awcms.commerce.stored_value.entry_recorded.
  • Redemption is a TENDER on the payment ledger (ADR-0025): gift_card and store_credit joined the tender types, POS tenders[] and POST …/orders/{id}/payments accept them with a storedValueCode, and the payment leg and its mirror ledger entry are written in one transaction (a database trigger and a deferred constraint trigger refuse either half alone). Change never applies to a card; a card that is unknown, disabled, expired or too poor refuses the whole sale before any row is written; concurrent redemptions of one card can never overdraw (the database refuses it, whatever the application does). A reversal of such a payment credits the SAME account back, or is 409 PAYMENT_NOT_REVERSIBLE when the program disallows it — there is no cash-out and no transfer in this design.
  • The code is 100 bits from the platform CSPRNG (an unambiguous 32-symbol alphabet plus a check character); only a tenant-scoped sha256 and its last four characters are stored; it appears in no log, audit attribute, event payload, idempotency row or response after issue (an integration test reads every table as text to prove it). Failed lookups are one neutral 404 STORED_VALUE_NOT_FOUND and are throttled per user. No public lookup or redeem endpoint exists.
  • Seven new permissions: commerce.stored_value_programs.{read,update}, commerce.stored_value.{read,create,update}, commerce.stored_value_adjustments.create (separate from issuing), commerce.stored_value_reconcile.approve. Existing tenants do not gain them retroactively. Redeeming needs only the existing commerce.pos.create / commerce.payments.create.
  • Backward compatible, opt-in. The whole surface is behind the new storedValue feature flag, which defaults OFF: with it off every owner route answers 409 FEATURE_DISABLED, the sidebar entry is hidden and a card tender is refused before anything is written; existing POS/payment payloads, hashes and responses are unchanged (the payment record gains an additive storedValue: null).
  • Heads-up for reviewers merging sibling branches: sql/986 drops and re-adds awcms_commerce_orders_payment_method_check (adding gift_card, store_credit to the legacy summary hint), the same constraint any other branch that widens payment_method touches; and commerce-features.ts / commerce-admin-labels.ts / the permission-count test gain lines next to other branches' flags.
  • Not here yet (ADR-0030 Deferred): a scheduled expiry job, a customer-facing balance view or any public redeem, refund to a lapsed card, selling a card as a catalogue product, store credit issued from a return.

The release tool honours the same dated audit exceptions as local CI

bun run release --apply ran a plain bun audit --audit-level=low and would
refuse every release on the accepted, unfixable http-cache-semantics
advisory (GHSA-ch52-4w7c-c8xp) that local-ci/check-toko and
local-ci/security already skip. It now runs bun audit through
tools/ci/lib/audit-exceptions.ts like both legs do — the same
tools/ci/dependency-audit-exceptions.json, and the same refusal once an
entry's reviewDate has passed.

Sync the AWCMS subtree to v10.4.0 (cfc2df9a)

apps/cms now embeds ahliweb/awcms at v10.4.0 (18 upstream commits since
8e9bce0c): the admin UI/UX parity work (ConfirmDialog, SettingsSaveBar,
ReasonPanel; .admin-stat-card/.admin-status-pill adopted by every screen;
the legacy .stat-card/.status-badge classes retired), an axe-core
accessibility smoke and a responsive overflow sweep, the OMES Mission Control
screens (migration sql/168), and dependency overrides. Merged with a merge
commit, as every subtree sync must be.

  • Five commerce admin screens moved off the retired .status-badge/
    .stat-grid classes onto .admin-status-pill/.admin-stat-card-grid.
  • commerce-confirm-dialog-client.ts no longer falls back to the browser's own
    confirm when no dialog is available (upstream now forbids it); it refuses,
    the same fail-closed posture as upstream's confirm-dialog-client.ts.
  • Operator action: upstream took sql/168, so this repo's media-library
    migrations moved (contents byte-identical, so checksums unchanged) to
    sql/880_awcms_news_media_objects_visibility.sql and
    sql/881_awcms_media_library_media_download_permission.sql, a new 880-899
    band for local non-commerce divergences. A database that already applied the
    old names must run, once, before its next db:migrate:
    UPDATE awcms_schema_migrations SET migration_name = '880_awcms_news_media_objects_visibility.sql' WHERE migration_name = '168_awcms_news_media_objects_visibility.sql'; and
    UPDATE awcms_schema_migrations SET migration_name = '881_awcms_media_library_media_download_permission.sql' WHERE migration_name = '169_awcms_media_library_media_download_permission.sql';.
    A fresh database needs nothing.
  • APP_BUDGET_BYTES re-measured on the merged build: 344,000 B (343,962 B).
  • The apps/cms http-cache-semantics audit exception is now upstream's own
    and no longer a local divergence; the root tools/ci/dependency-audit-exceptions.json
    entry stays.

Deliver receipts, invoices, quotations and work-order notices by e-mail and WhatsApp (issue #295, ADR-0034)

A receipt could be printed but not sent. It can now be delivered to the customer by e-mail or WhatsApp without a new notification subsystem: a delivery is an append-only request row (awcms_commerce_document_deliveries — migrations 965–967 under apps/cms/sql/, starting at apps/cms/sql/965_awcms_commerce_document_deliveries_schema.sql) in front of the two outboxes that already exist, whose dispatchers keep calling the providers outside any transaction. The options weighed, the transactional-versus-marketing decision and the one deviation (the three source references are a trigger, not foreign keys, because this issue's migration range sorts before the tables they reference) are in ADR-0034.

  • New: POST /api/v1/commerce/document-deliveries (idempotent; a new key is an explicit, attributable re-send; five per document per rolling hour) and GET /api/v1/commerce/document-deliveries?targetType=&targetId= (the history, with each outbox row's live status, provider message id, retry count and last error); a Deliver dialog on /admin/commerce-documents; an opt-in private link to a receipt's print view (GET /api/v1/commerce/storefront/document-links/{token} — opaque, hashed at rest, expires within 168 hours, audited). Event awcms.commerce.document.delivery_requested.
  • The message is built from a stored source only: a document's snapshot (hash re-verified), a quotation version, or a work order's status and target date. An order edited after issue cannot change a re-send; money in a message is the snapshot's own string.
  • Transactional, not marketing: marketing consent is not required, the e-mail suppression list is honoured, the recipient defaults to the customer the document names, and any other recipient needs its own permission and is stored masked. No address, name or message body is stored in the delivery table, its audit events or its logs.
  • Three new permissions, none implied by the document, quotation or work-order keys: commerce.document_deliveries.{read,create} and commerce.document_delivery_overrides.create. Existing tenants do not gain them retroactively.
  • Backward compatible, opt-in. Behind the new documentDelivery feature flag (commerce settings → Features), which defaults OFF and also needs documents; a channel additionally needs EMAIL_ENABLED / COMMERCE_WHATSAPP_ENABLED and its dispatcher scheduled, as it already did. New optional env COMMERCE_DOCUMENT_LINK_BASE_URL (falls back to APP_URL). No existing table gained a column; one partial index was added to the WhatsApp outbox for the correlation-id join.
  • Not here yet (ADR-0034 Deferred): customer-requested resend, PDF, a push channel, automatic delivery on payment or status change, a WhatsApp opt-out list, link revocation.

Returns, refunds and exchanges with inventory and payment compensation (issue #287, ADR-0033)

Commerce could record that money went back and could cancel a whole order, but had no RETURN: nothing tied "two of five units came back" to stock, money, loyalty points, affiliate commission and the sales reports. It does now, as new rows that compensate through the ledgers that already exist: four tables (awcms_commerce_returns, …_return_lines, …_refunds, …_refund_compensations — migrations 994–997 under apps/cms/sql/, starting at apps/cms/sql/994_awcms_commerce_returns_schema.sql). The "why", the options weighed (incl. the inventory boundary), the threat model and the deferrals are in ADR-0033.

  • New: staff record a return or exchange from the order detail ("Returns and refunds" panel and a three-step wizard): units per order line with a mandatory reason and a stock disposition (restock puts units back into sellable stock; damaged and quarantine are recorded and move none), bounded by what remains eligible — under the order-row lock and by a database trigger, so two concurrent returns of the last unit cannot both succeed. Value is exact to the cent (several partial returns of a line add up to the line). The refund is planned along the ORIGINAL payments newest first, each capped at what it can still give back (a trigger caps refund legs and, new here, payment-ledger reversals), and every leg that can settle now is settled in the same transaction: cash (optionally out of the caller's open register), a manual QRIS/bank refund, value back onto a gift card, or store credit (a new account's code is shown once and never stored). Nothing finalised is edited — no order, order line or payment row is ever updated.
  • Gateway refunds call the provider with NO transaction open, keyed by the refund leg's id so a retry can never refund twice (PaymentGatewayProvider.refund is an optional port method; the log adapter and Midtrans implement it). A leg the provider cannot make is settled offline under the new high-risk commerce.refunds_offline.approve with a stated reason.
  • Compensations are proportional and idempotent: loyalty points reversed (reversal:refund:{id}), affiliate commission given back (adjusted_amount; a paid commission becomes a clawback), and the sales-report projections net the return on the stream they already read (live equals rebuild; a cancel after a partial return nets to zero).
  • Five new permissions, existing verbs only: commerce.returns.{read,create}, commerce.refunds.{read,create}, commerce.refunds_offline.approve; a refund also needs commerce.payments.revoke. Existing tenants do not gain them retroactively. New endpoints under /api/v1/commerce/orders/{id}/returns and /api/v1/commerce/returns*; events awcms.commerce.return.recorded and awcms.commerce.refund.settled.
  • Backward compatible, opt-in. The whole surface is behind the new returns feature flag, which defaults OFF: with it off every owner route answers 409 FEATURE_DISABLED and the order detail shows no panel. Heads-up for reviewers: sql/995 narrows the loyalty ledger's "one reversal per earn" unique index to non-refund reversals and adds source_type = 'refund'; awcms_commerce_order_events gains a nullable return_id; awcms_commerce_affiliate_commissions gains adjusted_amount.
  • Not here yet (ADR-0033 Deferred): multi-location stock movements for damaged/quarantine units (#282), automatic tax/insurance refund, customer-initiated return requests, a scheduled refund job, voiding an open return, a returns report.

Fix: customer e-mail OTPs, conversation replies and campaign e-mails were sent with empty variables (#311)

email:dispatch runs as its own process and renders each queued message at send time. commerce registered its derived.commerce_* e-mail categories only as an import side effect of application files that process never loaded, so renderEmailTemplate treated them as unknown and substituted nothing: the customer e-mail OTP went out without its code (login and register by e-mail were broken), and conversation replies and campaign e-mails lost their variables. WhatsApp OTP was unaffected.

The three registrations now live in one dependency-free file, apps/cms/src/modules/commerce/domain/email-template-categories.ts, imported by the existing application files and by email/application/email-dispatch.ts (one recorded upstream divergence in AGENTS.md). The allow-list semantics are unchanged: an unregistered category still renders no variables. Regression tests spawn a fresh process importing only the dispatcher, and an integration test drains a real OTP e-mail through dispatchEmailQueue into a capturing provider.

Barcodes, label printing, scanner input and a keyboard-first cashier layer (issue #292, ADR-0032)

The POS screen had no barcode support: a cashier searched by name or SKU and clicked. Products and variants can now carry a barcode, a USB keyboard-wedge scanner works on the POS screen, labels can be printed, and the repeated cashier actions have configurable keyboard shortcuts. The "why", the options weighed (security, performance, accessibility, UX, compatibility, operational complexity) and what was left out are in ADR-0032. Migrations 975–976 under apps/cms/sql/, starting at apps/cms/sql/975_awcms_commerce_barcodes_schema.sql.

  • New: a nullable barcode on awcms_commerce_products and awcms_commerce_product_variants — 1 to 48 printable characters, unique per tenant among live products and variants (two tenants may share a code); an 8/12/13/14-digit numeric code is a GTIN and needs a valid check digit, anything else is a free Code 128 internal code, and the symbology is derived, never stored. GET /api/v1/commerce/barcodes/lookup?code= resolves a scan (one neutral 404 for unknown, deleted and other-tenant codes), GET/PUT /api/v1/commerce/barcodes browse and assign. Screen /admin/commerce-labels: assign codes and print label sheets (columns, copies, label size, name/price/SKU) rendered on the server with a built-in Code 128 / EAN-13 / EAN-8 encoder — no new dependency, no client script on the print path, tenant text escaped.
  • POS: a Scan a barcode field (3*CODE for a quantity), a global fast-burst scanner detector that never fires inside a text field and swallows its own Enter, inline role="status"/role="alert" results (no modal), and a chord-only shortcut layer (F2 scan, Ctrl+Enter finalize, Alt+Shift+… for the rest) with a visible help dialog where every chord can be changed (conflicts and browser-reserved keys are refused). A tenant default map is the commerce module setting posShortcuts; a personal override stays in the browser.
  • Two new resource-split permissions: commerce.barcodes.read (lookup, catalogue, labels) and commerce.barcodes.update (assign) — not implied by commerce.pos.create or commerce.products.update. Existing tenants do not gain them retroactively.
  • Backward compatible, opt-in. The whole surface is behind the new barcode feature flag (commerce settings → Features), which defaults OFF: with it off every barcode route answers 409 FEATURE_DISABLED, the scan field, shortcuts and sidebar entry are absent, and the existing mouse/touch POS flow is unchanged. No new table; the new columns are nullable.
  • Not here yet (ADR-0032 Deferred): several barcodes per item, bundle barcodes (blocked on the inventory epic), embedded price/weight barcodes, PDF label export and further symbologies, a tenant-level shortcut editor screen, a server-side per-user shortcut store, and a real-browser E2E of the scan/keyboard flow.

POS operational reports: tenders, cash-up variance, expenses, loyalty and stored value (issue #296, ADR-0035)

The commerce reports screen could show sales by day, product and category, plus a live tender mix. A shop running a till also needs to know, per register and per day, what came in by which tender, who was short at close, what was spent, and how many loyalty points and how much gift-card money it still owes — and to trust those figures. Five new reporting projections (migrations 998–999 under apps/cms/sql/, starting at apps/cms/sql/998_awcms_commerce_operational_reports_schema.sql) answer that on the reporting engine that already exists: no second analytics engine. Decision: docs/adr/0035-pos-operational-reports-are-commerce-projections-over-the-existing-ledgers-on-the-reporting-engine.md.

  • New: commerce.pos_tender_daily (payments and reversals per day, register and tender, by the day a leg settled), commerce.pos_cash_up_variance (expected, counted, corrections and variance per closed shift and tender), commerce.pos_expense_daily (posted and reversed per business day, category and tender; a reversal subtracts from the day it was posted on), commerce.pos_loyalty_daily (points earned, redeemed, expired, adjusted and reversed per day, with the outstanding points) and commerce.pos_stored_value_daily (gift-card and store-credit movement per day, with the outstanding liability per kind). Each is rebuildable from its ledger and a rebuild reproduces the live rows byte for byte; each reconciles by control totals recomputed through the same code; days are bucketed in Asia/Jakarta.
  • New endpoints, GET /api/v1/reports/commerce/operational-{tenders,cash-ups,expenses,loyalty,stored-value} (?from&to, at most 366 days) and a .csv sibling each. CSVs are formula-neutralised, no-store, and audited as operational_report.export (family, range, row count — never a cell). /admin/commerce-reports gains five panels, each shown only to a holder of that report's permission.
  • Ten new permissions, one read and one high-risk export per family: commerce.report_tenders.*, commerce.report_cash_ups.*, commerce.report_expenses.*, commerce.report_loyalty.*, commerce.report_stored_value.*. None is implied by reporting.dashboard.read or by a source-domain permission, and none opens a source row. Existing tenants do not gain them retroactively — grant them to the roles that should see each report.
  • Backward compatible, opt-in by feature. A family whose source feature (register, expenses, loyalty, storedValue; all default OFF) is off answers 200 with enabled: false and no rows, and its CSV is a header row. The projections fill on the next reporting:projections:refresh (or an on-demand rebuild) — nothing is backfilled by the migration.
  • Hardening: five new tables with FORCE RLS and WITH CHECK; four security_invoker source views and four partial cursor indexes; awcms_worker grants asserted in security-readiness.ts; retention (3650-day ceiling) and subject-data descriptors.
  • Not here yet (ADR-0035 Deferred): stock balance / movement / low-stock, receiving, tax, margin, discount and bundle reports (their source capabilities are not built), and the returns slice (#287) — the ADR records the contract it follows.

Returns & refunds operational report (issue #316, ADR-0035 addendum)

The POS operational reports shipped without returns because returns did not exist yet. They do now (issue #287), and the same screen should answer the questions a shop asks about them: how many returns this week, how much stock went back on the shelf versus was written off, and how much money went back, by which payment method — and how much of it was kept as store credit instead. One new reporting projection on the existing engine (migrations 945–946 under apps/cms/sql/, starting at apps/cms/sql/945_awcms_commerce_returns_report_schema.sql). Decision and numbering rationale: the addendum to docs/adr/0035-pos-operational-reports-are-commerce-projections-over-the-existing-ledgers-on-the-reporting-engine.md.

  • New: commerce.pos_returns_daily — returns and exchanges recorded (count and refund total), returned lines by stock disposition (restocked, written off, quarantined: lines, units, value) and settled refund legs by tender and destination (original tender or store credit), per day and register, in exact cents, days bucketed in Asia/Jakarta. A return lands on the day it was recorded and the register of the original sale; a refund on the day it settled. Rebuildable (a rebuild reproduces the live rows byte for byte) and reconcilable like the other six.
  • New endpoints, GET /api/v1/reports/commerce/operational-returns and .csv (?from&to, at most 366 days). The CSV is one long file with a section column, formula-neutralised, no-store, audited as operational_report.export. /admin/commerce-reports gains a Returns and refunds panel for holders of the new permission.
  • Two new permissions, commerce.report_returns.read and the high-risk commerce.report_returns.export, not implied by reporting.dashboard.read, commerce.returns.read or commerce.refunds.read. Existing tenants do not gain them retroactively — grant them to the roles that should see the report.
  • Backward compatible, opt-in by feature. While the returns feature (default OFF) is off the report answers 200 with enabled: false and no rows, and its CSV is a header row. The projection fills on the next reporting:projections:refresh (or an on-demand rebuild) — nothing is backfilled by the migration.
  • Hardening: one new table with FORCE RLS and WITH CHECK and CHECKs pinning its section, bucket and detail; awcms_worker grant asserted in security-readiness.ts; retention (3650-day ceiling) and subject-data descriptors (no person column).