Repository navigation
v0.15.0
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.
devalue5.9.2 → 5.9.3 andfast-uri3.1.7 → 3.1.8, both inside every
dependent's declared range, sobun audit fixclosed them inbun.lock
alone.http-cache-semantics(GHSA-ch52-4w7c-c8xp) has no fixed release at all.
Its only consumer isastro'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) inapps/cms'sdeps:audit:checklist and in a new root
tools/ci/dependency-audit-exceptions.json, whichlocal-ci/check-tokoand
local-ci/security(the two legs that runbun 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.5is a number,1,5is 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_publicfiltering, andqsearch 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,expectedSha256to bind apply to the reviewed file, matched onsku, no media column, no network. Export neutralises spreadsheet formulas. - A new
importaccess action (high-risk, besideexport), permissionscommerce.attributes.read|manageandcommerce.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
qsearch and the CSV export only reach non-public attributes for a caller who holdscommerce.attributes.read; the import accepts exactlytext/csv(no CORS-safelistedtext/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 anIdempotency-Key; eventsawcms.commerce.quotation.{accepted,converted},awcms.commerce.work_order.status_changed,awcms.commerce.document.issued; admin screen/admin/commerce-documentsand 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.createor by each other:commerce.held_sales.{read,create,update,approve},commerce.quotations.{read,create,update},commerce.quotation_conversions.create(a conversion also needscommerce.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
documentsfeature flag (commerce settings → Features), which defaults OFF: with it off every new route answers409 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_applosesDELETEon all seven tables (andUPDATEon versions, events and documents); composite tenant-safe FKs; guard triggers freeze identity/provenance and allow only legal status moves;security-readiness.tsasserts 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_appis revokedUPDATE/DELETE, a trigger rejects everyUPDATE; 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.cancelleddomain events (two newdomain_event_runtimeconsumers), so no order, POS, pricing or payment-webhook code changed. Exactly once per order, from the order row'ssubtotal - discountin integer cents with FLOOR rounding, under the program version effective atpaid_at. Walk-in customers never earn. commerce:loyalty:expire(hourly, append-only, idempotent) and a read-onlycommerce: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-loyaltyscreen. 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.createandcommerce.loyalty_redemptions.create, notloyalty.adjust|redeem, so the upstream-ownedAccessActionunion is not widened (a new subtree divergence). awcms_workergainsSELECTonawcms_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 recordedskipped_conflictinstead 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) underIdempotency-Key;GET .../paymentsandGET /api/v1/reports/commerce/{tender-mix,outstanding-balances}(commerce.payments.read); eventsawcms.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 permissioncommerce.pos_due.create,allowDuefor an explicit balance due. The legacy single-tenderpaymentpayload 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_paidwith the balance explicit (a down-payment order is still released on its down payment, now shown asdp_paid); a manualPATCH .../status -> paidon an order that has ledger legs but is not settled is refused with409 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 with409 ORDER_PARTIALLY_SETTLEDfor an order that already holds received money; the expiry job never expires (or restocks) such an order; a reusedIdempotency-Keyagainst another order or with a different payload is409 IDEMPOTENCY_CONFLICTat the ledger level too;orders.payment_statusgainspartially_paid; the Midtrans webhook/reconcile paths andcreateGatewaySessionnow write the ledger (a pending leg at checkout, resolved idempotently by the verified outcome). - Hardening:
awcms_applosesDELETEon 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/943backfills one deterministicbackfillleg per already-paid order (idempotent; a down-payment order's cached status is corrected todp_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 anIdempotency-Key; eventsawcms.commerce.register_session.{opened,movement_recorded,closed,corrected}; admin screens/admin/commerce-registersand/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_idstamp 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 becomescorrected. The approval threshold iscashUp.approvalThresholdin the commerce module settings (default0.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
registerfeature 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/ordersrequires aregisterIdwhose register has an open session of the acting cashier (409 REGISTER_SESSION_REQUIRED | REGISTER_SESSION_CLOSING | NOT_SESSION_CASHIER); naming aregisterIdwhile the flag is off is409 FEATURE_DISABLED. The POS 201 gainsregisterSessionId. - Hardening:
awcms_applosesDELETEon all six tables (andUPDATEon the three append-only ones); composite tenant-safe FKs;security-readiness.tsasserts 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.approveno longer self-approves a variance above the threshold, andclose-decisionrefuses an approval by the user who requested the close (409 SOD_MAKER_IS_CHECKER) unless the tenant setscashUp.allowSelfApproval(commerce settings, defaultfalse; a single-operator shop opts in). The reversal API accepts an optionalregisterSessionId(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 (migrationapps/cms/sql/974_awcms_commerce_register_reversal_stamp.sqllets a reversal name a session other than its order's); a counter/admin payment on a sale whose session isclosingis 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
expensecash-out movement; a reversal appends a compensatingcorrectioncash-in (to the same session if open, else the open session of the same register; refused with409 REGISTER_SESSION_REQUIREDif none). A cash-up total is never edited — the derived expected cash moves because the movement exists, exactly once (row lock +expense:<id>:postsource key + a partial unique index). Endpoints under/api/v1/commerce/expense-categoriesand/api/v1/commerce/expenses(.../{id}/post,/decision,/reverse,/cancel,/receipt,/receipt-url,/summary,/export.csv); every mutation takes anIdempotency-Key; eventsawcms.commerce.expense.{posted,reversed}; admin screen/admin/commerce-expenses. - Approval: the tenant setting
expenses.approvalThreshold(commerce module settings; default0.00— every expense needs a second person, so a single-owner shop should set it). Above it only a poster who holdscommerce.expense_postings.approveand did NOT create the expense posts in one step; otherwise it waitspending_approvalfor 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_approvalwas 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-urlunder its owncommerce.expense_receipts.readpermission (not implied by reading expenses),no-store, audited asmedia.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.updateorcommerce.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
expensesfeature flag (commerce settings → Features), which defaults OFF (a drawer expense also needsregister). One deliberate behaviour change, applying ONLY whileexpensesis on:POST .../register-sessions/{id}/movementswithmovementType: "expense"is refused with409 EXPENSE_REQUIRES_EXPENSE_RECORD, because a raw one would bypass the threshold. With the flag off nothing changes. Reporting:GET .../expenses/summaryand a formula-neutralised CSV export (commerce.expenses.export, 366 days / 10,000 rows). - Hardening:
awcms_applosesDELETEon both tables; composite tenant-safe FKs; a lifecycle trigger refuses illegal transitions and freezes a non-draft expense's content;security-readiness.tsasserts 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; eventawcms.commerce.stored_value.entry_recorded. - Redemption is a TENDER on the payment ledger (ADR-0025):
gift_cardandstore_creditjoined the tender types, POStenders[]andPOST …/orders/{id}/paymentsaccept them with astoredValueCode, 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 is409 PAYMENT_NOT_REVERSIBLEwhen 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_FOUNDand 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 existingcommerce.pos.create/commerce.payments.create. - Backward compatible, opt-in. The whole surface is behind the new
storedValuefeature flag, which defaults OFF: with it off every owner route answers409 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 additivestoredValue: null). - Heads-up for reviewers merging sibling branches:
sql/986drops and re-addsawcms_commerce_orders_payment_method_check(addinggift_card,store_creditto the legacy summary hint), the same constraint any other branch that widenspayment_methodtouches; andcommerce-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-gridclasses onto.admin-status-pill/.admin-stat-card-grid. commerce-confirm-dialog-client.tsno 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'sconfirm-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.sqland
sql/881_awcms_media_library_media_download_permission.sql, a new880-899
band for local non-commerce divergences. A database that already applied the
old names must run, once, before its nextdb: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_BYTESre-measured on the merged build: 344,000 B (343,962 B).- The
apps/cmshttp-cache-semanticsaudit exception is now upstream's own
and no longer a local divergence; the roottools/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) andGET /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). Eventawcms.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}andcommerce.document_delivery_overrides.create. Existing tenants do not gain them retroactively. - Backward compatible, opt-in. Behind the new
documentDeliveryfeature flag (commerce settings → Features), which defaults OFF and also needsdocuments; a channel additionally needsEMAIL_ENABLED/COMMERCE_WHATSAPP_ENABLEDand its dispatcher scheduled, as it already did. New optional envCOMMERCE_DOCUMENT_LINK_BASE_URL(falls back toAPP_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 (
restockputs units back into sellable stock;damagedandquarantineare 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.refundis an optional port method; the log adapter and Midtrans implement it). A leg the provider cannot make is settled offline under the new high-riskcommerce.refunds_offline.approvewith 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 needscommerce.payments.revoke. Existing tenants do not gain them retroactively. New endpoints under/api/v1/commerce/orders/{id}/returnsand/api/v1/commerce/returns*; eventsawcms.commerce.return.recordedandawcms.commerce.refund.settled. - Backward compatible, opt-in. The whole surface is behind the new
returnsfeature flag, which defaults OFF: with it off every owner route answers409 FEATURE_DISABLEDand the order detail shows no panel. Heads-up for reviewers:sql/995narrows the loyalty ledger's "one reversal per earn" unique index to non-refund reversals and addssource_type = 'refund';awcms_commerce_order_eventsgains a nullablereturn_id;awcms_commerce_affiliate_commissionsgainsadjusted_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
barcodeonawcms_commerce_productsandawcms_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 neutral404for unknown, deleted and other-tenant codes),GET/PUT /api/v1/commerce/barcodesbrowse 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*CODEfor a quantity), a global fast-burst scanner detector that never fires inside a text field and swallows its own Enter, inlinerole="status"/role="alert"results (no modal), and a chord-only shortcut layer (F2scan,Ctrl+Enterfinalize,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 thecommercemodule settingposShortcuts; a personal override stays in the browser. - Two new resource-split permissions:
commerce.barcodes.read(lookup, catalogue, labels) andcommerce.barcodes.update(assign) — not implied bycommerce.pos.createorcommerce.products.update. Existing tenants do not gain them retroactively. - Backward compatible, opt-in. The whole surface is behind the new
barcodefeature flag (commerce settings → Features), which defaults OFF: with it off every barcode route answers409 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) andcommerce.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 inAsia/Jakarta. - New endpoints,
GET /api/v1/reports/commerce/operational-{tenders,cash-ups,expenses,loyalty,stored-value}(?from&to, at most 366 days) and a.csvsibling each. CSVs are formula-neutralised,no-store, and audited asoperational_report.export(family, range, row count — never a cell)./admin/commerce-reportsgains five panels, each shown only to a holder of that report's permission. - Ten new permissions, one
readand one high-riskexportper family:commerce.report_tenders.*,commerce.report_cash_ups.*,commerce.report_expenses.*,commerce.report_loyalty.*,commerce.report_stored_value.*. None is implied byreporting.dashboard.reador 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 answers200withenabled: falseand no rows, and its CSV is a header row. The projections fill on the nextreporting:projections:refresh(or an on-demand rebuild) — nothing is backfilled by the migration. - Hardening: five new tables with FORCE RLS and
WITH CHECK; foursecurity_invokersource views and four partial cursor indexes;awcms_workergrants asserted insecurity-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 inAsia/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-returnsand.csv(?from&to, at most 366 days). The CSV is one long file with asectioncolumn, formula-neutralised,no-store, audited asoperational_report.export./admin/commerce-reportsgains a Returns and refunds panel for holders of the new permission. - Two new permissions,
commerce.report_returns.readand the high-riskcommerce.report_returns.export, not implied byreporting.dashboard.read,commerce.returns.readorcommerce.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
returnsfeature (default OFF) is off the report answers200withenabled: falseand no rows, and its CSV is a header row. The projection fills on the nextreporting:projections:refresh(or an on-demand rebuild) — nothing is backfilled by the migration. - Hardening: one new table with FORCE RLS and
WITH CHECKand CHECKs pinning its section, bucket and detail;awcms_workergrant asserted insecurity-readiness.ts; retention (3650-day ceiling) and subject-data descriptors (no person column).