Sales order → Invoice → Payment — as-built plan
| Status | Revision 49 — 25 Sep 2026. Print and Send Email are offered only on a finished document — Completed, Voided or Returned. A draft, one waiting for approval, a rejected one and an approved-but-not-issued one are still being agreed inside the business, so both buttons are hidden on the view page and both row actions are hidden on the list; an old ?action=send-email link is refused with a sentence rather than obeyed. One rule, canPrintOrEmail() in lib/constants/sales-lifecycle.ts, drives every one of those places. This replaces the earlier idea of sending a draft as a marked-up review copy (§6.5). Also fixed: the create / edit form wrote with api.* directly and never cleared the React Query caches, so after Save draft or Submit for approval the list showed the copy taken before the document existed and had to be refreshed by hand; it now calls useInvalidateSalesDocument() on every write. Revision 48 — 24 Sep 2026. The Audit is grouped by day — the owner chose style C from four shown side by side in the mockup, and the live style switcher is gone with the other three. The date is a heading, each line carries only the time, and the event is named as a verb instead of an old→new pair of pills (§6.5). Mockup and app both follow it. Also fixed from a clean-up review: sales_document_write_service imported the wrong coupon repository, so every draft edit carrying a cart discount or a coupon raised AttributeError mid-transaction — a new guard test checks that every module.thing() really exists in that module. About 50 pieces of dead code were removed across both sides. Revision 47 — 23 Sep 2026. The company owner may approve a document they raised (§0b) — everyone else, administrators included, is still refused; it is a separation-of-duties control, not a permission. First end-to-end test against a real database passed: a Sales Order and a Sales Invoice both wrote correct rows. One defect it found is fixed — the manual discount recorded the giver's e-mail instead of their name, on a field that prints on the document. Revision 46 — 23 Sep 2026. The view page's Audit leaves the footer steps and opens from an icon button in the header instead, furthest right; every button in that group is icon-only (Record Payment · Print · Send Email · Audit). Three steps remain: Details · Payment · Billing & Shipment, and none is marked current while the Audit shows. Mockup updated and deployed; the page follows. Revision 45 — 23 Sep 2026. G19 built (§0b): the user picks the order type on the create page — Delivery by default — and BO_ONLINE / "BO Online Sales" is removed everywhere, leaving Delivery · In-Store · Online Delivery. A converted invoice carries its order's type; a credit note carries the original sale's. No migration at all for the order type. Revision 44 — 23 Sep 2026. G19 replaced (§0b): the user picks the order type on the create page — one drop-down, every type offered, Delivery by default — and the BO_ONLINE back-fill migration is deleted, so no migration is needed at all. Mockup updated and deployed for review; the backend change is not built yet. Revision 43 — 23 Sep 2026. The owner's four answers are built (§0b): a customer is required on every Back-Office order and invoice; a draft moves no stock and stock moves on the day the document completes, not the day it was drafted; void voids the payments (already built, re-checked); a serial number must be unique across the whole document, refused on save and on submit. Revision 42 — 22 Sep 2026. Built. The plan is implemented on Redy-20260910-BO-Invoice — new §0b lists what the branch carries and the six things it deliberately does not. Two review rounds (security, plan-vs-build, design) were run and their findings fixed: the self-approval check now reads the append-only status log instead of the rewritable cashier column, PUT …/cashier no longer touches it, void resets the header money, manual discount limits are enforced on the API, duplicate serials are refused, and the predicate is built once rather than copied. Revision 41 — 22 Sep 2026. Safety review: new §2.1c (21 validation and safety-net rules — screen, server, error, test), §8.1 target tests, §8.2 acceptance list; the five developer notes written in. Verdict: READY. Revision 40 — 22 Sep 2026. Readiness questions answered by the owner (§0a): who / when / why of approve and reject live in workflow_status_log (no new columns); Void only at Approved and it voids the payments in the same step, a Completed document is voided at the till; a direct invoice is Approved then Issued like an order is converted. Mockup: Issue Invoice button, no Void on Completed, void clears the payments. Verdict: READY WITH MINOR ADJUSTMENTS. Revision 39 — 22 Sep 2026. Readiness review (§0a): NOT READY until three owner questions are answered — approval columns (Q2), what Void does to money and to a Completed document (Q3), what Approve does on a direct invoice (Q4). Three review fixes applied (G20 data step matches main, converted invoice arrives Paid, 409 STATUS_CHANGED on a stale approve / reject). Revision 38 — 22 Sep 2026. Owner decisions recorded (§9A): a direct invoice takes payments like an order and is submitted only when paid in full (G21 — the mockup now has a Payment step, Payment Status column, Record Payment and the paid-in-full gate on invoices too); G24 (a), G15 (a), G16 the standard UI, G22 and G18 confirmed, G1 (30-day default), G11 dropped. Revision 37 — 22 Sep 2026. §9 rewritten for review: three owner decisions (G21, G24, G15), the build order for the target, the small fixes, the closed items — plain words, one line each; the full developer rows kept in §9E. Revision 36 — 22 Sep 2026. Branch hygiene: the uncommitted functions_pg.sql / test_stock_inventory_layer.py edits and the stray sales-parity tool were dropped (owner); G10 removed, §2.3 and §7 trimmed to what the branch still carries. Revision 35 — 22 Sep 2026. Plan brought in line with the final mockup: §0 leads with the target, new §6.5 Target screens describes every page of the mockup as the standard for the build (common rules, lists, step form, Select Items, Serial / Batch pages, view page, Audit timeline, mobile); §6.1 / §6.3 / §6.4 marked "as built today"; Audit naming, per-step arrows, header buttons, void-only-once-approved and Save-draft-to-list folded in. Revision 34 — 22 Sep 2026. Workflow-status standard folded in (PR 11100): new §2.1b (every status change through change_status(), log_initial_status() on create, extra_set, remarks, actor, CI guard); vocabulary per D13 — a sales document is never CANCELLED, it is voided with a reason (§2.1a, §6.3, mockup: Cancel Order → Void Order, Reject / Void open a reason card, the Audit step became Audit showing the log rows); G24 asks whether to build the sales-only read endpoint. Revision 33 — 22 Sep 2026. The owner chose the step form with the steps in the footer as the one design going forward; the other three mockups (one-page as-built, walkthrough, steps-on-top) were removed locally and from the office site, G16 is decided. Revision 32 — 22 Sep 2026. Status badge colours aligned to the new Back-Office standard (PR 11100, status-badges.ts): new §6.3a with the seven tones and this module's word → colour table; CANCELLED spelling; label of FOR_APPROVAL settled as "Approval pending" (G23 closed). The four mockups use the standard tone classes. Revision 31 — 22 Sep 2026. Payment status words on the order aligned to the built DocumentStatusPill: Pending Payment · Partial Payment · Paid (were Unpaid · Partly paid · Paid in full) in all four mockups; the view page Summary follows the built two-column block (Currency / Tips / Surcharge / Service Charge / Shipping · Subtotal / Item Discount / Cart Discount / Tax / Rounding · Net Total / Amount Paid / Balance · Notes). Revision 30 — 21 Sep 2026 (night). The footer step-form mockup gains the view pages — Sales Order detail and Sales Invoice detail (sections Details · Payment (order only) · Billing & Shipment · Audit shown as steps in the pinned footer, like the form — no tab bar (owner, 22 Sep 2026); the footer also holds Back and the actions the status allows — Draft / Rejected: Edit · Submit for Approval (greyed until paid in full); Approval pending: Reject · Approve; Approved: Void Order · Convert to Invoice (greyed until paid in full); Completed: Void Order; Voided: Back only. Void appears only once the document is approved and never together with Reject (owner, 22 Sep 2026). Top-right in the page header, not in the footer (owner, 22 Sep 2026): Record Payment (outline button, only while the order can take money — Draft / Rejected / Approved with a balance; it jumps to the Payment section) and Print / Send Email as two icon-only buttons (printer, envelope; tooltip and aria-label carry the name), on every status. Button icons follow the built form pages (CouponFormPage, PriceRuleFormPage, lucide): Submit for Approval = Send (paper plane), Approve = Check, Reject = X, Void = Ban, Edit = Pencil, Record Payment = CreditCard, Print = Printer, Send Email = Mail; labels in Title Case like the built pages ("Submit for Approval"). Reject and Void first open a reason card (textarea, "Shown on the document and written to the audit with your name, the app and the time"; an empty reason is refused on the screen — "Give the reason — it goes on the document and into the audit.") — the reason becomes remarks on the status-log row and the red banner on the document. The Audit section is the workflow_status_log read back for this record as a timeline, not a table (owner, 22 Sep 2026): newest first, a dot in the status colour on a vertical line, the move as two status pills ("Draft → Approval pending"; the first row reads "Created as Draft"; the top row is marked "current"), then who (name + emp code) · app (Back-Office / POS), the reason as an indented quote when one was given, and the time at the right; on a phone the section circles stay tappable in the footer; the buttons walk the record through the whole lifecycle in memory | serial or batch block) with a primary Done. Revision 27 — 21 Sep 2026 (late). Line discount basis confirmed against the till, the BO server and the schema: per unit. Mockups now say so — header "Disc / unit", an RM | % segmented switch, and a "… line" total under the box; row-action icons fixed at one size, the actions column pinned on list pages, row menus float above the table box. Revision 26 — 21 Sep 2026 (late). Step-form mockups (top steps and footer steps) polished after a screen-by-screen review: every button and field on the standard 36 px desktop / 40 px phone height, Date and Sales rep share the row evenly, the review's item table fits its column, list rows on one line with the right row count, the totals card shows the discount negative, and an invoice now has three steps (its notes moved onto the Customer step) with a one-line payment hint on the order's Payment step — G16 updated. Revision 25 — 21 Sep 2026 (night). Serial-number page re-laid out in all four mockups to the Add Items page standard (title row, pinned bottom bar, no top Back) and for large quantities (paste-a-list, keyed grid with filter, one "N more to key" line) — §6.2 and G17 (4) updated. Revision 24 — 21 Sep 2026 (evening). Four corrections from the owner: (0) a sales order can be submitted for approval only when paid in full — a part-paid order stays a Draft; (1) payment status lives on the sales order only — payments are taken while the order is a Draft and a part payment keeps it Draft; a sales invoice carries no payment status (its Payment tab only lists what was carried over); (2) FOR_APPROVAL is shown as "Pending for approval"; (3) Yes / No columns on both list pages are coloured pills (green Yes, grey No). Applied to the four mockups; §2.1a and G20 updated. Revision 23 — 21 Sep 2026 (later the same day). Owner's new lifecycle for Back-Office sales orders and invoices: Draft → For approval → Approved / Rejected → Completed (plus Cancelled), no deposit on an order any more — an order takes ordinary payments after approval — and Convert to Invoice only when the order is paid in full; no money is taken on a draft. Applied to the four mockups (§2.1a, §3.0); the app still runs the Revision 19 lifecycle (OPENED / PENDING / deposit) — §9 G20 is the build. Revision 22 — 21 Sep 2026. Quick-add customer inside the customer search on both designs (Name · Mobile · Customer group · Email, code automatic); step-form button always "Next"; compact spacing everywhere; the one-page mockup's create pages are now live on sample data so both designs can be tried. Revision 21 — 16 Sep 2026. Owner's mockup pass on the create / edit page: no Draft badge; item search above the grid; "Add Items" opens the full-page catalogue picker, never a dialog; cart discount always a manual discount with a reason drop-down; grid columns cut to Net (Tax Type / Tax / Total dropped) and Net = price × qty − item discount only; summary in the Discounts tab — under the grid in the step form — (Total Net · Total Cart Disc negative · Tax by rate · Total) with the per-line share table behind a toggle; serialized-item chip + serial boxes; Line details inline under the item (no dialog) with a dedicated full-page serial-number entry; one Note per document (Internal note removed) and no custom_field column anywhere in the module; step-form option grown to four steps with a Review & confirm step. Applied to the three mockups only — SalesDocumentPage still shows the Revision 20 screen; §9 G17 lists the code work. Revision 20 — 15 Sep 2026. UI pass on 15 Sep: Status vs Payment Status wording, stage strips and document chain removed, header fields trimmed, tables on the table standard, Summary on the section standard, line maths corrected, mobile support. Revision 19 — 14 Sep 2026. Aligned to the implementation on branch Redy-20260910-BO-Invoice; adds the order deposit, the deposit refund, the invoice lifecycle Draft / Pending Payment / Paid, and draft editing (all built the same day). This document now describes what is built, the rules the code enforces, and the short list of what is still open. Everything that was planned but not built has been removed from the body; the one-line history in §11 says where it went. |
| Scope | Back-Office only, Online-type stores only. Sales order → Invoice → Payment. | ||
| Screen basis | One mockup: mockup/invoice-bo-step-form-footer.html — the owner's chosen direction (22 Sep 2026): the Sales Orders and Sales Invoices list pages; New / Edit as a step form (order: Customer › Items › Payment › Review & send; invoice: Customer › Items › Review & send) with the step strip in the fixed footer beside Back / Save draft / Next; the Add Items page, the Serial Numbers and Batch Numbers pages, the customer quick-add; and the Sales Order / Sales Invoice view pages (sections Details · Payment · Billing & Shipment as footer steps, the Audit behind an icon button in the header, with the actions each status allows). Every screen can be tried: a new document starts empty, "Edit" on a draft loads the worked example, and the view page walks a record through the whole lifecycle in memory. The earlier files — the one-page as-built mockup, the step-by-step walkthrough and the steps-on-top variant — were removed on 22 Sep 2026 once the owner chose this one; their decisions live on in §6 and §9. | ||
| Companion | plan.html is generated from this file by build-plan-html.py in the same folder. Edit the .md, then run python build-plan-html.py. Never edit the .html by hand. | ||
| Related specs | ../sales-order/spec.md, ../sales-invoice/spec.md (the read-only list/detail pages this work extends). |
0. Summary
Target (decided 15–22 Sep 2026, the mockup is the standard — §6.5). A Back-Office user raises a sales order (SALES_ORDER, sub-type PRE_ORDER, source_type = 'BO') in a step form (Customer › Items › Payment › Review & send). Money is taken on the order while it is a Draft; a part payment keeps it Draft. Once paid in full it is submitted for approval (FOR_APPROVAL, shown "Approval pending"); a manager approves or rejects it with a reason; an approved order is converted to an invoice (SALES_INV, created COMPLETED, payments carried over, stock moved) and itself becomes COMPLETED. A direct invoice (no order) is paid the same way and submitted only when paid in full (G21). A sales document is never cancelled — once approved it can be voided with a reason. Every status change goes through change_status() and lands in workflow_status_log, read back on the view page's Audit, opened from the header icon (§2.1b, §0b). Status words and colours follow the Back-Office standard (§6.3a); the Serial Numbers / Batch Numbers pages follow the Purchase Order pages; the Select Items page follows the built DataTable toolbar. Open points: none.
As built today (Revision 19 model, to be replaced — §2.1, §3, §6.1–6.3). The page confirms an order, converts it to an invoice that waits at Pending Payment (PENDING), takes a deposit on the order, and completes the invoice when paid. The lifecycle lives in the existing status column — no stage column was added. Stock moves only when an invoice reaches COMPLETED. Every write that creates a header or a payment carries an Idempotency-Key, so a retried request cannot create a second document or take money twice.
0a. Readiness review — answered 22 Sep 2026
Reviewed against the mockup, main and the schema. First verdict: NOT READY (three open questions). The owner answered all three the same day; the plan and the mockup carry the answers, and the developer notes are written in (§2.1c, §8.1, §8.2). Verdict: READY.
| # | Question | Answer |
|---|---|---|
| Q2 | Where do "who approved / rejected, when, why" live? | In workflow_status_log — no new columns on ft_sales_header. Each approve / reject is one log row (user_id, emp_code, created_by, created_date, remarks = the reject reason); the banner, the view page and the Audit read the latest row for the document (GIN index ix_wf_status_log_entity_id on entity_id, so one indexed query per page). The log starts fresh, so an old document shows no reason — accepted. |
| Q3 | Void: what happens to the money and to a Completed document? | (a) + (d) — Void at Approved voids the payment rows in the same step (same transaction, same reason); a Completed document is not voided in the Back-Office (the till owns the void of a completed sale), so the Void button shows on Approved only. |
| Q4 | What does Approve do on a direct invoice? | (b) — the same shape as the order: Approve → APPROVED; then an Issue Invoice button moves it to COMPLETED (stock moves, coupons claimed, the till gets the sale), the way Convert to Invoice does on an order. |
Already fixed in this revision (no action)
- G20's data step said to write a status migration that
mainalready has (PENDING → DRAFT, not→ APPROVED); corrected. - A converted invoice now "arrives Paid" (it used to say "no payment status", which G21 overturned).
- Approve / Reject at the same second: the service checks the status under the row lock and answers 409
STATUS_CHANGED.
Developer notes — now written into the plan
- Validation and safety net: §2.1c — 21 rules, each with the screen behaviour, the server check, the error the user reads and the test that proves it.
- Permissions:
sales.order.approveandsales.invoice.approvego intobackend/app/core/permissions.pyand a reversible seed migration in the shape of20260226_0200_seed_rbac_data.py, granted to the manager role (owner to name the role at build time; default: the role that holdssales.order.managetoday plus the approver flag). - Tests: §8.1. Acceptance: §8.2.
- G15: the stamp is drawn by
v2-sellable-api/src/modules/report/renderers/receipt_renderer.py(the PDF the Print button already calls) — the word is the status label in capitals ("DRAFT", "APPROVAL PENDING", "REJECTED"), diagonal, on every page; nothing on Approved / Completed. - G22: lowering Qty on a grouped line below its batch split refuses with 422
TRACKING_INCOMPLETE"Reduce the batch quantities first" (V6).
0b. Built — 22 Sep 2026
The target in this plan is now implemented on branch Redy-20260910-BO-Invoice. What follows is what the branch carries, and the short list of what it does not.
Backend. services/sales_lifecycle_service.py holds submit / approve / reject / void / status-history with VALID_TRANSITIONS; every status move goes through workflow_service.change_status() and every create through log_initial_status() — python ops/ci/scripts/check_workflow_registry.py prints all clear. Routes on both documents: POST …/{id}/submit, /approve, /reject, /void, /payments, /payments/{id}/void, GET …/{id}/payments, GET …/{id}/status-history; POST …/{id}/convert on an order and POST …/{id}/issue on an invoice. Removed: /confirm, /reconfirm, /cancel, /complete, /deposit, /deposit/refund, PUT …/{id}/draft, and repositories/sales_reservation_repository.py. derive_document_state() speaks the new vocabulary. Batch split writes one ft_sales_detail row per batch and the detail read re-groups them into one line with a batches list (G22). custom_field is gone from the module's models and inserts (G18/G19).
Who raised it. There is no created_by column on ft_sales_header, so the raiser is read from the document's first workflow_status_log row (old_status IS NULL) — an append-only source. The self-approval refusal (V11) and the detail page's created_by_user_id both use it. PUT …/{id}/cashier now changes the sales rep only, only while the document is Draft or Rejected, and is store-scoped.
Money on a void. Void writes total_amt_paid = 0, the full balance, is_paid = false, paid_date = null and is_voided = true in the same change_status() call that voids the payment rows, so a voided document never reads "Paid".
Readers. One predicate, built by sales_doc_semantics.bo_not_a_sale_predicate(alias); the dashboard's eight hand-written copies now use it. v2-sellable-api has the same rule as sales_query.bo_not_a_sale_sql() and applies it to till sync, the sales reports and the e-invoice lookup. The receipt PDF carries a grey stamp — DRAFT (Draft / Rejected), APPROVAL PENDING, NOT ISSUED (Approved).
Frontend. One step form (pages/sales/SalesDocumentPage.tsx), one list page and one view page shared by both documents, plus SalesAuditTimeline, ReasonCard, SalesFooterBar, SalesPaymentSection, TrackingEntryPage and salesListColumns. Error codes are mapped to plain sentences in lib/constants/sales-lifecycle.ts; no code reaches a screen.
Migration (not yet run against a database). One only — 20260922_0100_seed_sales_approve_permissions.py, which seeds sales.order.approve / sales.invoice.approve (Backoffice Admin and Store Manager true, the rest false). Nothing else in this module needs a migration.
G19 — the user picks the order type; BO_ONLINE is gone (owner, 23 Sep 2026)
Until now every Back-Office sale was written BO_ONLINE with no say from the user, and a migration was going to back-fill old rows. The owner replaced all of it: the user picks the order type, Delivery is the default, the BO Online Sales type is deleted, and there is no migration.
- The field. One drop-down, "Order type", on the Customer step of a sales order and a direct invoice. It shares a row with Date and Sales rep — three equal fields, so nothing is left hanging — and carries the hint "How the goods reach the customer." It is required, starts on Delivery, and shows on the Review step, on the view page's Details section and in the list's Order Type column.
- The choices are now only the three real ones: Delivery · In-Store · Online Delivery.
BO_ONLINE/ "BO Online Sales" is removed fromapp/core/order_types.py, fromfrontend/src/lib/constants/index.ts, from the mockup and from every write that hardcoded it. - Old documents already stored
BO_ONLINE. Nothing rewrites them.formatOrderType()turns an unrecognised code into words, so an old row still reads as words rather than a raw enum. No migration, by the owner's instruction —20260922_0200_backfill_bo_online_order_type.pywas written and has been deleted. - Where the value comes from. A create or edit sends
order_type_code; the service resolves it throughorder_types.resolve_order_type(), which answersDELIVERYfor a missing value and refuses an unknown one with 422INVALID_ORDER_TYPE. Two paths take no choice at all: a converted invoice carries its order's type, and a return / credit note carries the original sale's./dropdownson both routers returns the list.
The owner may approve their own document (owner, 23 Sep 2026)
Separation of duties still holds for everyone else: you do not approve what you raised. The company owner is the single exception — in a one-person shop there is nobody else to ask, and blocking them would stop the work.
- Owner only. Checked against
users.is_ownerviarbac_service.is_owner(), the same flag that already bypasses permission checks. An administrator with every permission is still refused: this is a control, not a permission. - Both sides. The screen shows Approve / Reject to the owner on their own document (view page and list row menu); the server lets them through. For anyone else both layers still refuse.
- Who raised it is read from the document's first
workflow_status_logrow, which is append-only, so it cannot be edited afterwards to dodge the rule. The header's cashier is the fallback for documents written before the log existed. - Tested both ways — the owner passes, an ordinary approver is refused. The test harness signs everyone in as the owner, so the refusal case signs in as a plain approver; otherwise the rule would read as tested while testing nothing.
The Audit moved out of the steps (owner, 23 Sep 2026)
The view page's footer used to walk through four sections: Details · Payment · Billing & Shipment · Audit. The Audit is not a part of the document — it is the record of what happened to it — so it no longer sits among the steps.
- Three steps remain in the footer: Details · Payment · Billing & Shipment.
- The Audit opens from an icon button in the page header, the furthest right of the group, lucide
History. It is pressed-in while the timeline is showing, and while it shows no step is marked current or ticked off — the strip belongs to the sections, and the Audit is not one of them. - Every button in that group is icon-only now: Record Payment · Print · Send Email · Audit. Each keeps its name in
titleandaria-label, so it is still hoverable and still read aloud. Record Payment still appears only while the document can take money; the other three show on every status. - The timeline is grouped by day (owner picked style C of four, 24 Sep 2026). The old shape printed, on every row, the old status, an arrow and the new one — but the old status is already the row below it, so each step appeared twice and needed a footnote to explain the doubling, while the date sat at the far right where the eye had to hunt for it. Now:
- The date is a heading, written once per day, and each line under it carries only the time, in one narrow column you can read straight down. You see at a glance which days the document actually moved.
- Each line names the event as a verb — Created · Submitted for approval · Approved · Rejected · Converted to invoice (an order) / Completed (an invoice) · Voided · Returned · Back to draft — with the colour carried by a dot, not by a pill. No arrows, no from→to pair.
- Under the verb: who (name + employee code) · app; the reason as an indented quote when one was given. The newest line keeps the "Current" tag.
- Newest first, still every row of
workflow_status_log(§2.1b, G24).
Owner's answers — 23 Sep 2026
Four points the owner settled, all now built.
| # | The rule | Where it is enforced |
|---|---|---|
| 1 | A customer is required. There is no walk-in Back-Office document. | customer_id is required on CreateSaleRequest, CreateOrderRequest, UpdateOrderRequest and UpdateInvoiceRequest, so the request is refused at the door. submit() also refuses an older document that has none — 422 CUSTOMER_REQUIRED, "Choose a customer before sending." The step form already refuses to leave the Customer step without one. /calculate stays open — it is only a price preview. |
| 2 | A draft moves no stock; stock moves on completion, dated the day it completed. | Only convert and issue move stock, and both now pass their own stock_date. issue_invoice reads the clock once as completed_at and uses it for both the stock movement and the audit row, so an invoice approved on Monday and issued on Friday moves stock on Friday. Convert already dated the new invoice today and now dates the stock the same. |
| 3 | Void also voids the payments. | Already built and re-checked: void_document voids every live payment row and writes total_amt_paid = 0, the full balance, is_paid = false, paid_date = null, is_voided = true in the same transaction. |
| 4 | A serial number is always unique. | A serial is one physical unit, so it can appear once on a document — not twice on a line, not once on each of two lines, and "AB1" and "ab1" are the same serial. Refused on save (sales_serial_service.check_serials_unique, called by all three write paths) and again on submit (get_duplicate_serials), both 422 DUPLICATE_SERIAL. On the screen the Serial Numbers page marks a clash in red, refuses Save, and Paste a list drops what another item already uses; Save draft and Submit are both blocked while two items share a serial. |
Not built.
| What | Why |
|---|---|
| Payment date | A payment is recorded as of today; the API takes no date, so the field was removed from both payment forms rather than shown and thrown away. |
| Old e-mail rows | The manual-discount giver was written as an e-mail until 23 Sep 2026. Rows already saved keep it; nothing rewrites them (owner). New rows carry the name. |
| Payment status filter | The list API has no payment_status filter, so the toolbar filters Status only. The column still shows and the balance still sorts. |
| Unsaved-changes guard | Covers a new document only. App.tsx uses BrowserRouter, so useBlocker is unavailable and an edit form has no dirty flag to compare against. |
SALES_WORKBENCH_ENABLED | A compile-time constant in the frontend (V21), not a runtime switch. |
1. Scope
1.1 Built
| Area | What the user can do | Where |
|---|---|---|
| Sales order | Create from scratch (saved as Draft, or saved and confirmed in one click), record a deposit (on the create page or the detail page), confirm, cancel with a reason, convert to invoice, print, e-mail, change sales rep | SalesDocumentPage (mode order), SalesOrderPage, SalesOrderDetailPage |
| Invoice | Create directly as a Draft (fully editable) or pay in full at once; or convert a confirmed order into a Pending-payment invoice; edit Notes after that; cancel a draft with a reason; print, e-mail, change sales rep | SalesDocumentPage (mode invoice), SalesInvoicePage, SalesInvoiceDetailPage |
| Payment | Record one or several payments on a pending invoice; the last one completes it (stock moves, sale counts); cash change is computed; payment records list | PaymentTab + RecordPaymentPanel on the invoice detail page |
| Cross-cutting | Online-store-only, SO/SI numbering, audit fields, permissions + store scope, Back-Office drafts and cancellations excluded from sales figures, the till sync and e-invoice | backend repositories and v2-sellable-api readers (§3.9) |
1.2 Built in the API only (no screen yet)
| Endpoint | Note |
|---|---|
PUT /api/sales-orders/{sales_id} for a confirmed order (Notes) | The draft branch has a screen (/sales/orders/:id/edit); the confirmed-order allow-list edit has none. |
POST /api/sales-invoices/{sales_id}/payments/{sales_payment_id}/void | Allowed only while the invoice is Pending Payment. The hook useVoidInvoicePayment exists; no button calls it. |
POST /api/sales-orders/{sales_id}/reconfirm | Leftover from the stock-hold design. It only checks the order is Confirmed and returns; it changes nothing. |
1.3 Not built (deliberately out, or deferred)
- A separate deposit document (
PREPAY_INV). The deposit is stored the way the till stores it — payment rows on the order itself (§3.3a). NoPREPAY_INVheader is written. - Forfeiting a deposit (keeping the money when the customer walks away). Only refund is built; a forfeit needs a permission, an income account and terms-and-conditions wording (§9 G7).
- Discount approval (over-limit park, queue, approve/reject) — separate plan with its own table.
- Quotation, delivery note as a document, partial invoicing, several orders on one invoice.
- Voiding a completed invoice from the Back-Office (till only). Returns and credit notes are the existing return path. Cancelling a Pending-payment invoice is not built either — a pending invoice with no payments has no way out yet (§9 G13).
- Shipping charge, service charge, surcharge — the money engine is unchanged. The create page shows a "Shipping charge (RM, not taxed)" input on the Delivery tab, but its value is not sent to the server (§9).
- Statements, reminders, ageing, credit limit, foreign-currency payments, gift-card / coupon tenders on a later payment.
- Print options (choose what the printed document includes) — designed 28 Sep 2026, §3.11; mockups only, nothing built.
2. Data model (as built)
2.1a Target lifecycle — decided 21 Sep 2026, not built
Owner's rule (screenshot of the stage grid, 21 Sep 2026): a Back-Office sales order and a Back-Office sales invoice share one lifecycle; a till sales order keeps Draft → Completed. Stages "Doing the work" and "Delivering" are not used by these documents.
| Stage | Back-Office sales order status | Back-Office sales invoice status | Till sales order |
|---|---|---|---|
| Draft | DRAFT | DRAFT | DRAFT |
| Approval pending (label) | FOR_APPROVAL | FOR_APPROVAL | — |
| Approved or rejected | APPROVED / REJECTED | APPROVED / REJECTED | — |
| Completed | COMPLETED | COMPLETED | COMPLETED |
| Undone | VOIDED (reason asked) | VOIDED (reason asked); RETURNED after a return | VOIDED (POS) |
How the stages behave (the mockups follow this; §9 G20 builds it):
Vocabulary (workflow-status plan D13, owner 2026-09-21, shipped on main in PR 11094 / 11100): a sales document is never CANCELLED — "a document that can be voided is not cancelled". The Back-Office voids an order or invoice instead (VOIDED, reason asked, a new BO action); the POS voids too; an invoice can also end RETURNED. FOR_APPROVAL, APPROVED and REJECTED are Back-Office words only — the POS never approves anything (its order goes DRAFT → COMPLETED, then VOIDED). The earlier CANCELLED stage in this plan and its Cancel buttons are replaced by Void; where an older section still says cancel, read void.
- Draft — editable. Payments are taken here on both documents (G21, owner 22 Sep 2026): the create page has a Payment step and the view page a Record Payment panel; a part payment keeps the document a Draft; the payment words Pending Payment · Partial Payment · Paid (the built
DocumentStatusPillwords) show on the order and on the invoice. No void here — a draft is edited or left. - Submit for Approval (staff) —
DRAFT/REJECTED→FOR_APPROVAL, shown as Approval pending (thefield-options.tslabel; owner accepted it on 22 Sep 2026 over the earlier "Pending for approval"). A sales order — and a direct invoice (G21) — can be sent only once it is paid in full; a part-paid order stays a Draft (owner, 21 Sep 2026) — the button is greyed out with "Record the full payment first" and the API refuses. Locked for editing while it waits. Reject or Approve only — no void. - Approve / Reject (a manager with
sales.order.approve/sales.invoice.approve; not the person who raised it — except the company owner, §0b) —FOR_APPROVAL→APPROVED, or →REJECTEDwith a mandatory reason. A rejected document goes back to editable, shows the reason, and can be sent again. Two managers at once: the service reads the rowFOR UPDATEinside the transaction and refuses with 409STATUS_CHANGEDwhen the status is no longerFOR_APPROVAL(theold_statusthatchange_status()returns is the check) — Approve and Reject can never both succeed on one document. - Approved order — always paid in full by then (the gate above). There is no deposit concept and no refund-deposit flow; a wrong payment is voided.
- Convert to Invoice — only on an
APPROVEDorder that is paid in full; the payment rows carry over, the order becomesCOMPLETED, the invoice is createdCOMPLETED, stock moves, coupons are claimed, the till gets the sale. A converted invoice needs no approval of its own and arrives Paid — its Payment section lists the carried payment records. - Direct invoice (no order) — decided 22 Sep 2026 (G21, Q4): it follows the same shape as the order — Payment step on the form, Record Payment on the view page, Submit for Approval only when paid in full, Approve →
APPROVED, then Issue Invoice (APPROVED → COMPLETED: stock moves, coupons are claimed, the till gets the sale — the invoice's Convert). A converted invoice arrives Paid andCOMPLETED. - Void (was Cancel — D13, a sales document is never cancelled) — only at
APPROVED(owner, 22 Sep 2026, Q3): never next to Reject — a document that is Draft, Rejected or Approval pending is edited, rejected or left as it is, not voided; a Completed document is not voided in the Back-Office (the till owns the void of a completed sale). The screen asks for a reason; in one transaction the service voids the document's payment rows (the existing void-payment service, same reason) and moves the status toVOIDEDthroughchange_status()withextra_set= the fivevoided_or_returned_*columns (reason_code = 'VOIDED',notes= the reason) andremarks= the reason. - Doc types — the Back-Office writes only
SALES_ORDERandSALES_INV. Order payments are ordinary payment rows against theSALES_ORDERheader (total_amt_paid); noPREPAY_INVis ever written by the Back-Office — that document is the till's deposit on a pre-order / special order / layaway and only reaches these lists through the till sync (checked 21 Sep 2026:new_sale_order_controller.dartwrites it;sales_payment_service.pystates there is no PREPAY_INV header for a BO deposit).
Assumptions the owner has not confirmed yet: (a) a converted invoice is created COMPLETED, not FOR_APPROVAL; (b) REJECTED is editable and re-sendable; (c) the approver is a new permission, not a role; (d) settled: a direct invoice pays like an order (G21). (The earlier assumption that payment starts only after approval was wrong and is withdrawn: payments are taken on the Draft order.)
2.1b Workflow status log — the standard every status change follows (PR 11100, 22 Sep 2026)
Since PR 11100 every status change on ft_sales_header (one of the 8 workflow tables) goes through workflow_service.change_status() in apps/v2-sellable-bo/backend/app/services/workflow_service.py (BO rule 17; the API twin is apps/v2-sellable-api/src/services/workflow_service.py; long form db-postgresql/docs/workflow-status-log-plan.md). A repository that writes UPDATE ft_sales_header … status = on its own fails the CI guard (ops/ci/scripts/check_workflow_registry.py, run on every PR). What it means for this module:
| Rule | What the sales order / invoice code does |
|---|---|
| One writer | change_status(conn, entity_name="ft_sales_header", entity_id={"sales_id": id}, new_status=…, actor=actor_from_user(current_user), remarks=…, extra_set={…}) inside conn.transaction(). It locks the row, updates status (+ updated_by, last_updated_src and every extra_set column in the same UPDATE) and inserts one workflow_status_log row. Same status twice → writes nothing. |
| Transitions stay ours | change_status() records, it does not decide. The allowed moves (§2.1a) and the paid-in-full gate live in sales_order_write_service / sales_document_service as VALID_TRANSITIONS, checked before the call. |
| Create path | log_initial_status() after the INSERT — the NULL → DRAFT row (a converted invoice: NULL → COMPLETED). |
extra_set | approve / reject: nothing — who, when and why live in the log row itself (Q2; no approval columns on the header); void: voided_or_returned_reason_code = 'VOIDED' + voided_or_returned_notes, _by, _by_user_id, _datetime (the five void columns §2.2 already lists); convert: COMPLETED on the order together with the invoice link; issue (invoice): COMPLETED. |
remarks | user text first, the record's own reason column second, a fixed system text third, else NULL (D10). The screen asks for a reason on Reject and Void (§6.3) and passes it as remarks; the service never refuses an empty one. Convert writes the fixed text "Converted to invoice SI-…" on the order and "Created from order SO-…" on the invoice. |
| Who / where | Actor from the JWT: company_id, company_code, user_id, emp_code, app_type = "BO"; created_by = emp code. A row synced from the till carries app_type = "POS" and the POS updated_date (D9). |
| Read side | The workflow plan builds none (its §8); this module builds its own (G24, decided): GET /api/sales-orders/{id}/status-history / …/sales-invoices/{id}/status-history → the rows for the document, newest first (GIN index on entity_id). The view page's Audit timeline — opened from the header's History icon, not a footer step (§0b) — plus the rejected / voided banner and "approved by" all read from it (Q2). |
| Vocabulary | D13: never CANCELLED on a sales document; Void with a reason instead (§2.1a). |
2.1c Validation and safety net — every rule, where it is enforced, what the user sees
Rule of the module: the screen guides, the server decides. Every check below runs on the API even when the screen already greys the button, so a stale tab, a second user or a hand-made request cannot get past it. Error codes are the ones the built routes already use (§3, §4); new ones follow the same shape (409 for a state that no longer allows the action, 422 for bad input, 403 / 404 for access).
| # | Rule | Screen | API (inside the transaction) | Error → what the user reads | Test |
|---|---|---|---|---|---|
| V1 | Every write is tenant- and store-scoped | — | company_id on every read and write; require_record_store (404 for another company's id, 403 outside the caller's stores) — as built §3, §3.10 | 404 / 403 | test_receipt_access.py pattern, one per new route |
| V2 | Only one writer moves a status | — | SELECT … FOR UPDATE on the header, then change_status() (locks again, writes the log row) — §2.1b; CAS that updates 0 rows → 409 | 409 STATUS_CHANGED "Someone else changed this document. Reload it." | two approvers at once → one 200, one 409 |
| V3 | No double submit / double payment | — | Idempotency-Key on every create and payment route — as built §3.8; change_status() writes nothing when the status is already the target | second identical request → the first result, no second row | replay test per route |
| V4 | Edit only while editable | Edit shows on Draft / Rejected | PUT refused unless DRAFT / REJECTED; version must match | 409 STAGE_NOT_ALLOWED "This document is waiting for approval and cannot be edited." / 409 VERSION_CONFLICT "Reload — it was changed since you opened it." | edit at each status |
| V5 | A document needs at least one line, a customer and a store before it leaves Draft | Next / Submit greyed with the reason | /submit → 422 EMPTY_DOCUMENT | "Add at least one item before sending." | submit empty |
| V6 | Tracked lines complete before Submit | Submit greyed: "Enter all serial numbers for … (1 of 2)" | /submit → 422 TRACKING_INCOMPLETE with the line and the count; serial: exactly Qty distinct values; batch: quantities add up to Qty, every batch with a quantity has a number (G22) | same words | serial short, duplicate serial, batch over / under |
| V7 | Paid in full before Submit (both documents) | Submit greyed: "Record the full payment (balance RM …) before submitting" | /submit → 409 ORDER_NOT_PAID_IN_FULL / INVOICE_NOT_PAID_IN_FULL unless total_bal_amt = 0 | "Record the full payment first — a part-paid order stays a Draft." | part-paid submit |
| V8 | Payments only while the document can take money | Record Payment shows on Draft / Rejected / Approved with a balance | /payments → 409 STAGE_NOT_ALLOWED on any other status; amount > 0 and amount ≤ total_bal_amt → 400 PAYMENT_EXCEEDS_BALANCE; tender active for the company, no stored-value tender (as built §3.7) | "The payment cannot be more than the balance of RM …" | pay over balance; pay on Approval pending |
| V9 | Discount limits | Line and cart discount fields turn red with "Min 0.5% / Max 50%" | /calculate and the writes refuse → 422 MANUAL_DISCOUNT_OUT_OF_RANGE reading MIN_ / MAX_MANUAL_DISCOUNT from Company Settings (G17) | "Manual discounts must be between 0.5% and 50% (Company Settings › Advanced)." | 0.4 %, 50.1 %, both modes |
| V10 | Approve / Reject only by an approver, never by the author — unless the caller is the company owner | Reject / Approve are hidden from the author, and shown to the owner on their own document | sales.order.approve / sales.invoice.approve on the route; then rbac_service.is_owner() — owner passes, otherwise the raiser (the actor on the first workflow_status_log row, append-only) must differ from the caller → 403 SELF_APPROVAL | "You cannot approve a document you raised." | author approves; owner approves their own; staff without permission |
| V11 | Reject and Void need a reason | Reason card refuses an empty box | reason 1–500 chars → 422 REASON_REQUIRED (the module enforces it; change_status() itself never does — D10) | "Give the reason — it goes on the document and into the audit." | empty, 501 chars |
| V12 | Approve / Reject only from Approval pending; Convert / Issue only from Approved; Void only from Approved | buttons per status (§6.5) | VALID_TRANSITIONS in the write service, checked on the locked row → 409 STAGE_NOT_ALLOWED | "This document is not waiting for approval." etc. | every wrong-status call |
| V13 | Convert / Issue only when paid in full | Convert / Issue greyed until paid | /convert, /issue → 409 ORDER_NOT_PAID_IN_FULL unless total_bal_amt = 0 | as V7 | part-paid approved (only reachable by voiding a payment) |
| V14 | Convert and Issue are all-or-nothing | — | one transaction: order COMPLETED + invoice created COMPLETED (log_initial_status NULL → COMPLETED) + payment rows copied + stock moved + coupons claimed; any failure rolls all of it back (as built §3.5 pattern) | 500 with the request id; nothing half-done | fail the stock step → no invoice, order still Approved |
| V15 | Void voids the money with it | Reason card says so | one transaction: every non-voided payment row voided (existing void-payment service, same reason) + status VOIDED + the five voided_or_returned_* columns + log row | — | void an approved order with two payments → both voided, one log row |
| V16 | Void never on a Completed document (Back-Office) | no button | /void on COMPLETED → 409 STAGE_NOT_ALLOWED "A completed sale is voided at the till." | same words | void completed |
| V17 | Every status move leaves a trace | — | change_status() / log_initial_status() only; the CI guard (check_workflow_registry.py) fails a direct UPDATE … status = | — | one log row per move, old_status / new_status / emp_code / remarks / app_type = 'BO' |
| V18 | Drafts and pending documents never count as sales | — | BO_NOT_A_SALE_PREDICATE_H and the till-sync predicate exclude DRAFT, FOR_APPROVAL, REJECTED, VOIDED and a SALES_INV at APPROVED (§3.9, G20) | — | list / report / sync tests per status |
| V19 | No custom fields | — | the write models extra="forbid" refuse custom_field* (G18) | 422 | send custom_field1 → 422 |
| V20 | Audit read is scoped | — | …/status-history behind the module's VIEW permission + require_record_store; rows filtered by company_id and entity_id | 403 / 404 | another company's id → 404 |
| V21 | Roll-back path | — | no destructive migration in this build (statuses already on main; one seed migration for the two permissions, reversible); the create routes stay behind SALES_WORKBENCH_ENABLED, so the new pages can be switched off without a deploy | — | migration up / down on an empty schema |
What is deliberately not enforced: a reason on Approve (none asked); a limit on how many times a document may be rejected and resubmitted; editing a document while another user views it (the version check catches the write, not the read).
2.1 Documents — as built today (Revision 19 lifecycle, to be replaced by §2.1a)
No stage column. The label the user sees is derived from status, the money columns and is_voided by derive_document_state() in backend/app/services/sales_document_service.py.
Sales order — doc_type = 'SALES_ORDER', doc_sub_type = 'PRE_ORDER', source_type = 'BO'. A deposit does not change the stage; the Payment tab shows "Deposit received" / "No deposit" from total_deposit_paid.
| Label | status | Extra condition | Meaning |
|---|---|---|---|
| Draft | OPENED | — | Saved, not confirmed. Not synced to tills, not counted in reports. |
| Confirmed | PENDING | — | Confirmed. No stock held. Synced to tills (Pre-orders list). |
| Invoiced | COMPLETED | has a Back-Office SALES_INV child in ft_sales_parent_child | Converted in the Back-Office; the invoice moved the stock. |
| Collected | COMPLETED | no Back-Office child | A till collected the pre-order. |
| Cancelled | CANCELED | voided_or_returned_reason_code = 'CANCELED' + _notes, _by, _by_user_id, _datetime | Cancelled with a reason. |
Invoice — doc_type = 'SALES_INV', doc_sub_type = 'NORMAL_ORDER', source_type = 'BO'.
| Label | status | Condition | Meaning | |
|---|---|---|---|---|
| Draft | OPENED | direct invoice, saved without payment | Fully editable. No stock, no report, not on the till. | |
| Pending Payment | PENDING | total_amt_paid = 0 | Converted from an order (or a deposit-carrying convert). Not yet a sale: no stock move, not in reports, not on the till. Due date from the customer's terms. | |
| Partial Payment | PENDING | total_amt_paid > 0, or a carried deposit (0 < total_bal_amt < grand total) | Same, with money already taken. | |
| Paid | COMPLETED | is_paid = true | Paid in full. Stock moved, coupons claimed, counted as a sale, reaches the till. | |
| Overdue (red chip) | PENDING | is_overdue — doc_due_date < today (company zone) and balance > 0; never stored | Shown beside Pending Payment / Partial Payment. | |
| Cancelled | CANCELED | draft cancelled with a reason | Excluded everywhere. | |
| Voided | VOIDED | is_voided = true (till only) |
A Back-Office invoice at COMPLETED is always paid. The "Issued" label survives only for till-written invoices at COMPLETED with nothing paid (COD).
Balance rule everywhere: total_bal_amt = total_amt_after_disc_tax_sc_rd − COALESCE(total_deposit_paid, 0) − COALESCE(total_amt_paid, 0). On an order total_amt_paid mirrors total_deposit_paid (the deposit is the only money an order holds); on the invoice the two are separate — deposit carried from the order, payments recorded on the invoice.
2.2 Columns used
| Concept | Column |
|---|---|
| Lifecycle | ft_sales_header.status — the Back-Office writes only OPENED, PENDING, COMPLETED, CANCELED (both documents) |
| Payment due | ft_sales_header.doc_due_date = conversion date + customer.payment_terms_day on a pending invoice; the completion date on a direct invoice; NULL on a draft |
| Not used — rule | No custom_field column, header or line, is used by this module (owner, 16 Sep 2026). Today the code still touches them: the write models (CreateOrderRequest / UpdateOrderRequest in models/sales_order_write.py, CreateSaleRequest in models/sales_creation.py, UpdateInvoiceDraftRequest in models/sales_invoice.py) carry header custom_field1..3, the confirmed-order and invoice PUT allow-lists accept custom_field1 / custom_field2, convert copies the order's custom_field1..3 onto the invoice, DetailsTab echoes header.custom_field1 back on every notes save, and the line write copies custom_field1..3 (line custom_field1 = the old warranty note). All of that goes in §9 G18. |
| Cancel reason | voided_or_returned_reason_code, _notes, _by, _by_user_id, _datetime |
| Paid state | total_amt_paid, total_bal_amt, is_paid, paid_date — all four written by the payment service |
| Deposit | current_deposit_paid (latest deposit), total_deposit_paid (sum) on the order, copied to the invoice at conversion; the deposit money itself is ft_sales_payment rows whose sales_id is the order |
| Request id | ft_sales_header.offline_sales_id, ft_sales_payment.offline_sales_payment_id (§3.8) |
| Payment reference / card | ft_sales_payment.payment_ref_no, payment_card_number, payment_card_holder_name, payment_card_auth_code |
| Void reason | ft_sales_payment.return_reason |
| Link order ↔ invoice | ft_sales_parent_child (read only through a company-scoped header join) |
| Addresses / driver note | bill_address_*, ship_address_*, ship_special_notes |
2.3 Migration (committed)
db-postgresql/migrations/tenant/versions/20260914_0200_customer_terms_and_comments.py — id bo_sales_customer_terms_01, revises audit_codes_01. Idempotent and reversible.
ALTER TABLE customer ADD COLUMN payment_terms_day bigint NOT NULL DEFAULT 30+ comment. Existing customers read 30 through the default.- Comment rewrites:
ft_sales_header.doc_due_date,doc_type,status(OPEN→OPENED),current_deposit_paid,total_deposit_paid;ft_sales_payment.return_reason(added);ft_stock_trans.status(addsCLOSED,CANCELLED→CANCELED) andtrx_type(sign convention). - Seeds the
field_optionrowft_sales_header.doc_sub_type→ parentdoc_type = 'SALES_INV', valueNORMAL_ORDER. Mirrored inbackend/app/core/field_options.pyandfrontend/src/lib/constants/field-options.ts.
tenant_column_comments.sql carries the same comment text as the migration (uncommitted on the branch at the time of writing); tables_pg.sql, triggers_pg.sql and functions_pg.sql are as on main.
No column was added to ft_sales_header. No index was added.
The migration's new doc_type comment text still says "PENDING confirmed with stock held" and lists PREPAY_INV as a Back-Office writer; the owner dropped this from the plan on 22 Sep 2026 (this round does not touch PREPAY_INV). (An earlier uncommitted edit to fn_api_inventory_set_sale_stock_movement in functions_pg.sql — negative-stock flag from store_settings_json, company_id filters, narrowed release — was dropped on 22 Sep 2026 with its test change; if the negative-stock flag fix is wanted it is its own small PR with a migration, outside this module.)
3. Business rules (as enforced)
> Superseded by §2.1a (21 Sep 2026): §3.2 Confirm becomes Submit for Approval + Approve / Reject; §3.3a–b Deposit and Refund a deposit go away (an approved order takes ordinary payments); §3.5 Convert requires the order to be paid in full and creates a COMPLETED invoice; §3.6 direct invoice goes Draft → For approval → Approved → Completed with payments only after approval. The text below is still what the code does today.
All in backend/app/services/sales_order_write_service.py unless stated. Every lifecycle action takes SELECT … FOR UPDATE on the header, checks source_type = 'BO' and the caller's store scope (require_record_store), then does a compare-and-swap on status. A CAS that updates 0 rows raises ConflictError.
3.1 Create order
POST /api/sales-orders→status = 'OPENED',doc_sub_type = 'PRE_ORDER', number from theSOprefix,doc_trx_dateserver-set (back-dating allowed throughdoc_date, bounded byMAX_BACKDATE_DAYS).- Cart is priced by the existing engine (
calculation_tokenfrom/calculate). Discount rows and promo rows are written; tracked coupons are not claimed on an order. - Store must be an Online-type, active store.
3.2 Confirm
POST …/confirm:OPENED → PENDING. Nothing else: no stock hold, no availability check. Response carriesstatus,hold_end_date = null,warnings = [].
3.3 Cancel
POST …/cancelwithreason(1–500 chars): allowed fromOPENEDorPENDING. Refused iftotal_deposit_paid > 0("This order has a deposit…"). WritesCANCELEDplus the five cancel columns.
3.3a Deposit (backend/app/services/sales_payment_service.py, record_order_deposit)
POST /api/sales-orders/{sales_id}/deposit, Idempotency-Key required, body tender_type_id, amount > 0, optional payment_ref_no and card number / holder / approval code. One transaction:
- Idempotency replay on
RequestRoute.SALES_ORDER_DEPOSIT(uid inoffline_sales_payment_id). - Header lock; must be a Back-Office
SALES_ORDERatOPENEDorPENDING— else 409STAGE_NOT_ALLOWED"A deposit can only be recorded on a draft or confirmed order." - Tender must be active for the company; stored-value tenders (gift card, store credit) are refused.
amount ≤ grand_total − total_deposit_paid— else 400DEPOSIT_EXCEEDS_BALANCE"The deposit cannot be more than the balance of RM …". No cash change on a deposit.- Writes one
ft_sales_paymentrow on the order'ssales_id(trx_date = now()), then the header:current_deposit_paid = amount,total_deposit_paid += amount,total_amt_paid = total_deposit_paid,total_bal_amt = grand_total − total_deposit_paid,updated_date,version + 1. - Several deposits may accumulate up to the order total. A deposit is never voided; it is refunded (below), and the original row is kept.
- Every payment and deposit row also carries the header's
offline_sales_id— the till joins its payment rows to the header on that column, so a NULL would orphan the row on the device. - Replay look-up is scoped to the document in the path and to the last 90 days, like the header look-up.
3.3b Refund a deposit (refund_order_deposit)
POST /api/sales-orders/{sales_id}/deposit/refund, Idempotency-Key required, body tender_type_id, amount > 0, reason (1–500), optional payment_ref_no. Mirrors the till's "Refund deposit":
- Same lock, store scope and stage rule as a deposit (
OPENEDorPENDING, Back-Office only). amount ≤ total_deposit_paid— else 400REFUND_EXCEEDS_DEPOSIT; no deposit → 400NO_DEPOSIT_TO_REFUND. Stored-value tenders refused.- Writes one
ft_sales_paymentrow with a negativelocal_amtand the reason inreturn_reason; the original deposit row is kept. - Header:
total_deposit_paid,current_deposit_paidandtotal_amt_paid= held − amount;total_bal_amt = grand_total − new total. - Cancel with a deposit: refused with "This order holds a deposit of RM …. Refund the deposit first, then cancel." (409
DEPOSIT_MUST_BE_REFUNDED). Once the deposit is fully refunded, Cancel works as today. This is the rule the strict ERPs use (Business Central, SAP B1) and what the till does; no system auto-refunds and none deletes the original deposit row.
3.4 Edit
PUT /api/sales-orders/{sales_id}(API only, §1.2): draft → full re-price and child rows replaced; confirmed → allow-listnotes(today alsocustom_field1,custom_field2— to be dropped, G18; the branch lives inservices/sales_order_write_service.py,update_confirmed_order_headerinrepositories/sales_document_repository.py); otherwise refused. Optimistic locking throughversion. A draft that already holds a deposit keeps it in the balance, and a new total below the deposit is refused (409TOTAL_BELOW_DEPOSIT).PUT /api/sales-invoices/{sales_id}: allow-listnotes(≤ 600) with a requiredversion(today alsocustom_field1≤ 255 — to be dropped, G18). Used byEditInvoiceDialogand the inline pencil editors on the invoice Details tab.
3.5 Convert order → invoice
POST …/convert, one transaction:
- Lock the parent; must be
PENDINGandsource_type = 'BO'. Customer, if any, must be active. - New
SALES_INVheader: ownSInumber, owndoc_trx_date,doc_sub_type = 'NORMAL_ORDER',status = 'PENDING',doc_due_date = doc_trx_date + customer.payment_terms_day(default 30);total_deposit_paidcopied from the order,total_amt_paid = 0,total_bal_amt = grand_total − deposit. The deposit payment rows stay on the order; the invoice's Reconciliation block adds them back. - Lines, item tax rows and promo rows are copied, not repriced;
sales_idremapped. - Coupons are not claimed and stock does not move at conversion — both happen at completion (§3.7a). If the carried deposit already covers the total,
complete_invoiceruns inside the same transaction and the invoice landsCOMPLETED. ft_sales_parent_childlink written with the real offline ids.- Parent CAS
PENDING → COMPLETEDas the last statement; 0 rows → "already converted".
Response: the new invoice's sales_id, doc_ref_no, doc_trx_date, status (PENDING, or COMPLETED when the deposit covered it), doc_due_date.
3.6 Direct invoice — Draft or paid in full
POST /api/sales-invoices (sales_commit_service.create_sale):
payments = []→ Draft:status = OPENED, no payment rows, no stock, no coupon claim,doc_due_date = NULL. SI number assigned now.payments ≥ grand total→ completed at once: payment rows (+ cash change),COMPLETED,is_paid,paid_date, stock moves, coupons claimed (complete_invoice).doc_due_date= today.0 < payments < grand total→ 400DIRECT_INVOICE_MUST_BE_PAID_IN_FULL"A direct invoice must be paid in full, or saved as a draft with no payment." A direct invoice never waits for payment; to sell on credit, raise a sales order and convert it.- Walk-in and named customer follow the same rule (D21 is retired).
3.6a Draft invoice — edit, complete, cancel
PUT /api/sales-invoices/{sales_id}/draft(body = the order update shape +version): full re-price and child rows replaced, shared with the draft-order edit (sales_document_write_service.replace_draft_document). OnlyOPENEDBack-Office invoices; 409STAGE_NOT_ALLOWEDotherwise.POST /api/sales-invoices/{sales_id}/complete(Idempotency-Key, bodypayments[]covering the grand total): writes the payment rows,doc_due_date = today, thencomplete_invoice. Gift-card and store-credit tenders are refused here (no redemption runs on this path) — to pay a direct invoice with a gift card, take the payment on the create page (paid in full at save), where redemption is part of the commit.POST /api/sales-invoices/{sales_id}/cancel(bodyreason):OPENEDonly →CANCELED+ the five cancel columns, like an order. A pending invoice cannot be cancelled here yet (G13).
3.7 Payments (backend/app/services/sales_payment_service.py)
Record — POST /api/sales-invoices/{sales_id}/payments, body tender_type_id, amount > 0, optional payment_ref_no, card number / holder / approval code:
- Idempotency replay on
RequestRoute.SALES_INVOICE_PAYMENT. - Header lock; must be a Back-Office
SALES_INVatPENDING(a till invoice atCOMPLETEDwith a balance is still accepted as before), not voided, not already paid. A Draft (OPENED) is refused with 409INVOICE_NOT_PAYABLE"Complete the draft invoice with full payment first." - Stored-value tenders (gift card, store credit) are refused.
change_due = max(amount − balance, 0); change is allowed only on a cash tender, and is written as a linked cash-change row.- Header recomputed:
total_amt_paid,total_bal_amt. When the balance reaches 0 on a Back-Office pending invoice,complete_invoiceruns in the same transaction (§3.7a) and the response carriesstatus = COMPLETED.
Void — POST …/payments/{sales_payment_id}/void with reason (API only, §1.2): allowed only while the Back-Office invoice is PENDING; on COMPLETED refused with 409 INVOICE_CLOSED "This invoice is paid in full and closed. Use a sales return instead." Refuses an already-voided row and a cash-change row; voids the payment and its linked change row; recomputes the header from the surviving rows.
3.7a Completion (sales_commit_service.complete_invoice)
One helper, called from the direct paid-in-full create, /complete on a draft, the last payment on a pending invoice, and a convert whose deposit covers the total: re-checks the invoice's tracked coupons (409 COUPON_NO_LONGER_VALID if one was used or expired meanwhile) and claims them; run_sale_stock_movement(SALES_INV, BO); header status = COMPLETED, is_paid = true, paid_date = now, total_bal_amt = 0, updated_date, version + 1. This is the one moment a Back-Office invoice becomes a sale.
3.8 Retry safety (sales_document_service.py, api/sales_requests.py)
request_uid = uuid5(NAMESPACE, f"{company_id}:{route}:{Idempotency-Key}"), route being one ofRequestRoute:sales.order.create,sales.invoice.create,sales.return.create,sales.invoice.payment(andsales.order.deposit, unused).- The write takes
pg_advisory_xact_lock(hashtext(request_uid)), looks the uid up inoffline_sales_id(headers, ±1 day around the resolveddoc_trx_date, or 90 days back) oroffline_sales_payment_id(payments, last day) and replays on a hit. GET /api/sales-requests?key&route&doc_datetakes the same lock and answersfound,doc_type,sales_id,doc_trx_date,sales_payment_id;found = falsewhen the stored store is outside the caller's scope. Confirm / cancel / convert rely on their CAS, not on a key.
3.9 Readers and the till
- Sales figures exclude Back-Office drafts, cancellations and pending invoices with
AND NOT (source_type = 'BO' AND (status IN ('OPENED','CANCELED') OR (status = 'PENDING' AND doc_type = 'SALES_INV')))(BO_NOT_A_SALE_PREDICATE_Hincore/sales_doc_semantics.pyfor theh.-aliased readers; the Sales Summary and Sales by Item reports use it too):dashboard_repository.py(all aggregations, recent list, by-type donut),view_sales_repository.py, the Invoices-list money band insales_invoice_repository.py,v2-sellable-apireports_sales.py(invoice and order lists) andeinvoice_query.py. - Till sync (
v2-sellable-api/src/modules/sales/sales_query.py) usesNOT (sh.source_type = 'BO' AND (sh.status = 'OPENED' OR (sh.status IN ('CANCELED','PENDING') AND sh.doc_type = 'SALES_INV')))— a Back-Office invoice reaches the till only once it isCOMPLETED; a confirmed or cancelled Back-Office order still syncs so the till's pre-order copy updates; a draft never does. - A Back-Office invoice carries
NORMAL_ORDER, so it lists under Sales on the till; it has noregister_id, so it never enters a register's shift totals. - Order type (
core/order_types.py): the user picks it on the create page — Delivery · In-Store · Online Delivery, Delivery by default (G19, owner 23 Sep 2026). A converted invoice carries its order's type; a credit note carries the original sale's. The line-levelft_sales_detail.order_type_codestays NULL.order_type_codeis plain text with no FK or CHECK (theorder_typetable was dropped on 13 Feb 2026), tax overrides ignore order type by design, and the pricing engine never filters promotions by it or adds a service charge — so the choice changes no amount. The oldBO_ONLINE("BO Online Sales") type has been removed; documents written before that still hold the code and are shown throughformatOrderType(), which renders an unknown code as words.
3.10 Print and e-mail (as built)
Both documents reuse the shared receipt path every Back-Office sales document already had (see ../sales-invoice/spec.md § Receipt PDF Template and docs/standards/receipt-spec.md). Nothing new was written for this plan; the rules below are what the routes do today.
Print — GET /api/sales-orders/{sales_id}/receipt and GET /api/sales-invoices/{sales_id}/receipt
- The Back-Office proxies to
v2-sellable-api's report module throughservices/report_api_client.generate_receipt()withreport_type = sales-order-receipt/sales-invoice-receiptand the document'sdoc_type; the report module renders the shared Jinja2 template (receipt.html, A4, wkhtmltopdf) and streams the PDF back. - The browser fetches the PDF as a blob and opens it (
lib/api.tsopenReceipt,components/dispatchReceipt.ts); no file is stored. - Buttons: Print Receipt on the order list row menu and detail bottom bar, Print Invoice on the invoice list row menu and detail bottom bar — shown in every state, including Draft (the PDF then reads as a draft: no payment rows,
doc_due_dateempty). - The receipt prints the payment due date for a pending invoice ("Pay within N days · due …", added to
receipt.html/receipt_renderer.pyon this branch). - Guard: the router-level
sales.order.view/sales.invoice.viewpermission, plusassert_receipt_access()in the service — the samerequire_record_storecheck as the detail page (404 for another company's id, 403 outside the caller's stores). Fixed 15 Sep 2026 (was G14).
E-mail — POST /api/sales-orders/{sales_id}/send-email and POST /api/sales-invoices/{sales_id}/send-email, body SendEmailRequest { to_email?: str }
- Permission
sales.order.manage/sales.invoice.manage(the invoice list row action additionally hides behindsales.invoice.email, §5). - Service (
sales_order_service.send_order_email,sales_invoice_service.send_invoice_email): recipient =to_emailoverride, else the header'scust_email; none → 400 "No email address available for this invoice/order". Renders the same PDF throughreport_api_client.fetch_receipt_bytes(), attaches it asreceipt_{doc_ref_no}.pdf, subjectSales Order {ref} — {company}/Invoice {ref} — {company}, sends through the sharedemail_service.send_with_attachment(). - Outcome is written to the header:
is_emailed = true,email_status,email_error_msg; a failed send stores the failure, logs it and answers 400 "Failed to send email: …". - Screens: Send Email on the list row menu and the detail bottom bar sends straight away to the customer's e-mail with a progress toast ("Sending invoice to {email}…" → "Invoice emailed to {email}"); no e-mail on the customer → toast "No customer email address on this invoice/order" and nothing is sent. The inline confirmation panel the earlier specs describe (
SendEmailPanel.tsx,SendOrderEmailPanel.tsx) is no longer on the sales order / invoice pages — the button sends directly. Shown in every state (§9 G15).
3.11 Print options — what goes on the paper (designed 28 Sep 2026, not built)
Ask (owner, 28 Sep 2026): when the user presses Print, let them choose what the printed document includes — hide the item barcode, hide the sales rep name, and so on — with the store's usual choice saved in store.store_settings_json.
Flow
- The existing Print Invoice / Print Receipt buttons stay one click: they print with the store's saved choices (the fast path — most prints).
- A second action, Print with options… (list row menu, next to Print Invoice / Print Receipt; and the view page header, next to the print icon — the view page has no bottom-bar Print today,
SalesDocumentViewPage.tsx:536), opens a full page — no dialog, no side panel — where the user ticks what to include, watches the A4 preview change, and takes one of three ways out with the same choices: Print invoice / Print order (primary), Download PDF, Send e-mail. ‹ Back (footer, left, the standard) returns to the detail page. A counter above the list reads "9 of 13 included" (an invoice: 13 choices; an order: 18). The A4 preview is sticky and scaled to fit, so the whole page is always visible on a desktop screen while the checklist scrolls. - Two layers of choices, resolved in this order: this print (what is ticked now) › company setting (
company_settings_json, one for every store) › shipped defaults. Nothing is stored per user (owner, 30 Sep 2026: nousers.preferences): the page always opens with the company setting, and what the user ticks lives only for that print. - Save as the company default (all stores) (checkbox on that page) writes the choices into Company Settings. Only a user with the company-manage permission (
ADMINISTRATION_COMPANY_MANAGE) sees it; everyone else reads "Only an admin with the Company Settings permission can change this." There is no store-level setting: every store of the company prints the same way. - Copies: "Number of copies" 1–5 (default 1) and "Stamp each copy — first ORIGINAL, the rest COPY" (default off, only when copies ≥ 2). The renderer prints the PDF N times in one file, the stamp as a small grey tag under the title panel. Copies and the stamp are print-time only — never saved anywhere.
- Remark for this print: one free-text line (max 120 chars) printed as "Remark: …" above the footer text. Never saved anywhere; not written to the document.
- E-mail attachment: the PDF attached to the e-mail always uses the same choices as the print — no separate checkbox (owner, 30 Sep 2026). The existing
POST …/send-emailgains the same optionalprint_optionsas the receipt route. - The same checklist, without the preview, sits as a Printing section on the Company Settings page (
pages/company/tabs/AdvancedSettingsTab.tsx, next to the othercompany_settings_jsonswitches) — that is where the company setting is normally set. - Which statuses: the same rule as the existing Print button — only Completed, Voided and Returned (
lib/constants/sales-lifecycle.ts:40canPrintOrEmail;salesListColumns.tsx:383;SalesDocumentViewPage.tsx:433). A draft, a pending or a rejected document does not offer Print with options either. A voided document previews with its VOIDED stamp; the preview is always what the customer would get. - Preview: the page shows the real document, not a screen copy of it — a new route returns the receipt as HTML (
render_receipt_html,receipt_renderer.py:1073, already exists but has no route) and the page loads it in a frame, re-fetched 300 ms after the last tick. Only the final Print / Download builds the PDF through wkhtmltopdf.
What can be turned off — grouped as on the page; default in brackets. Everything else on the document is always printed.
| Group | Choice (label the user sees) | Key | Default | Notes |
|---|---|---|---|---|
| Header | Company logo | logo | on | |
| Header | Logo shape — Square / Wide (landscape) / Tall (portrait) | logo_shape | square | Not a checkbox: one of square · wide · tall (owner, 28 Sep 2026). The letterhead reserves a box of that shape (36×36, 120×34 or 30×52 px on A4) and the logo fits inside it, never stretched. Square and tall sit beside the company name; wide sits on its own line above the company name (owner, 28 Sep 2026). Shown under Company logo, only when the logo is on |
| Header | Company address & phone | company_address | on | Required on a Sales Invoice (a tax invoice must show the seller's address and SST number). Optional on a Sales Order. Seller name always prints |
| Customer | Deliver-to address | deliver_to | on | |
| Customer | Customer phone & e-mail | customer_contact | on | cust_contact_no, cust_email (there is no contact-person column). Customer name always prints |
| Customer | Sales rep name | sales_rep | off | New on the document (so off until the company turns it on — owner, 30 Sep 2026): "Sales rep · {name}" in the identity panel, from ft_sales_header.sales_rep_user_name. Off also blanks the name on the Sales Order's "Prepared by" signature line (today it prints sales_rep_user_name or cashier_user_name) |
| Items | Item code | item_code | on | |
| Items | Item barcode | item_barcode | off | New on the document: ft_sales_detail.item_barcode printed as text on the item's second line — not a scannable barcode image (owner, 30 Sep 2026). The LHDN QR stays a scannable image |
| Items | Item note | item_note | on | serial numbers, installation notes |
| Items | Unit price | unit_price | on | Required on a Sales Invoice (price per item). On a Sales Order turning it off also hides Discount |
| Items | Discount | discount | on | Forced off when Unit price is off |
| Items | Tax code & rate | tax_code | on | Optional even on a Sales Invoice (owner, 30 Sep 2026): it only hides the code on each line; the tax amount in the totals always prints. The template prints the rate only today (:375) — the code is a new template cell |
| Totals & payment | Subtotal, discount and tax lines | totals_breakdown | on | Required on a Sales Invoice (the tax amount must show). Total always prints |
| Totals & payment | Amount paid & balance due | payments | on | Amount due always prints |
| Totals & payment | LHDN e-Invoice QR | einvoice_qr | on | Required on a Sales Invoice when the store has e-Invoice on; the choice cannot force it to appear otherwise |
| Footer | Signature lines (Prepared / Approved / Received) | signature_lines | on | Sales Order only; the row is hidden on an invoice |
| Footer | Document note | doc_note | off | New on the document: the note typed on the order / invoice (ft_sales_header.notes, never printed today), printed as "Note: …" above the terms. Off until the company turns it on (owner, 30 Sep 2026) |
| Footer | Notes & terms | notes_terms | on | The disclaimer / terms text from the footer file — what prints today |
| Footer | Bank details | bank_details | off | New on the document, so off until the company turns it on (owner, 30 Sep 2026). company.bank_id and company.bank_acc_no (tables_pg.sql:169-170); the bank name comes from LEFT JOIN bank b ON b.bank_id = c.bank_id → b.bank_name (tables_pg.sql:44-46) — company_sql must select all three. Nothing else exists, so no "payment instructions" text |
Not a choice, so not on the page (owner, 30 Sep 2026 — the "Always included" card was removed as noise): document number, date, seller name, customer name, total and amount due always print. On a Sales Invoice the four rows marked Required above are not choices either — they never appear in the checklist and the counter does not count them. They are forced on when the company setting would turn them off (forceRequired(opts, doc_type) on the page; the renderer applies the same rule server-side so a hand-crafted request cannot drop them). A Sales Order is not a tax invoice, so nothing extra is required there. The page has no document-type switch: it opens from the Print button of one document, so the type is known. With every choice off, the document still prints the fixed items plus one line: "Nothing else is included on this print."
Buying side — Purchase Order, Goods Received Note, Purchase Return (owner, 29 Sep 2026). The same page, the same rules, the same storage key, opened from the Print button of a purchase document. Mockup: mockup/purchase-print-options.html (the dark shell bar flips between the three documents for review; the page itself has no switch). The documents themselves are the Option A sheets in mockup/purchase-print-email-formats.html. Not buildable yet: the Back-Office has no purchase routes or print path today (no api/purchase_*.py, no purchase receipt in the report module) — this section is the standard the purchasing build follows, in the purchasing plan's own order.
| Group | Choice | Key | PO | GRN | PR | Notes |
|---|---|---|---|---|---|---|
| Header | Company logo (+ logo shape) | logo, logo_shape | on | on | on | |
| Header | Company address & phone | company_address | on | on | on | Buyer company name always prints |
| Supplier | Supplier address | supplier_address | on | on | on | Supplier name always prints |
| Supplier | Supplier contact person & phone | supplier_contact | on | on | on | |
| Supplier | Buyer name (staff who raised it) | buyer_name | on | on | on | |
| Delivery | Deliver-to address (warehouse) | deliver_to | on | — | — | |
| Delivery | Expected delivery date | expected_date | on | — | — | |
| Delivery | Payment terms | payment_terms | on | — | — | |
| Items | Item code | item_code | on | on | on | |
| Items | Item barcode | item_barcode | off | off | off | |
| Items | Item note | item_note | on | on | on | |
| Items | Unit price | unit_price | on | — | on | PO: off also hides Discount |
| Items | Discount | discount | on | — | — | |
| Items | Tax code & rate | tax_code | on | — | — | |
| Items | Batch / expiry | batch | — | on | on | |
| Items | Ordered vs received difference | qty_difference | — | on | — | |
| Totals | Subtotal, discount and tax lines | totals_breakdown | on | — | — | Order total always prints |
| Totals | Other costs (freight, handling) | other_costs | on | — | — | |
| Totals | Condition on arrival | condition | — | on | — | |
| Totals | Return reason | return_reason | — | — | on | |
| Footer | Signature lines | signature_lines | on | on | on | PO / PR: Prepared · Approved · Supplier acknowledgement. GRN: Received · Checked · Delivered by |
| Footer | Notes & terms | notes_terms | on | on | on |
Fixed on the buying side (never a choice, not shown on the page): document number, date, buyer company name, supplier name, total; on a GRN the purchase order reference; on a purchase return the original PO / GRN reference (a return must point at what it returns). No tax-invoice set here — a PO, GRN or PR is not a tax invoice. Copies, remark and the e-mail rule are the same as the selling side.
Storage — one new key in CompanySettingsJson (backend/app/models/company_settings.py) inside company.company_settings_json, per document type so an order, an invoice, a purchase order and so on can differ (one key for both sides — DOC_PRINT_OPTIONS; company-level, not store-level, since 30 Sep 2026):
"DOC_PRINT_OPTIONS": {
"SALES_ORDER": { "logo": true, "logo_shape": "square", "item_barcode": false, "signature_lines": true, "...": true },
"SALES_INVOICE": { "logo": true, "logo_shape": "wide", "item_barcode": false, "...": true },
"PURCHASE_ORDER": { "...": true }, "GOODS_RECEIVED": { "...": true }, "PURCHASE_RETURN": { "...": true }
}
- Missing key or missing entry = the defaults above (so no migration and no back-fill; existing companies print exactly as today).
- The receipt path reads
company_settings_jsontoday only for its own two keys; the receipt query (report_query.py,company_sql) must selectcompany_settings_jsonsoreceipt_renderer.pycan resolve requestprint_options› company key › defaults. No store lookup at all. - Add the key to
app/core/data/company_settings_default.json(the seed every new company gets, used by both signup and provisioning) with the shipped defaults. There is no test today that checks the seed file, the backend model and the frontend defaults agree — add one in this PR (§3.11a). - Update the column comment at
db-postgresql/tenants.schema/tables_pg.sql(company_settings_json, line ~228) in the same PR.
API and renderer (rewritten 30 Sep 2026 after verification against the code — every path checked)
Request contract — one object, print_options, used everywhere:
{ "logo": true, "logo_shape": "square", "company_address": true, "deliver_to": true, "customer_contact": true,
"sales_rep": false, "item_code": true, "item_barcode": false, "item_note": true, "unit_price": true,
"discount": true, "tax_code": true, "totals_breakdown": true, "payments": true, "einvoice_qr": true,
"signature_lines": true, "doc_note": false, "notes_terms": true, "bank_details": false,
"copies": 1, "copy_stamp": false, "remark": "" }
The 19 choice keys are exactly the Key column above (the mockup's data-key attributes carry the same names). copies 1–5, copy_stamp bool, remark ≤ 120 chars — print-time only, never stored. Precedence is per document type and whole-object: request print_options › company_settings_json.DOC_PRINT_OPTIONS[<type>] › shipped defaults; the page always sends the full object it shows (as the print_options query value on the GET routes, as a body field on send-email). Type map: SALES_ORDER ↔ doc_type SALES_ORDER, SALES_INVOICE ↔ doc_type SALES_INV.
Back-Office (apps/v2-sellable-bo/backend):
GET /api/sales-orders/{id}/receiptandGET /api/sales-invoices/{id}/receipt(api/sales_orders.py:387,api/sales_invoices.py:172— both routes carry no query value today and inherit the router-levelsales.*.viewpermission,sales_orders.py:49,sales_invoices.py:53): add one optional query valueprint_options(the JSON object URL-encoded — one value, built withJSON.stringify, about 900 characters), parsed by a small shared dependencyparse_print_options(print_options: str | None = Query(None)) -> PrintOptions | Noneinapi/_deps/print_options.py(new) that turns a JSON error or a PydanticValidationErrorintoBadRequestError— there is no precedent for JSON in a query value in the Back-Office (request_parser.py:370handles only page/search/sort/filter), and a baremodel_validate_jsoninside a handler would surface as a 500. Frontend:lib/api.tsopenReceipt(:2166invoice,:2280order) gains the argument; both throw today when the answer is notapplication/pdf(:2180, :2294), so the preview cannot reuse them (see below).- New
GET /api/sales-orders/{id}/receipt-previewand…/sales-invoices/{id}/receipt-preview, same query value, same router permission and the sameassert_receipt_access()(sales_order_service.py:405,sales_invoice_service.py:491) as/receipt; answerstext/html. Plumbing that must be named because every piece is PDF-only today: a newreport_api_client.fetch_receipt_html(...)(_build_url,:27-29, takes no suffix — add one), a newlib/api.tsmethodfetchReceiptPreview(salesId, printOptions)returning the HTML string, and on the report side a new controller + service branch (below). The preview shows one copy without the stamp; copies are a print-time concern. - New
GET /api/sales-orders/{id}/print-optionsand…/sales-invoices/{id}/print-options(router permissionsales.order.view/sales.invoice.view; callsassert_receipt_access()first, so another company's id is a 404 like/receipt): returns{ options, required, can_save_company_default }— the company setting for that document type merged over the shipped defaults, the required keys for that type (invoice:company_address,unit_price,totals_breakdown,einvoice_qr), and whether the caller holdsADMINISTRATION_COMPANY_MANAGEthroughrbac_service.has_permission(conn, current_user, P.ADMINISTRATION_COMPANY_MANAGE)(rbac_service.py:123, owner bypass included, so it agrees withusePermissions().can(); handler precedentapi/roles.py:207). Needed becauseGET /api/company-settings/requiresADMINISTRATION_COMPANY_VIEW(api/company_settings.py:33-34), which a sales user does not have. POST …/send-email(api/sales_orders.py:365,api/sales_invoices.py:150): add optionalprint_options: PrintOptions | Noneto bothSendEmailRequestmodels (models/sales_order.py:308,models/sales_invoice.py:359— they areextra="forbid", so the field must be declared; the third copy atmodels/sales_return.py:271is left alone) — a JSON body field, not a query value.send_order_email/send_invoice_email(today(conn, company_id, sales_id, to_email_override, current_user)) gain aprint_options=Nonekeyword and pass it tofetch_receipt_bytes()(services/report_api_client.py:106), which — likegenerate_receipt()(:62) and_build_body()(:40) — gains aprint_options=Nonekeyword (the other callers,api/einvoice.py:498andsales_return_service.py:352, use keywords and keep working). FrontendsendEmail(salesId, data)(lib/api.ts:2164, 2278) widensdatato{ to_email?: string; print_options?: PrintOptions }. Noprint_options= the company setting.- New
PATCH /api/company-settings/print-options, permissionADMINISTRATION_COMPANY_MANAGE, body{ "doc_type": "SALES_INVOICE", "options": { …the 19 keys… } }(extra="forbid"; copies/stamp/remark refused; an unknowndoc_typeis a 400). Answerssuccess_response(data={doc_type, options}). Repositorycompany_settings_repository.update_print_options()(new, besideupdate_company:54):
UPDATE company
SET company_settings_json = jsonb_set(
CASE WHEN jsonb_typeof(company_settings_json) = 'object' THEN company_settings_json ELSE '{}'::jsonb END,
'{DOC_PRINT_OPTIONS}',
CASE WHEN jsonb_typeof(company_settings_json->'DOC_PRINT_OPTIONS') = 'object'
THEN company_settings_json->'DOC_PRINT_OPTIONS' ELSE '{}'::jsonb END
|| jsonb_build_object($2::text, $3::jsonb),
true),
updated_by = $4, last_updated_src = $5, updated_date = NOW(), version = version + 1
WHERE company_id = $1 AND is_deleted = false
Two traps this SQL is written around (grill + critic, 30 Sep 2026). One: jsonb_set(…, '{DOC_PRINT_OPTIONS,<doc_type>}', …) creates only the last key of the path, so on a company with no DOC_PRINT_OPTIONS yet (every existing company — no back-fill) it returns 200 and writes nothing. Two: COALESCE(x->'KEY', '{}') does not catch a stored JSON null, and 'null'::jsonb || '{…}' gives an array [null, {…}], not an object. A stored null will exist: the Company Settings PUT writes model_dump() with no exclude_none (company_settings_service.py:225), so a new DOC_PRINT_OPTIONS: … | None = None field lands as "DOC_PRINT_OPTIONS": null on the first save. Hence the jsonb_typeof(…) = 'object' guards on both levels, and the service also drops the key from the dict when it is None before the PUT writes it. The document type is a bound parameter, never pasted into the SQL. The write is per document type, so saving the invoice default never touches the order or purchase defaults. updated_date / version / is_deleted follow the sibling updates (:101-102, 147-148) so the till's sync sees the change. Orphaned (read-only after migration) companies are already refused on every write to /api/company-settings by check_orphaned_company (main.py:299, 310, subscription_guard.py:86-89) — nothing to add. Last writer wins against a concurrent full PUT of Company Settings; accepted. There is no live-database test harness (docs/standards/testing.md:427: every DB call is mocked), so the gate is a SQL-string test on the repository (both jsonb_typeof guards present, the path is {DOC_PRINT_OPTIONS}, four parameters) plus one manual check on dev written into the PR's Testing section: PATCH on a company with no key, with "DOC_PRINT_OPTIONS": null, and with one type already set — then SELECT jsonb_typeof(company_settings_json->'DOC_PRINT_OPTIONS') must read object all three times.
- Company Settings screen: the key is not added to
_HIDDEN_SETTINGS_KEYS(that would copy the stored value over every save, so the screen's own Printing section could never change it —company_settings_service.py:233-235). Instead the service keeps the storedDOC_PRINT_OPTIONSonly when the incoming value isNone(a form that does not carry the key), and writes it when the form sends it. The Printing section on the Advanced tab is a hand-placedAccordionItemexactly likeDayshiftPaymentSection(AdvancedSettingsTab.tsx:717-730) — not anADVANCED_SECTIONSentry:SectionConfig(:73-83) holds only flatfieldsrendered byAdvField, which cannot render a per-document-type checklist. Register the section in Global Search (src/lib/global-search/targets/company-settings.targets.ts, BOCLAUDE.mdstep 5) and update the counts inpilot-targets.test.ts:23, 34.
Report service (apps/v2-sellable-api/src/modules/report):
ReceiptInput(report_model.py:121-132,extra="ignore"— today the field would be dropped silently): addprint_options: PrintOptions | None, where the nestedPrintOptionsisextra="forbid"with the same 19 keys + copies/stamp/remark as the Back-Office model. Two apps, two models, one contract: a test in each app compares its key set to a shared literal list (the same two-file ruleworkflow_serviceuses).report_service.py:257passes it torender_receipt_pdf(data, print_options)→_build_receipt_render(...), wrapped inasyncio.to_threadthe way the e-invoice path does (:268) — today the sync wkhtmltopdf call blocks the event loop, and copies multiply it by up to 5. Anydoc_typeother thanSALES_ORDER/SALES_INV(returns, credit notes, till receipts share this renderer,report_service.py:171-177) ignores both the request and the company key and prints exactly as today, with a test.- New route
POST /report/{report_type}/html(same registry, samereport_auth— Bearer orX-Api-Keywithcompany_idfrom the body,report_router.py:53-74, 118-122, the same trust as the PDF route) returningrender_receipt_htmloutput (receipt_renderer.py:1073, exists, no callers today) astext/htmlinline. It needs its own controller and service branch:generate_report_controller/generate_report_serviceare PDF-only and always answer an attachment (report_controller.py:90-97,report_service.py:251-257). Receipt types only (_RECEIPT_REPORT_TYPES,report_service.py:231), 404 for any other type.modules/report/CLAUDE.md:165-169listsreport_router.py,report_controller.pyandrenderers/as never-touch; this plan overrides that on purpose — one additive route plus the renderer and template changes below — and the P1 PR says so in its Critical Change Attention. report_query.py:company_sql(:467-485) must selectc.company_settings_json,c.bank_acc_noandb.bank_name(LEFT JOIN bank b ON b.bank_id = c.bank_id), and thecompanydict (:555-567) must carry them.items_sqlalready selectssd.item_barcode(:336); the header already carriessales_rep_user_name.receipt_renderer.py: resolve the options (request › company setting › defaults), pass oneshowdict pluslogo_shape,remark,copy_stampto the template. Today the renderer reads no company JSON at all —sales_receipt_disclaimer/sales_receipt_footercome fromstore_settings_json(:903-909, 985-986) and stay there; those two are not part of this feature.- Template
sales_document_receipt.html— gate what exists, add what is missing: - gate: logo (
:231), address block (:239-245), deliver-to (:324-339), contact (:317-321), item code (:365), item note (:367), unit price / discount / tax columns (:352-354, 373-375), totals (:411-440), payments (:446-460), QR (:387-405), signatures (:485-501); - new: sales rep line, barcode line, tax code cell, document note (
h.notes, never printed today), bank details block, remark line (last line of the body, above the footer file), ORIGINAL / COPY tag, logo box driven bylogo_shape(today a fixed 48×90 box,:233;logo_shapealso replaces the network probe_should_stack_logo,receipt_renderer.py:429-443, that fetches the image on every render). - escaping: the receipt Jinja environment has no autoescape (
receipt_renderer.py:172) and wkhtmltopdf runs with JavaScript on (:1114); only the footer texts are escaped by hand (:489, :496).remark,h.notes, the bank details and every other free text this feature adds go throughhtml.escapebefore the template, with a test that<script>in a remark comes out as text. Existing free text (item notes:367,ship_special_notes:337, names, addresses) is printed raw today and stays so — a known gap on the PDF side, outside this feature; the preview is sandboxed, so nothing in it can reach the Back-Office page. The preview on the page is shown withsrcDocin a sandboxed frame (sandbox="allow-scripts", neverallow-same-origin) — the SPA's CSP isdefault-src 'self'with noframe-src(index.html:15,public/staticwebapp.config.json:7), so an<iframe src=backend>would be blocked anyway;allow-scriptsis needed for the template's own layout script (:174). - Copies: the footer file numbers pages across the whole PDF and prints the disclaimer on the last page only (
_build_page_footer_html,:446, 519-531), so "repeat the body N times" would break both. Instead render one PDF per copy (each with its own page numbers and stamp) and join them withpypdf— not inapps/v2-sellable-api/requirements.txttoday (checked 30 Sep 2026), so add it in this PR. - A second consumer picks the company setting up automatically: the e-invoice mail in
modules/email/email_service.py:585-593renders the same receipt; it sends noprint_options, so it uses the company setting. Intended. - Store keys
DOUBLE_RECEIPTandRECEIPT_INCLUDE_CART_NOTE(till receipt) are untouched; for a Back-Office documentDOC_PRINT_OPTIONSwins. - No new permission code, no new table, no migration.
- Defaults do not change today's paper (owner, 30 Sep 2026). The three rows that are new on the document — sales rep, document note, bank details — default off, so a company that never opens the page prints exactly as today. Turning them on is a company choice under Company Settings › Advanced › Printing. The document note is its own row (
doc_note) so that Notes & terms keeps printing the existing disclaimer.
Screens, routes and permissions (CLAUDE.md rule 12)
- L1 page. New route
/sales/orders/:id/printand/sales/invoice/:id/print→SalesPrintOptionsPage(one page,kindprop likeSalesDocumentPage), wrapped inPermissionGuardwithsales.order.view/sales.invoice.view— printing is a read, the same leaf that gates/receipt. Entry points: the list row menu (salesListColumns.tsx, next to Print, samecanPrintOrEmailrule) and the view page header next to the print icon. - L2 buttons. Print and Download PDF:
sales.*.view. Send e-mail:sales.order.manage/sales.invoice.manage, as the existing send-email routes require (the unusedsales.invoice.emailleaf is left as it is). "Save as the company default": shown only whencan_save_company_defaultis true (ADMINISTRATION_COMPANY_MANAGE); everyone else sees the sentence "Only an admin with the Company Settings permission can change this." instead of the checkbox. The checkbox applies to whichever of the three actions is pressed. - L3 fields. None — nothing here is a sensitive column.
- Company Settings › Advanced › Printing: the same checklist per document type, no preview, saved with the page's full PUT.
Tests — the build is done when these pass
- Backend (
apps/v2-sellable-bo/backend/tests):PrintOptionsrefuses unknown keys, copies outside 1–5, a remark over 120; the query parser answers 400 (not 500) for bad JSON or a bad key;PATCH print-optionscreatesDOC_PRINT_OPTIONSwhen the company has none and writes only{DOC_PRINT_OPTIONS,<type>}, leaving the other types and every other company key unchanged (real database);GET print-optionsreturns the company entry merged over the defaults and the rightrequiredset per type, andcan_save_company_defaultfollows the permission; a Company Settings PUT that omits the key keeps the stored value, one that sends it writes it;send-emailforwardsprint_optionstofetch_receipt_bytes; a seed-file test thatcompany_settings_default.jsonkeys equalCompanySettingsJsonfields. - Report service (
apps/v2-sellable-api/tests/report/test_receipt_renderer.py): each of the 19 keys hides or shows its element; required keys cannot be turned off onSALES_INV;copies=2withcopy_stampgives two documents stamped ORIGINAL then COPY, each numbered from page 1; the remark prints once, above the footer; noprint_options= company setting = defaults when the key is missing. - Frontend (
src/pages/sales/__tests__): the page opens with the company setting; a required row is not in the list and the counter says "9 of 13"; Unit price off hides Discount on an order; the preview frame is re-fetched after a tick; the save checkbox is absent without the permission; Print / Download / Send e-mail each send the sameprint_options.
Screens — one mockup: mockup/sales-print-options.html — the checklist beside a live A4 preview, using the document and sample data from mockup/print-email-formats.html. A preset-card variant was built and dropped on 28 Sep 2026 (owner: Option A only).
Readiness — 30 Sep 2026: grilled against the code (API, renderer, DDL) and for completeness. The API/renderer part was rewritten from that check; screens, routes, permissions and tests were added; the mockup keys now equal the wire keys. Verdict: ready for the selling side. The one new dependency, pypdf in the report service, is named in the copies bullet. The buying side waits for the purchasing module.
Owner's answers — 30 Sep 2026 (the questions are closed)
- The item barcode prints as text under the item. The LHDN e-Invoice QR stays a scannable image.
- Tax code & rate may be turned off on a completed Sales Invoice — it only hides the code per line; the tax amount in the totals always prints. So the invoice's required set is four rows: company address, unit price, totals lines, LHDN QR.
- Defaults are per document type.
- The e-mail attachment always uses the same choices as the print. No separate checkbox.
3.11a Putting DOC_PRINT_OPTIONS in company_settings_json — what else it touches (checked 30 Sep 2026)
Read against the real code. One line per place; "safe" means nothing to change beyond adding the key.
| Place | What happens today | Risk | What to do |
|---|---|---|---|
Backend model CompanySettingsJson (backend/app/models/company_settings.py:54) | No extra= set, so unknown keys are ignored, not refused | Safe | Add DOC_PRINT_OPTIONS: DocPrintOptions | None = None with its own sub-models, the way DAYSHIFT_PAYMENT (:44-48) has DayshiftPayment |
Company Settings save (services/company_settings_service.py:224-237) | The PUT re-serialises every field with model_dump(). A form that does not carry a key writes that key's default over what is in the database | Real risk: an old Company Settings screen, or any caller that sends a partial object, wipes the print options | Do not put the key in _HIDDEN_SETTINGS_KEYS (:109-147): those keys are copied back from the stored row on every save (:233-235), so the screen's own Printing section could never change it. Instead: keep the stored value only when the incoming value is None, write it when the form sends it. Same PR as the model change |
Seed for a new company (core/data/company_settings_default.json) | JSON (already holds the nested DAYSHIFT_PAYMENT), loaded once by signup (signup_repository.py:24-25, 233-239) and by provisioning (tenant_provisioning_service.py:47-48, 276) — the same file, as docs/standards/company-seeding.md requires | Safe once the key is in the file. Without it a new company simply has no key and the defaults apply | Add the key with the shipped defaults. Add a unit test that every key in the file exists on CompanySettingsJson and vice versa — none exists today |
Frontend type CompanySettingsJson (frontend/src/types/company-settings.ts:20) and defaults (lib/constants/company-settings-defaults.ts:18) | COMPANY_SETTINGS_DEFAULTS must list every scalar key; object keys are left out through Omit<…, "DAYSHIFT_PAYMENT"> — the typecheck enforces this | Typecheck fails if the key is added to the type but not to the Omit | Add the field to the type and "DOC_PRINT_OPTIONS" to the Omit list. Give it its own hand-placed AccordionItem on the Advanced tab, like DayshiftPaymentSection (AdvancedSettingsTab.tsx:717-730); the ADVANCED_SECTIONS list (:88) only renders flat switches and inputs, so nothing there renders the new key |
Till and API readers (v2-sellable-api/src — general_query.py, store_query.py, company_query.py, auth_middleware.py, tenant_query.py) | Each reads named keys with ->>'KEY' or ->'KEY'; nothing ships the whole JSON to the till | Safe. A nested object under a new key is never cast or parsed by them | Nothing. If the till ever prints with these options, read ->'DOC_PRINT_OPTIONS' explicitly, as general_query.py:884 does for CASH_DENOMINATIONS |
Store-level fallback pattern (store_query.py:189-275, COALESCE(store…, company…)) | Exists for a few keys | Not used here — the owner chose company-level only | Nothing now. If a store override is ever wanted, this is the pattern to copy |
Receipt path (v2-sellable-api report_query.py company_sql, receipt_renderer.py) | Reads no company JSON today; its two receipt texts come from store_settings_json (:903-909, 985-986) | Safe | Select company_settings_json (plus bank_id, bank_acc_no) in company_sql (:467-485), carry them in the company dict (:555-567), read DOC_PRINT_OPTIONS in the renderer |
Setup wizard / onboarding (api/setup_guide.py, onboarding.py) | The 2 Jul 2026 wipe was fixed; they merge, not replace | Safe | Nothing |
Other Back-Office modules reading company_settings_json (stock settings, discount limits, dayshift payment, PSP threshold) | Each reads its own key | Safe | Nothing |
Bottom line: two things must land in the same PR as the key — the keep-when-None rule in the Company Settings save (otherwise an old screen wipes the print options on its next save) and the seed file entry with a key-set test. Everything else is additive.
3.11b Build order — print options for Sales Orders and Sales Invoices (planned 30 Sep 2026)
Four phases, each one pull request, each safe on its own if the next never ships. Start after Redy-20260910-BO-Invoice is merged: the working tree still carries uncommitted edits to sales_document_receipt.html and the sales services, and starting earlier means merge conflicts. Purchase documents are out of scope (§3.11).
| Phase | App | Goal | Size | What a user gets when it ships |
|---|---|---|---|---|
| P1 Renderer | v2-sellable-api | The receipt follows request print_options › DOC_PRINT_OPTIONS › defaults; copies, stamp, remark and the HTML route work | L · 3–4 days | Nothing visible yet (no BO sends options). A company with the key already set prints its way |
| P2 Company setting | v2-sellable-bo backend + frontend | The key on the model, the seed, the keep-when-None save rule, the PATCH, the Printing section on Company Settings › Advanced | M · 2 days | An admin sets the printing choices once; every Print button, the e-mail PDF and the e-invoice mail follow them |
| P3 Per-print API | v2-sellable-bo backend | ?print_options= on /receipt, receipt-preview, GET print-options, print_options on send-email | S–M · 1.5 days | Nothing new on screen; the API is ready for P4 |
| P4 Print with options page | v2-sellable-bo frontend | The full page: checklist, live A4 preview, Print / Download / Send e-mail, save as the company default | M–L · 3 days | The whole §3.11 flow |
About 10 dev-days, plus one for review fixes.
P1 — Renderer (apps/v2-sellable-api)
- Touch:
src/modules/report/report_model.py:121-132(print_optionsonReceiptInput, nestedPrintOptionsextra="forbid");report_service.py:251-257(pass it through;asyncio.to_threadaround the render, as:268does);report_query.py:467-485, 555-567(company_settings_json,bank_id,bank_acc_no);renderers/receipt_renderer.py(:717_build_receipt_render,:1073render_receipt_html,:1088render_receipt_pdf; resolve options,showdict,logo_shapebox, escaping, copies viapypdf);templates/sales_document_receipt.html(gate what exists, add what is new — the bulk of the work);report_router.py+report_controller.py(newPOST /{report_type}/html, receipt types only,text/htmlinline — the never-touch note inmodules/report/CLAUDE.mdis overridden on purpose);requirements.txt(+pypdf). - New:
src/modules/report/print_options.py— the 19 keys, shipped defaults per document type, the required set per type, theSALES_INV↔SALES_INVOICEmap, and the resolver (resolve_print_options(request, company_json, doc_type)). - Gate:
tests/report/test_receipt_renderer.py— each key hides or shows its element; required keys cannot be turned off onSALES_INV;copies=2+copy_stampgives two documents stamped ORIGINAL then COPY (asserted on the rendered HTML of each copy — the page numbers restart because each copy is its own wkhtmltopdf run, which the 53 renderer tests never invoke; the joined PDF is checked by hand on dev, page count = 2 × pages); the remark prints once above the footer and<script>in it comes out as text; noprint_options= company key = defaults when the key is missing, and the default output equals today's receipt byte for byte on the gated rows; aSALES_RETURNdocument ignores everything. Newtests/report/test_receipt_html_route.py— receipt type answers HTML, another type 404, the auth is the same as the PDF route. - Safe alone: the three rows that are new on the document default off (owner, 30 Sep 2026), so a company that never touches the page prints as today, with or without the key.
P2 — Company setting (apps/v2-sellable-bo, both sides)
- Touch backend:
app/models/company_settings.py:54(DOC_PRINT_OPTIONS: DocPrintOptions | None = None, sub-models likeDayshiftPayment:44-48);app/core/data/company_settings_default.json(the shipped defaults);services/company_settings_service.py:224-237(keep the stored value when the incoming one isNone, write it when sent — not_HIDDEN_SETTINGS_KEYS);repositories/company_settings_repository.py(update_print_options, the SQL in §3.11);api/company_settings.py(PATCH /print-options,ADMINISTRATION_COMPANY_MANAGE);db-postgresql/tenants.schema/tables_pg.sql(~:228column comment). - Touch frontend:
src/types/company-settings.ts:20;lib/constants/company-settings-defaults.ts:18(Omitlist);pages/company/tabs/AdvancedSettingsTab.tsx:717-730(a hand-placedPrintingSectionAccordionItembesideDayshiftPaymentSection);src/lib/global-search/targets/company-settings.targets.ts(+ the counts inpilot-targets.test.ts:23, 34). - New:
app/core/print_options.py+app/models/print_options.py(the Back-Office copy of the contract: keys, defaults, required set,PrintOptionsextra="forbid");frontend/src/lib/constants/print-options.ts(keys, groups, labels,forceRequired);frontend/src/pages/sales/components/PrintOptionsChecklist.tsx(the checklist without the preview — reused by P4 with it). - Gate:
tests/unit/services/test_company_settings_service.py(PUT that omits the key keeps it, PUT that sends it writes it);tests/unit/repositories/test_company_settings_print_options_db.py(real database: the PATCH creates the parent key on a company that has none, leaves the other types alone); newtests/unit/models/test_company_settings_seed.py(every key in the seed file is a model field and vice versa — none exists today); newtests/unit/models/test_print_options.py(unknown key, copies out of range, remark too long; key set equals the shared literal);npm run typecheck:ratchetunchanged or lower; the Advanced tab test shows the section and saves.
P3 — Per-print API (apps/v2-sellable-bo/backend)
- Touch:
api/sales_orders.py:365, 387,api/sales_invoices.py:150, 172(?print_options=through the shared dependency; newreceipt-previewandprint-optionsroutes on the same routers, so they inheritsales.*.view);models/sales_order.py:308,models/sales_invoice.py:359(print_optionsonSendEmailRequest);services/report_api_client.py:27-29, 40, 62, 106(print_options=Nonekeyword on_build_body/generate_receipt/fetch_receipt_bytes; newfetch_receipt_html; URL suffix);services/sales_order_service.py,services/sales_invoice_service.py(print_options=Noneonsend_*_email, newget_print_optionsthat callsassert_receipt_access()then the resolver +rbac_service.has_permission). - New:
api/_deps/print_options.py(parse_print_options→ 400 on bad JSON or bad key);services/print_options_service.py(merge company key over defaults, required set per type). - Gate: new
tests/unit/api/test_sales_print_options_api.py(both document types; bad JSON is 400;receipt-previewandprint-optionsrefuse another company's id with 404); the send-email tests checkprint_optionsreachesfetch_receipt_bytes;tests/unit/test_call_sites_match_their_signatures.pystays green.
P4 — Print with options page (apps/v2-sellable-bo/frontend)
- Touch:
src/App.tsx(/sales/orders/:id/print,/sales/invoice/:id/print,PermissionGuardsales.order.view/sales.invoice.view);lib/api.ts(openReceiptwith options:2166, :2280; newfetchReceiptPreview;sendEmaildata widened);pages/sales/components/salesListColumns.tsx:386(Print with options… next to Print, samecanPrintOrEmailrule);pages/sales/SalesDocumentViewPage.tsx:556(header action next to the print icon). - New:
pages/sales/SalesPrintOptionsPage.tsx(one page, amodeprop the waySalesDocumentPageis mounted twice inApp.tsx:369, 375; sticky footer ‹ Back · Download PDF · Send e-mail · Print; counter; sticky scaled A4 preview in a sandboxedsrcDocframe re-fetched 300 ms after the last tick; "Save as the company default (all stores)" only whencan_save_company_default);hooks/use-print-options.ts(usePrintOptions(kind, id),useReceiptPreview,useSavePrintDefaults);pages/sales/__tests__/SalesPrintOptionsPage.test.tsx. - Gate: the page opens with the company setting; a required row is not listed and the counter reads "9 of 13"; Unit price off hides Discount on an order; the preview is re-fetched after a tick; the save checkbox is absent without the permission; Print, Download and Send e-mail each send the same
print_options;typecheck:ratchetunchanged or lower.
Deploy order and what breaks if it is wrong
- P1 goes live before any BO phase. P2 and P3 may be built alongside it but deploy after it. P4 never ships before P1.
- BO new, report service old:
print_optionsin the body is dropped silently (ReceiptInputextra="ignore",report_model.py:132), so the PDF comes out as today — harmless. ButPOST …/htmldoes not exist, so the P4 preview gets a 404 (/{report_type}cannot match a path with a/). That is why P4 waits. - P2 before P1: the company setting saves but changes nothing on the paper. Harmless, confusing; deploy P1 first.
- Report service new, BO old: no
print_optionsis sent, so the company key (or the defaults) is used. Safe.
Risks and the answer to each
- Unescaped free text in the PDF and the preview —
html.escapeon every new text in P1, test with<script>; sandboxedsrcDocframe in P4 (§3.11 "escaping"). - The PATCH writes nothing for existing companies — the
||-merge SQL in §3.11, tested against a real database with the key missing (P2). - Copies block the event loop —
asyncio.to_threadaround the render,logo_shapereplaces the network logo probe, each copy is its own PDF joined withpypdfso page numbers restart (P1). /htmlwidens the report surface — receipt types only, samereport_auth, and the BO calls it only afterassert_receipt_access()(P1 + P3).- A partial Company Settings PUT wipes the key; a missing TS
Omitentry raises the typecheck count — keep-when-Nonerule, seed test andtypecheck:ratchetall inside P2.
3.12 Reports and the back-office sale — no business date, no register (reviewed 29 Sep 2026)
The fact. A sale made at the till belongs to a shift (ft_sales_header.dayshift_id), and the shift carries the business date (ft_dayshift.business_date) and the register. A sale made in the Back-Office has no shift: the insert (repositories/sales_commit_repository.py, INSERT INTO ft_sales_header) sets neither dayshift_id, register_id nor register_code. There is no business_date column on the sales table itself — every report that shows a business date gets it through the shift, so for a Back-Office sale it is empty. The document date (doc_trx_date, never empty) is the only date every sale has.
What is affected and what to do — one line per report, plain words.
| Report / screen | What you see today | Why | Fix | Size |
|---|---|---|---|---|
| Sales Summary (Reports) | Cannot open — the page errors as soon as one Back-Office sale is in the date range | The grid is grouped by business date. A Back-Office row comes back with an empty business date, and the response model (models/sales_summary.py, business_date: date) refuses an empty value, so the request fails. The quantity and payment groups also never match the header group (empty ≠ empty), so even if it opened, that row would show zero quantity and zero paid | In repositories/sales_summary_repository.py use COALESCE(d.business_date, h.doc_trx_date::date) as the business date in all four groups (header, qty, pay, cn — lines ~163, 223, 267, 357), in the master join keys (~514–522) and in the per-bill drill-down filter (~710, AND d.business_date = $4). Make business_date optional in the three response models as a safety net. Register stays "—" (already handled, COALESCE(register_id, 0)) | S — 1 file + 1 model, half a day with tests |
| Sales Details by Item (Reports) | Cannot open for the same reason | Same required business_date: date in models/sales_details_by_item.py; the query already LEFT JOINs the shift (sales_details_by_item_repository.py ~157, 182, 192) so the row is there, but empty | Same COALESCE(d.business_date, h.doc_trx_date::date) in the select and the ORDER BY; model optional | S |
Sales Invoices / Sales Orders / Sales Returns exports (v2-sellable-api report module, reports_sales.py ~205, 278, 357) | Open fine; the "Business Date" column is blank for Back-Office documents | LEFT JOIN on the shift; sorted by document date, so nothing breaks | Same COALESCE in the three selects so the column is never blank | S |
Sales Orders / Sales Invoices / Sales Returns list and view pages (salesListColumns.tsx, DetailsTab.tsx) | "Business Date" shows "—" for a Back-Office document | Models already allow empty | Either show the document date there with a hint, or drop the column from the Back-Office lists. Owner to choose | XS |
Printed order / invoice (receipt_renderer.py ~624, 748) | The receipt header carries an empty business date field | Same source | Print the document date; the print template does not show the business date today, so no visible defect | XS |
Shift Day / Business Day pages and reports (business_day_repository.py, ShiftDayPage, report_query.py ~176) | Back-Office sales never appear | These are shift reports: they list shifts, not sales. A Back-Office sale has no shift | No change. This is correct — Back-Office money is not till cash. See "Do not do" below | — |
X report, Z report, shift summary, day close (DB functions fn_api_dayshift_get_xreport_shift_summary, fn_api_get_shift_summary, fn_api_dayshift_close_business_day, fn_api_set_dayshift_payment) | Back-Office sales are not counted | Every one filters fsh.dayshift_id = p_dayshift_id | No change, same reason | — |
| Sales Collection, Void Bill, Cash Movement, Price Override, Credit Note, Exchange Item, Salesperson Performance, Dashboard | Work as before | They use the document date or are not sales reports | None | — |
Do not do: attach Back-Office sales to a real register or shift, or invent an "HQ register" shift. Every shift function above would then add Back-Office revenue to that register's expected cash with no cash counted, and every shift close would show a shortage. Leaving dayshift_id and register_id empty is right; the reports must stop assuming every sale has a shift.
Rule for every report from now on: business date of a sale = COALESCE(shift business_date, doc_trx_date::date); register = "—" when there is none; never INNER JOIN ft_dayshift from a sales table. Add this line to docs/standards/db-schema-reference.md.
Later, if we want one column instead of a rule: add business_date date to ft_sales_header, filled at insert (Back-Office: the document date; till: copied from the shift by the existing sync path) and back-filled once. Then every report reads one column. Bigger change (partitioned table, sync path, back-fill), not needed to fix the reports above.
Done 29 Sep 2026 (this branch, not yet merged): the rule lives in one constant, BUSINESS_DATE_EXPR in backend/app/core/sales_doc_semantics.py, and every query imports it — Sales Summary (four groups, master joins, drill-down), Sales Details by Item (select + sort), the Sales Orders / Sales Invoices / Sales Returns list and view queries, View Sales (list, export, business-date filter and sort), and the three API exports in reports_sales.py. The two response models accept an empty date as a safety net. The list-page column now shows the document date for a Back-Office document (chosen over dropping the column — same rule everywhere). Rule recorded in docs/standards/db-schema-reference.md §4. Tests: tests/unit/repositories/test_sales_summary_repository.py (TestBackOfficeSaleHasNoShift) and the new test_sales_details_by_item_repository.py assert the fallback in every SQL; 231 related backend tests pass. Not changed: shift reports, X/Z, day close (correct as they are); the printed receipt (never showed the business date).
4. API (as built)
backend/app/api/sales_orders.py
| Method | Path | Permission |
|---|---|---|
| GET | /api/sales-orders · /{sales_id} · /dropdowns · /{sales_id}/receipt | sales.order.view |
| POST | /api/sales-orders (Idempotency-Key) · /calculate · /validate-coupon | sales.order.manage |
| PUT | /api/sales-orders/{sales_id} · /{sales_id}/cashier | sales.order.manage |
| POST | /{sales_id}/confirm · /reconfirm · /cancel · /deposit · /deposit/refund (both Idempotency-Key) · /send-email | sales.order.manage |
| POST | /{sales_id}/convert | sales.order.manage and sales.invoice.manage |
backend/app/api/sales_invoices.py
| Method | Path | Permission |
|---|---|---|
| GET | /api/sales-invoices · /{sales_id} · /dropdowns · /sales-reps · /{sales_id}/receipt · /{sales_id}/payments · /{sales_id}/returnable | sales.invoice.view |
| POST | /api/sales-invoices (Idempotency-Key) · /calculate · /validate-coupon · /{sales_id}/complete (Idempotency-Key) · /{sales_id}/cancel · /{sales_id}/send-email | sales.invoice.manage |
| GET | /giftcards/{giftcard_code} | sales.invoice.manage |
| PUT | /{sales_id} (notes, PO no.) · /{sales_id}/draft (full draft edit) · /{sales_id}/cashier | sales.invoice.manage |
| POST | /{sales_id}/payments (Idempotency-Key) · /{sales_id}/payments/{sales_payment_id}/void | sales.invoice.manage |
backend/app/api/sales_requests.py
| Method | Path | Permission |
|---|---|---|
| GET | /api/sales-requests?key=&route=&doc_date= | sales.order.view or sales.invoice.view |
Errors are the standard ApiResponse envelope; conflicts carry data.reason (e.g. STAGE_NOT_ALLOWED, already-converted).
5. Permissions
No new permission code. Existing leaves in backend/app/core/permissions.py:
| Layer | Code | Gates |
|---|---|---|
| L1 page | sales.order.view, sales.invoice.view | list + detail pages, GET routes |
| L2 button | sales.order.manage | New Sales Order, Edit (draft), Confirm, Cancel, Record Deposit, Refund Deposit, Change Sales Rep, Send Email (order) |
| L2 button | sales.invoice.manage | New Sales Invoice, Save draft, Edit (draft), Pay & complete, Cancel Invoice (draft), Edit Details, Record Payment, Change Sales Rep; Convert to Invoice needs both .manage leaves |
| L2 button | sales.invoice.email | Send Email row action on the invoice list only. The detail-page Send Email button and the backend send-email route use sales.invoice.manage. |
| L3 field | sales.order.field.cost, sales.invoice.field.cost | Cost / Margin columns on the detail grids |
Store scope: every read narrows to effective_store_ids; every write on a record calls require_record_store. The subscription guard is on the routers as everywhere else.
6. Screens
§6.5 is the target — the mockup, page by page, is the standard the build follows. §6.1–6.3 record what is on the pages today so the diff is visible.
Routes (frontend/src/App.tsx):
| Route | Page | Guard | Query params |
|---|---|---|---|
/sales/orders | SalesOrderPage | sales.order.view | from, to, store, doc_sub_type, page |
/sales/orders/new · /sales/orders/:id/edit | SalesDocumentPage mode="order" | sales.order.manage | edit: Back-Office draft only |
/sales/orders/:id | SalesOrderDetailPage | sales.order.view | mode, action=change-sales-rep|send-email |
/sales/invoice | SalesInvoicePage | sales.invoice.view | from, to, store, page |
/sales/invoice/new · /sales/invoice/:id/edit | SalesDocumentPage mode="invoice" | sales.invoice.manage | edit: Back-Office draft only |
/sales/invoice/:id | SalesInvoiceDetailPage | sales.invoice.view | mode, action=change-sales-rep|edit|record-payment|send-email |
The "New …" buttons on both list pages sit behind SALES_WORKBENCH_ENABLED (frontend/src/lib/constants/sales.ts, currently true). The two create routes are not behind the flag — only the permission guard — so a direct URL still opens them when the flag is off.
6.1 List pages (as built today — target in §6.5)
- Sales Orders — title "Sales Orders", subtitle "View and export sales order transactions.", button "New Sales Order". Filters: date range (default today), Stores, Order Sub Types; quick filter on status. Status words are the same everywhere (list, detail pill, Audit tab): Draft · Pending · Completed · Dispatched · Voided · Returned · Cancelled — never "Opened" or "Canceled". Row actions: View · Edit (Draft, → edit page) · Convert to Invoice (Draft/Confirmed; on a Draft it calls
/confirmfirst, then/convert; shown onsales.invoice.managealone, so a user withoutsales.order.managegets a 403 on the hidden confirm step) · Record Deposit (?action=record-deposit, Draft/Confirmed) · Refund Deposit (?action=refund-deposit, when a deposit is held) · Print Receipt · Change Sales Rep · Send Email · Cancel Order (Draft/Confirmed). Columns "Latest Deposit" and "Total Deposit". - Sales Invoices — title "Sales Invoices", subtitle "View and export sales invoice transactions.", button "New Sales Invoice". Two columns: Status (the document: Draft · Pending · Completed · Voided · Cancelled) and Payment Status (
stage_label+ Overdue chip: Pending Payment · Partial Payment · Paid; "—" for a draft, cancelled or voided invoice). The document turns Completed only when the payment status reaches Paid. Row actions: View · Edit (draft, → edit page) · Edit Notes (?action=edit) · Record Payment (?action=record-payment, Pending Payment only) · Cancel Invoice (draft) · Print Invoice · Change Sales Rep · Return Items · Send Email. Status filter shows plain words (Draft, Pending Payment, …).
6.2 Create page — SalesDocumentPage
> Everything marked (owner, 16 Sep 2026) below was decided on the mockups on 16 Sep 2026 and is not yet in the app — the built page still matches Revision 20 there. The code work is listed in §9 G17.
One page for both modes, in create and edit (:id/edit, Back-Office drafts only; the store is read-only in edit mode and the update carries version). No stage strip, "next step" hint, badges or helper lines on the form — the document is always a Draft here (owner, 15 Sep 2026). Title "New Sales Order" / "New Sales Invoice" (edit: "Edit Sales Order {ref}" / "Edit Sales Invoice {ref}"), no status badge next to the title (owner, 16 Sep 2026), a "Preview PDF" button at the right of the title; subtitle "Create and confirm a back-office sales order." / "Create a back-office sales invoice. Save it as a draft, or take full payment to complete it.". Spacing is compact (owner, 21 Sep 2026): grid cells py-2, panel bodies pb-3, space-y-3 between panels, 2.25 rem inputs, title and subtitle on one line — so the whole form needs less scrolling. Every panel is the standard CollapsibleSection (title left, chevron right, px-4 content) — "Sales Order Details" / "Invoice Details", "Order Items" / "Invoice Items" (badge "N lines · N units", header action "Add Items"), "More Details" (the Discounts · Taxes · Delivery · Notes tab strip lives inside it — the Deposit / Payments tab left on 21 Sep 2026: no money is taken on a draft), "Totals".
- Header panel ("Sales Order Details" / "Invoice Details"), six-column grid with every row full: Customer (search; required — the suggestion list always ends with "Add … as a new customer", which opens a four-field panel in place: Name*, Mobile, Customer group* (default Retail), Email; "Save customer & use" creates the customer with an automatic code and selects it; addresses come later via the customer page — owner, 21 Sep 2026; the same quick-add sits on step 1 of the step form) · Store (required; the list holds online stores only) / Date · Payment due (order only) · Sales rep / Note (full width; labelled "Printed note" until 16 Sep 2026). No helper text under the fields. (Customer PO no., Delivery window and Project / Cost centre were removed on 15 Sep 2026; the
custom_fieldcolumns they used are no longer touched by this module — G18.) - Items panel ("Order Items" / "Invoice Items"): a
DataTable(the table standard, like every other table on the page), sorting and the filter row switched off because the line order (No) is the user's own. Columns No · Item Code · Barcode · Item Name · UOM · Qty · Item Price · Disc · Net — Tax Type, Tax and Total were dropped from the grid (owner, 16 Sep 2026); tax and the document total live in the summary card (Discounts tab; the step form shows it under the grid). One font size throughout, numbers right-aligned. Disc / unit (header renamed 21 Sep 2026 so nobody guesses) is a per-unit figure — the same basis as the till (new_sale_order_controller.dartsetDiscItem: unit price − discount,totalDiscAmt = itemDisc × qty), the BO server (sales_calculation_service.pygross = (unit_price − item_disc_amt) × qty) andft_sales_detail.item_disc_amt; RM 100 on qty 5 = RM 500 off the line. Beside the box sits an RM | % segmented switch (active side filled, same width on every row; clicking the active side does nothing): switching converts the number so the discount stays the same (15% of RM 250 ↔ RM 37.50). Under the box a small grey line spells it out — "= RM 2.10 each · RM 84.00 line" in % mode, "RM 120.00 line" in RM mode when qty > 1. The review step's read-only grid shows the line figure under the header Line Disc. Net = price × qty − the line's own discount only (the cart discount is not taken off the line; owner, 16 Sep 2026); the footer adds Qty, the total item discount (under Disc) and Net. The summary card reads Total Net · Total Cart Disc (shown negative, red) · Tax block (one row per rate, Total Tax) · Total. Tracked items — an item is normal, serialized (item.is_serialized) or batch-tracked (item.is_batch_tracked), at most one (the item screen disables the other;stock_on_hand.tracking_typecarries the NORMAL / SERIAL / BATCH CHECK): a chip under the item name (amber until complete, green with a tick when done) opens the line details sub-row; a SERIAL line's chip opens a dedicated Serial Numbers page and a BATCH line's chip a Batch Numbers page — both the same shape as the built Purchase Order pages (owner, 21 Sep 2026, screenshots in the owner'sDesktop/serial and batchfolder): title + subtitle ("EQ-STOVE-3B — Serial. One serial number for each unit." / "OIL-PALM-17 — Batch. Split the 24 units across the batches they come from."), one card with the count line ("2 of 50 entered" / "30 of 24 allocated — 6 too many"; amber, green when complete, red when over) and the helper "You can save what you have and come back. The order cannot be submitted for approval until …", then numbered rows — serial: one text box per unit with a bin, "+ Add another" (greyed once there are Qty rows; Enter on the last row adds the next), and a "Paste a list" ↔ "Enter one by one" switch (textarea "One serial number per line" + "Use this list"; duplicates and extras beyond Qty dropped and reported); batch: rows of [batch number][quantity][bin] with "+ Add batch". Unlike the PO page the Back and Save buttons sit in the pinned footer, not at the top / inline (owner). Save tidies the rows and refuses a duplicate serial, more serials than Qty, a batch quantity without a number, or a split larger than Qty; Back throws the edits away. The line-details block summarises serials as chips ("1 of 2" pill, "1 missing") and batches as "RB-2609-15 · 10" chips ("24 of 24 ✓" pill) with a "Key / Edit serial numbers" / "Key / Edit batch numbers" button to the page; the chip under the item reads "Serial numbers 1/2" / "Batches 10/24" / "Batch RB-2609-14 ✓" / "2 batches ✓"; a NORMAL line has no chip. In the step form, Confirm order / Pay & complete is blocked while a tracked line is short and the Review step lists the serials / batch under the item name; the as-built and flow mockups only show the chip states. The Disc column is right-aligned end to end — header, input + RM/% toggle, footer total; the toggle reads RM on the lines and on the cart discount alike (owner, 16 Sep 2026 — the built page shows the currency code "MYR" on lines and "RM" on the cart). The only row button is Remove line (bin icon); clicking anywhere else on the line (No, code, barcode, name, UOM, Net — not the Qty / Price / Disc boxes, the RM | % switch or the chips, which keep their own job) opens or closes its details, and the open line is tinted (owner, 21 Sep 2026: a "⋯" menu or a second icon for two actions wastes a click; the builtDataTablerow-click pattern is reused). "Line details" is inline, not a dialog (owner, 16 Sep 2026): it opens a details block right under the row on the same light-blue tint as the open line (title "Line N details · code · name", primary Done on the right; stacked: the Note box, then the tracking block — serial numbers as chips with a "n of N" pill and "Key / Edit serial numbers" on one row, or the batch number box with a "Needed / Entered ✓" pill; a NORMAL line shows the Note alone), the same sub-row the serial chip opens, holding the serial summary + "Key serial numbers" button (SERIAL items) or the batch number box (BATCH items), plus one general Note on every line (printed under the item; no warranty field — owner, 16 Sep 2026), saved to the line'snotes,serial_no,batch_no— nocustom_fieldcolumn; "Done" closes it, and the note then shows as a small grey line under the item name (the batch shows in the chip) — and "Remove line"; footer totals; the add-line search row sits above the grid (owner, 16 Sep 2026); one "Add Items" header button swaps the whole page for theItemSelectioncatalogue picker (title · search · tick rows · a bottom bar with Back and "Add N items to the order" — owner, 21 Sep 2026), the same full-page pickerSalesDocumentPageuses today — never a dialog. The Payments/Deposit, Discounts and Taxes tabs useDataTabletoo. - More Details tabs (21 Sep 2026: no Payments / Deposit tab on the create page — payment is taken on the document page after approval, §2.1a), Discounts (whole-invoice discount amount, RM/% toggle, Reason as a drop-down from General Reasons of type Manual discount, always recorded as a manual discount — no "Recorded as" choice (owner, 16 Sep 2026); every manual discount, per line and whole-order, is checked against Company Settings › Advanced
MIN_MANUAL_DISCOUNT/MAX_MANUAL_DISCOUNT(%) the way the till checks it (new_sale_discount_promotion_dialog_controller.dart: a % is compared as is; an RM amount against price × qty on a line, against the subtotal on the cart) — red border and "Min 0.5% / Max 50% of the line" under the Disc input, a sentence under the cart Amount, and Next / Confirm blocked while one is out of range; the till'sMANUAL_DISCOUNT_AUTH_PASSWORDmanager prompt has no Back-Office step — thesales.*.managepermission covers it (owner, 16 Sep 2026); "How it lands on each line" is a short summary — Total Net (the grid footer) · Total Cart Disc (negative, red) · a Tax block with one row per rate and Total Tax · Total — with the per-line share table (Line · After line disc · Share · Tax) behind a "Show per-line share (N)" toggle in a scroll box with a fixed header, so a long order does not double the page (owner, 16 Sep 2026)), Taxes (breakdown), Delivery (delivery address, billing address, note for the driver, shipping charge), Notes (one Note only — the "Internal note" textarea is removed, owner 16 Sep 2026). Where the notes go: Note →ft_sales_header.notes(≤ 500 on the create / draft body —notes: max_length=500inmodels/sales_order_write.py,sales_creation.py,sales_invoice.py; only the invoice notesPUTallows 600; the column itself is unboundedtext. The PDF prints it throughparse_noteswhen the store settingRECEIPT_INCLUDE_CART_NOTEis on, its default); note for the driver →ft_sales_header.ship_special_notes(the column the customer address's special note is copied into). Nothing else on the form is a note, and nocustom_fieldcolumn is written. - Totals (same shape as the detail page's Summary block —
text-smrows, muted labels, semibold total, orange balance): left Subtotal · Discounts · Net before tax · Tax total · Rounding · Grand total; on an order a payment badge (Pending Payment · Partial Payment · Paid) with Paid and Balance due rows and the line "A part-paid order stays a Draft: Submit for Approval unlocks only when it is paid in full, and Convert to Invoice after the approval"; on an invoice no payment rows at all (an invoice carries no payment status, 21 Sep 2026); and the validation list when something is missing. - Bottom bar (all buttons right-aligned, one size): Back · Save draft · primary "Submit for Approval · RM …" (both modes; 21 Sep 2026 — replaces "Confirm order" and "Pay & complete"). Blocked while a customer or item is missing, a discount is outside the company limit, a reason is missing, or a serialized / batch line is short.
6.3 Detail pages (as built today — target in §6.5)
Both share: doc_ref_no title with DocumentStatusPill (label + Overdue chip) and sub-type badge, date · store subtitle, four tabs Details · Payment · Billing & Shipment · Audit, sticky bottom bar starting with Back. No stage strip and no document-chain strip (owner, 15 Sep 2026). The Details tab is a stack of standard sections: Linked Documents (n) (plain table, no pagination — an invoice has one order, an order one invoice and maybe a return; row click opens the document; hidden when nothing is linked) → Invoice Info / Order Info (info grid, up to five columns) → lines DataTable → Summary (the standard two-column block: left Currency · Tips · Surcharge · Service Charge · Shipping; right Subtotal · Item Discount · Cart Discount · Tax (Inclusive/Exclusive) · Rounding · Net Total · Amount Paid · Balance in orange; the editable Notes box inside the same section) → Promotions & Discounts when present.
- Order: the status pill carries the stage. Deposit actions appear only on Back-Office orders (
source_type = 'BO'); a synced till order is read-only here. Bottom bar: Print Receipt · Change Sales Rep · Send Email; at Draft also Edit (→ edit page) · Cancel Order · Confirm Order (ConfirmOrderDialoglists the items); at Confirmed also Cancel Order (CancelOrderDialog, reason required; disabled when a deposit exists) · Convert to Invoice; at Draft and Confirmed also Record Deposit and, while a deposit is held, Refund Deposit (both switch to the Payment tab; Cancel Order stays disabled until the deposit is refunded). Inline pencil on Sales Rep in the Details tab. Payment tab: status "Deposit received" / "No deposit", Latest deposit, Deposit received, Balance, a Record deposit / Refund deposit toggle above the inline panel (RecordDepositPanel— button "Record Deposit · RM …";RefundDepositPanel— Payment Method, Amount, Reference No., Reason, button "Refund Deposit · RM …"), then Deposit records where a refund row shows a red "Refund" badge and its reason. - Invoice: the Status and Payment Status words carry the state (§6.1). Bottom bar by state — Draft: Print Invoice · Edit (→ edit page) · Cancel Invoice (
CancelInvoiceDialog, reason required); Pending Payment: Print Invoice · Change Sales Rep · Send Email · Edit Notes (EditInvoiceDialog: Notes only) · Record Payment (switches to the Payment tab; the payment that clears the balance completes the invoice — "Invoice {ref} paid in full and completed."); Paid: no Record Payment. Details tab has an inline pencil on Sales Rep and Notes. Payment tab: Payment Status grid (Payment Status, Paid Date, Total Paid, Balance, Deposit when > 0, COD), the inline Record Payment panel (Payment Date, Payment Method, Amount, Reference No. (Optional); card fields Card Number / Cardholder Name / Approval Code appear for a card tender; button "Record Payment · RM …"), then Payment Records (Payment Method, Code, Category, Currency, Amount, Foreign Amount, Exchange Rate, Auth Code, Date) and a Reconciliation block when linked documents exist.
Labels and colours: status words follow the Back-Office standard in §6.3a (status-badges.ts); the payment words (Pending Payment / Partial Payment / Paid) and the Overdue chip stay in frontend/src/components/DocumentStatusPill.tsx.
6.3a Status badge colours — the Back-Office standard (22 Sep 2026)
Since PR 11100 (5f75f7414, "one standard for status badge colours") a status word gets its colour in one place: frontend/src/lib/constants/status-badges.ts (TONE_BY_STATUS → STATUS_TONES), documented in docs/standards/table-view.md §5.17 and docs/status-badge-colors-plan.md. A page never writes a bg-… text-… class for a status; it imports WORKFLOW_STATUS_CONFIGS.sales() (built from field-options.ts ft_sales_header.status) and hands it to statusCell(). A drift test (status-badges.test.ts) fails npm test when a page declares its own status colours. The seven tones, by meaning:
| Tone | Meaning | Classes (light) | Sample |
|---|---|---|---|
| Neutral | Not started; still editable | bg-slate-100 text-slate-700 | Draft |
| Waiting | Parked until someone decides | bg-amber-100 text-amber-800 | Approval pending |
| In progress | Work is happening on it right now | bg-violet-100 text-violet-800 | Processing |
| Accepted | Approved or partly done; not the end | bg-blue-100 text-blue-800 | Approved |
| Done | Finished with a good outcome | bg-green-100 text-green-800 | Completed |
| Stopped | Ended on purpose, nothing wrong | bg-gray-100 text-gray-700 | Cancelled |
| Problem | Ended badly or needs attention | bg-red-100 text-red-800 | Rejected |
This module's words — what every list page, detail header, view page and mockup must show (label from field-options.ts, tone from TONE_BY_STATUS):
ft_sales_header.status | Label (field-options) | Used by | Tone | Classes | Sample |
|---|---|---|---|---|---|
DRAFT | Draft | order, invoice | Neutral | bg-slate-100 text-slate-700 | Draft |
FOR_APPROVAL | Approval pending | order, invoice | Waiting | bg-amber-100 text-amber-800 | Approval pending |
APPROVED | Approved | order, invoice | Accepted | bg-blue-100 text-blue-800 | Approved |
REJECTED | Rejected | order, invoice | Problem | bg-red-100 text-red-800 | Rejected |
COMPLETED | Completed | order, invoice | Done | bg-green-100 text-green-800 | Completed |
CANCELLED | Cancelled | order, invoice | Stopped | bg-gray-100 text-gray-700 | Cancelled |
VOIDED | Voided | invoice | Problem | bg-red-100 text-red-800 | Voided — a void is a correction and must stand out from a cancel — owner, 22 Sep 2026 |
RETURNED | Returned | invoice | Accepted | bg-blue-100 text-blue-800 | Returned — a finished sale, not the plain way — owner, 22 Sep 2026 |
Not statuses, so outside the standard (they keep their own component colours):
| Word | Where | Component | Classes today | Sample |
|---|---|---|---|---|
| Pending Payment | Payment Status column on both lists, view page header and Payment section | DocumentStatusPill | bg-amber-100 text-amber-800 | Pending Payment |
| Partial Payment | same | DocumentStatusPill | bg-amber-100 text-amber-800 | Partial Payment |
| Paid | same | DocumentStatusPill | bg-emerald-100 text-emerald-800 | Paid |
| Overdue chip | invoice list / detail | DocumentStatusPill | as built | Overdue |
| Pre-Order / Normal / Special Order (type tags) | list Type column, detail header | Badge | as built — type tags are out of scope by owner decision (22 Sep 2026) | Pre-Order Normal Special Order |
| Yes / No cells (Paid, For Delivery, Has Promo) | both lists | pill | green bg-emerald-100 / grey bg-gray-100 (owner, 21 Sep 2026) | Yes No |
Two things this standard changes for the module:
- Spelling
CANCELLED—sales.tson main keeps one spelling (CANCELLED, 2026-09-21 vocabulary note); the earlierCANCELEDin §2.1a is replaced. The tone map accepts both, the field option isCANCELLED. - Label of
FOR_APPROVAL—field-options.tson main ships "Approval pending" and the standard takes labels from there; the owner accepted it (22 Sep 2026) over the earlier "Pending for approval", and the mockup now says "Approval pending" (G23, closed).
The mockup uses these exact tone classes for the status pills (Draft slate, Approval pending amber, Approved blue, Rejected red, Completed green, Voided red — no Cancelled pill any more, D13) so a screenshot of a mockup and of the app look the same.
6.4 Mobile (≤ 767 px) — as built today
Reviewed and fixed on 15 Sep 2026 from the layout classes (not on a device yet):
- List pages render a purpose-built card (ref no, date, customer, status, payment status, total, balance, row menu); filters wrap.
- Create / edit page: header fields stack to one column; the items grid renders a card per line with the Qty / Item Price inputs side by side, the Disc input with its RM/% toggle, and Net / Tax / Total; the tab strip scrolls sideways; the Totals card is sticky on desktop only; the bottom bar stacks full-width 40 px buttons with the primary at the bottom.
- Detail pages: the tab row scrolls sideways; the bottom bar (
DetailActionBar) stacks full-width buttons, Back on top, primary at the bottom, and folds secondary actions beyond three into a More menu with the same labels; info grids collapse to two then one column; the Linked Documents table scrolls sideways; the Record Payment / Deposit / Refund panels collapse to one column with a full-width button. - Dialogs keep a 16 px gutter and stack their footer buttons.
6.5 Target screens — the mockup, page by page (the standard for the build, 22 Sep 2026)
Source: mockup/invoice-bo-step-form-footer.html (live at the office site). Every page below can be tried there. Where a Back-Office standard exists the page follows it; the standard is named on the line.
Common to every page
- Page title
text-xl font-heading font-boldat the same position on every page (title, Add Items, Serial / Batch Numbers, view page); breadcrumb Sales › Orders / Invoice › page. - Inputs, selects and text boxes 36 px (
h-9); buttons 36 px on desktop, 40 px on a phone (h-10 md:h-9); icon buttons 36 × 36; labelslbl; helper texttext-xs text-muted-foreground. - Pinned footer (
sticky bottom-0, border-top) on every full-page screen: the step strip or the section strip on the left, the buttons on the right, nothing else — no hints, no draft number. - Status pills from
status-badges.ts(§6.3a); payment words Pending Payment · Partial Payment · Paid fromDocumentStatusPillon both documents (G21); Yes / No cells as green / grey pills; type tag Pre-Order / Normal as aBadge. - Button icons (lucide, as the built form pages): Submit for Approval
Send, ApproveCheck, RejectX, VoidBan, EditPencil, Record PaymentCreditCard, PrintPrinter, Send EmailMail, BackChevronLeft, NextChevronRight, AddPlus, RemoveTrash2. Labels in Title Case ("Submit for Approval", "Record Payment", "Void Order"). - No jargon on screen: no raw enums, no engineering words; errors say what happened and what to do next.
Sales Orders / Sales Invoices lists (DataTable, WORKFLOW_STATUS_CONFIGS.sales())
- Header card: title + subtitle, then Stores · Types (order only) · search · Filter · date, and Settings · New Sales Order / Invoice · Export.
- Columns (order): Transaction Date · Business Date · Ref. No · Type (Pre-Order — the old Type column is gone) · Status · Payment Status · Store · Register Code · Cashier · Customer · Order Type · Total Paid · Balance · Total · Paid (Yes/No) · For Delivery · Total Line Order · Has Promo · Source Type · Created Date · Updated By · Updated Date · actions. Invoice: the same columns (an invoice takes payments too — G21).
- Every column sorts (click the header: ↕ → ↑ → ↓ → off, the active icon in the primary colour); a filter row under the header with a per-column "All ▾" multi-select ("Any date" range boxes on the date columns) and the toolbar Filter popover with the quick filters (Status; Payment Status on orders) — checkboxes, "Filter (n)" on the button, "Clear filters" at the bottom; the row count follows the filter ("1–1 of 1", "No orders match the filters.") — all as the built
DataTable(data-table-header.tsx,data-table-filter-row.tsx,data-table-toolbar.tsx). - One line per row (
whitespace-nowrap); the table scrolls sideways; the actions column is pinned at the right edge. The row menu opens on the viewport (never cut by the table box, flips upward near the bottom); menu icons one size. Row click → view page. Row menu: View · Edit (Draft / Rejected) · Submit for Approval (Draft, paid in full) · Approve / Reject (Approval pending) · Record Payment (a balance is open) · Convert to Invoice (Approved, paid) · Print Receipt / Print Invoice · Change Sales Rep · Send Email · Void Order / Void Invoice (Approved / Completed).
New / Edit — the step form (G16)
- Both documents: 1 Customer › 2 Items › 3 Payment › 4 Review & send (the invoice's Note and Note for the driver sit on the Customer step; the order's on the Payment step). Steps live in the footer with Back · Save draft · Next / Submit for Approval; a blocked Next is greyed, no message.
- Customer: search (name / code / phone) with the last suggestion "Add … as a new customer" opening an inline quick-add (Name, Mobile, Customer group, Email →
POST /api/customers); Store (online stores only); Date and Sales rep share a row evenly; "From the customer record" shows billing / delivery address, contact, e-mail, payment terms and due date read-only. - Items: search-or-scan row above the grid ("press Enter to add" — a scan adds one unit); Add Items opens the picker; grid columns No · Item Code · Barcode · Item Name · UOM · Qty · Item Price · Disc / unit · Net · bin. Disc is per unit (§6.2) with an RM | % segmented switch (active side filled) and a one-line hint "= RM2.10 each · RM84.00 line". Click anywhere on a line to open its details under it (same tint as the line): Note, then the serial chips ("n of N" pill, at most 3 chips + "+N more", Key / Edit serial numbers) or the batch chips (Key / Edit batch numbers); Done closes. Under the grid: Cart Discount (Reason select from General Reasons, Amount with RM | %, min / max from Company Settings) and the summary (Total Net · Total Cart Disc negative · Tax by rate · Total) with "Show per-line share (n)". Empty grid = header row + inbox icon "No items yet. Use Add Items to start." + the summary at zero.
- Payment: the info line "A part payment keeps the order a Draft. Submit for Approval opens once it is paid in full (RM …)."; Date · Method · Amount · Reference + Add payment; the payments table with Total paid and Balance; the Totals card on the right (Subtotal · Discounts negative · Net before tax · Tax total · Rounding · Grand total · payment pill · Paid · Balance due).
- Review & send: every earlier step read-only with Edit links (Customer, Items with Line Disc, Payment & notes) and the Totals card; the footer's primary is Submit for Approval · RM …, greyed until paid in full (both documents).
- Save draft (steps 2 onward) writes the document and returns to the list (the row shows as Draft); Submit for Approval writes it as Approval pending and shows the done card.
Select Items (ItemSelection / DataTable toolbar standard)
- Header card: title + subtitle, the search box beside it (icon left, 36 px, 224 px), Settings; table with tick boxes, Code · Name · Barcode · Category · Brand · Serial · Batch; a tick mark on items already on the document; click anywhere on a row to tick it; "n rows selected" bar with Clear; footer Back · "Add n items to the order".
Serial Numbers / Batch Numbers pages (Purchase Order standard, §6.2)
- Title + subtitle; one card with the count line ("2 of 50 entered" / "30 of 24 allocated — 6 too many") and helper; serial: numbered text boxes with a bin, "+ Add another" (greyed at Qty), Enter on the last row adds the next, "Paste a list" ↔ "Enter one by one"; batch: rows of batch number + quantity + bin, "+ Add batch". Typing only updates the count line (no redraw). Footer: count line · Back (discards) · Save (tidies, refuses a duplicate serial, more serials than Qty, a batch quantity without a number, a split larger than Qty). Batch split storage: G22.
View page — Sales Order / Sales Invoice (§6.3 for the full text)
- Header: ref · status pill · payment pill · type tag; date · store. Top-right: Record Payment (while the document can take money: Draft / Rejected / Approved with a balance) and icon-only Print / Send Email. On a phone the buttons drop under the title.
- Sections as footer steps, all clickable: Details (Order Info · Order Lines · Summary in the built two-column block with Notes · Linked Documents last) · Payment (status grid, Record Payment panel, Payment Records — both documents) · Billing & Shipment · Audit.
- Footer actions by status — Draft / Rejected: Edit · Submit for Approval (greyed until paid in full, both documents); Approval pending: Reject · Approve; Approved: Void Order · Convert to Invoice (order) / Void Invoice · Issue Invoice (invoice); Completed and Voided: Back only (a Completed document is voided at the till, Q3). Void only once approved, never next to Reject. Reject and Void open a reason card first (empty reason refused); the reason goes to
remarksand to the red banner on the document. - Audit = the
workflow_status_logrows for this record (G24 endpoint) as a timeline: newest at the top, a coloured dot per status, one arrow per step from the older entry up to the newer one, the move as two pills ("Draft → Approval pending"; first row "Created as Draft"; top row tagged Current), who (name + emp code) · app, the reason as an indented quote, the time at the right. Read endpoint: G24.
Mobile (≤ 767 px): everything stacks; the items grid and the lists scroll sideways with the line-details block and the actions column kept in view; the form's step strip collapses to "Step 2 of 4 — Items", the view page's section circles stay tappable.
7. Files
Backend — new: api/sales_requests.py; services/sales_document_service.py, sales_document_write_service.py (shared draft edit), sales_order_write_service.py, sales_payment_service.py (invoice payments + order deposit + refund); repositories/sales_document_repository.py, sales_payment_repository.py; models/sales_order_write.py, sales_payment.py; types/sales-creation.ts on the frontend side.
Backend — modified: api/sales_orders.py, api/sales_invoices.py, main.py; services/sales_commit_service.py (optional payments, balance rule, due date, NORMAL_ORDER, idempotency), sales_invoice_service.py (edit), sales_order_service.py, customer_service.py; repositories/sales_invoice_repository.py, sales_order_repository.py, sales_commit_repository.py, customer_repository.py, dashboard_repository.py, view_sales_repository.py; core/sales_doc_semantics.py, field_options.py, constants.py; models/customer.py (payment_terms_day), sales_invoice.py, sales_order.py, sales_creation.py.
Frontend — new: pages/sales/SalesDocumentPage.tsx; components/DocumentStatusPill.tsx; pages/sales/components/LinkedDocumentsSection.tsx (Linked Documents table at the top of the Details tab), SalesListMobileCard.tsx, DetailActionBar.tsx, LineDetailsDialog.tsx, RecordPaymentPanel.tsx, RecordDepositPanel.tsx, RefundDepositPanel.tsx, CancelInvoiceDialog.tsx, EditInvoiceDialog.tsx, ConfirmOrderDialog.tsx, CancelOrderDialog.tsx, CartLineEditor.tsx, CartTotalsPanel.tsx, CouponPanel.tsx, DiscountModeToggle.tsx, GiftcardField.tsx, TenderPanel.tsx, PaymentsPanel.tsx; hooks/use-document-cart.ts, use-sales-creation.ts; lib/money/; lib/constants/sales-creation.ts.
Frontend — modified: App.tsx, lib/api.ts, hooks/use-sales-orders.ts, use-sales-invoices.ts, pages/sales/SalesOrderPage.tsx, SalesInvoicePage.tsx, SalesOrderDetailPage.tsx, SalesInvoiceDetailPage.tsx, order-tabs/DetailsTab.tsx, order-tabs/PaymentTab.tsx, tabs/DetailsTab.tsx, tabs/PaymentTab.tsx, hooks/use-document-cart.ts, lib/constants/sales.ts, field-options.ts, types/sales-order.ts, types/sales-invoice.ts.
Other apps: v2-sellable-api — sales_query.py (sync predicate), reports_sales.py, einvoice_query.py, report_query.py, receipt renderer + template. v2-pos-client — sale_constants.dart (constants only).
Database: the migration in §2.3, tenant_column_comments.sql, and a docstring-only edit to 20260319_0700_add_running_bal_and_inventory_position.py.
8. Tests
Backend (backend/tests/unit/): api/test_sales_orders_write_api.py, api/test_sales_invoice_payment_api.py, api/test_sales_requests_api.py, api/test_sales_order_deposit_api.py, api/test_sales_order_deposit_refund_api.py; services/test_sales_order_write.py, test_sales_deposit_service.py, test_sales_deposit_refund_service.py, test_sales_invoice_lifecycle.py (+ api/test_sales_invoice_lifecycle_api.py), test_sales_conversion_service.py, test_sales_payment_service.py, test_payment_rules.py, test_balance_due_rules.py, test_derive_document_state.py, test_cancel_writes_reason.py, test_sales_invoice_edit.py, test_sales_invoice_payments_list.py, test_sales_commit_doc_date.py, test_sales_coupon_service.py; core/test_bo_online_order_type.py. test_sales_reservation.py still covers the negative-stock pre-check used by convert; test_reconfirm_expired_hold.py covers the no-op reconfirm route and goes with it (§9 G6).
Frontend (frontend/src/): components/__tests__/DocumentStatusPill.test.tsx; pages/sales/__tests__/SalesOrderPage, SalesOrderDetailPage, SalesInvoicePage, SalesInvoiceDetailPage, RecordPaymentPanel, RecordDepositPanel, RefundDepositPanel tests plus the pure-function tests for discounts, tax breakdown, totals, sticky totals, payment cap, sales-rep auto-select, online-store filter; hooks/__tests__/use-sales-orders, use-sales-invoices.
Print and e-mail: the order e-mail service has cases in services/test_sales_order_service.py; the receipt store-scope guard is covered by services/test_receipt_access.py; the invoice e-mail service has no unit test in backend/tests/unit (the report client is only exercised by tests/api/test_einvoice_routes.py). Nothing new was added for them here.
Run: cd apps/v2-sellable-bo/backend && pytest -v; cd apps/v2-sellable-bo/frontend && npm test && npm run typecheck.
8.1 Target tests — what proves the build (one per rule in §2.1c)
Backend (backend/tests/unit/), new files beside the existing ones:
api/test_sales_lifecycle_api.py— V2 (two approvers → 200 + 409), V4 (edit at every status), V5, V6 (serial short / duplicate / batch over / under), V7 (part-paid submit on order and invoice), V10 (author approves → 403; no permission → 403), V11 (empty and 501-char reason), V12 (every wrong-status call), V13, V16 (void completed → 409).services/test_sales_convert_issue.py— V14: convert and issue succeed in one transaction; a forced failure in the stock step leaves no invoice and the order still Approved; payment rows copied;log_initial_statusNULL → COMPLETED on the new invoice.services/test_sales_void.py— V15: void an approved order with two payments → both payment rows voided, headerVOIDED+ the five void columns, exactly one log row with the reason.services/test_sales_status_log.py— V17: one log row per move withold_status,new_status,emp_code,remarks,app_type = 'BO';change_status()on the same status writes nothing;python ops/ci/scripts/check_workflow_registry.pyexits 0.services/test_sales_readers.py— V18: a document at each status is or is not in the list, the sales report and the till sync.api/test_sales_payments_api.py— V3 (Idempotency-Key replay → same result, one row), V8 (over balance → 400; on Approval pending → 409; on both documents).api/test_sales_status_history_api.py— V20 (own company → rows newest first; another company's id → 404).api/test_sales_custom_field_refused.py— V19.services/test_batch_split.py— G22: a line with two batches saves two detail rows and reads back as one grouped line; lowering Qty below the split → 422TRACKING_INCOMPLETE.
Frontend (frontend/src/pages/sales/__tests__/): the step form's Submit is disabled with the reason text for V5 / V6 / V7; the reason card refuses an empty reason (V11); the footer shows exactly the buttons of §6.5 per status; the Audit timeline renders the rows newest first with the reason quote; the list filter row and quick filter narrow the rows; npm run typecheck and the status-badges drift test pass.
8.2 Acceptance — the build is done when all of these are true
- A new order starts empty; Save draft on step 2 returns to the list with the row as Draft.
- Submit for Approval on a part-paid order or invoice is greyed on the screen and answers 409 on the API.
- A paid-in-full Draft submits to Approval pending; the author cannot approve it; an approver can, and a second approver gets 409.
- Reject without a reason is refused on the screen and with 422 on the API; the reason shows in the red banner and in the Audit.
- Convert on a paid, approved order creates a Completed invoice with the payments carried over, in one transaction; the order shows the invoice under Linked Documents.
- Issue on a paid, approved direct invoice completes it; stock moves; the till receives it.
- Void on an approved document asks a reason, voids its payments, and the document shows Voided with the reason; Void is absent on a Completed document.
- Every status change appears in the Audit timeline (newest first, one arrow per step) with who, app, time and reason.
- A serial line with a duplicate or a missing serial, or a batch split that does not add up, blocks Submit on the screen and with 422 on the API.
- A line discount of 0.4 % or 50.1 % is refused on the screen and with 422 on the API.
- Status pills use the
status-badges.tstones; the invoice and order lists sort, filter and page like every otherDataTable. pytest,npm test,npm run typecheckandcheck_workflow_registry.pyall pass.
9. Open points — for the owner to review
Simple version first. The full developer text for every item is in §9E, same numbers.
9A. Owner decisions — taken 22 Sep 2026
| # | Question | Decision |
|---|---|---|
| G21 | A direct invoice (no order) — how is it paid? | It takes payments like an order. A direct invoice has a Payment step and a Record Payment button while it is a Draft / Rejected / Approved with a balance; it cannot be submitted for approval until paid in full, the same gate as the sales order. So an invoice now shows the payment words (Pending Payment · Partial Payment · Paid) and Paid / Balance too; a converted invoice is simply Paid. (Replaces Revision 24's "an invoice carries no payment status".) |
| G24 | The Audit timeline needs a way to read the log. | (a) Build the small read endpoint for sales — GET /api/sales-orders/{id}/status-history and …/sales-invoices/{id}/status-history (½ day). |
| G15 | Print / Send Email on every status. | (a) Keep both on every status; stamp "DRAFT" / "APPROVAL PENDING" on the PDF of anything not yet approved. |
| G20 | The lifecycle. | Build it; the exact scope is set when implementation starts. |
| G16 | The step form (steps in the footer). | The new standard UI for New / Edit. |
| G22 | Batch split saved as one detail row per batch. | Confirmed. |
| G18 | custom_field columns. | Remove every use — this module uses no custom_field at all. |
| G19 | BO_ONLINE order type follow-ups. | Keep (owner, 22 Sep 2026): teach the till the word, back-fill old Back-Office rows. |
| G1 | Payment terms on the customer screen. | Editable on the customer screen; a new customer starts at 30 days by default. |
| G3 · G4 | Void-payment button; show the due date. | Do them. |
| G11 | PREPAY_INV in the migration comment. | Dropped from this plan — this round does not touch PREPAY_INV. |
9B. Build next — the target (§6.5), in this order
| # | What | Size | Notes |
|---|---|---|---|
| G20 | The lifecycle: Draft → Approval pending → Approved / Rejected → Completed, void once approved; payments on the Draft order and the Draft invoice (G21); submit only when paid in full; convert when approved. Every move through change_status(). | 6–8 days | Scope fixed when implementation starts (owner). Replaces Confirm, deposits and Pending Payment. G13 is moot: a document is paid before it can be approved. |
| G16 | The step form (steps in the footer) replaces the single create / edit page — the new standard UI. | 3–4 days | Decided 22 Sep 2026. |
| G17 | Everything the mockup changed on the form: items grid (Disc / unit, RM | %, click a line for details), Add Items page, serial and batch pages, quick-add customer, discount reason list, discount limits, no custom fields. | 3 days front + 1 day back | Lands inside G16's Items and Customer steps. |
| G22 | Batch split saved as one detail row per batch, shown as one line. | ½ + ½ day | Confirmed 22 Sep 2026. Part of G17. |
| G24 | Audit read endpoint for sales orders / invoices (…/{id}/status-history) — also feeds the rejected / voided banner and "approved by" (Q2). | ½ day | Decided (a), 22 Sep 2026. |
| G15 | PDF stamp "DRAFT" / "APPROVAL PENDING" on anything not yet approved; Print and Send Email stay on every status. | ½ day | Decided (a), 22 Sep 2026. |
| G19 | Order type chosen by the user — one drop-down on the create page, Delivery by default; BO_ONLINE removed everywhere. No migration (owner, 23 Sep 2026). | ½ day | Built. |
| G18 | Remove every custom_field use from the sales models, services and screens. | 1 day | Confirmed 22 Sep 2026: this module uses no custom fields. |
9C. Small fixes — no decision needed
- Sales Summary and Sales Details by Item cannot open when a Back-Office sale is in the range — no business date on a Back-Office sale. Fix and the full report-by-report table: §3.12 (29 Sep 2026).
| # | What | Size |
|---|---|---|
| G1 | Payment terms on the customer screen: add the field, editable; a new customer starts at 30 days. | ½ day |
| G3 | Void payment has no button. Add a row action with a reason (the same service Void uses in one step, Q3). | ½ day |
| G4 | The due date is not shown on the Back-Office screens (only the PDF). Show "Payment due". | Hours |
| G5 | Three inputs are never sent to the server (create-page Payment due, Shipping charge, payment date). Make them read-only or remove them. | Hours |
| G2 | Notes on an approved order cannot be edited from the screen. Inline pencil. | 1 day |
| G6 | Leftovers from the old stock-hold design (a no-op endpoint, an unused banner, a shim, a test). Delete. | Hours |
| G9 | One stale code comment. Delete. | Minutes |
| G8 | Two tabs submitting at once are not deduplicated (only retries are). Accepted, no action. | — |
9D. Closed
| # | Closed how |
|---|---|
| G23 | Label of FOR_APPROVAL = "Approval pending" (owner, 22 Sep 2026). |
| G7 | No deposit on an order any more (§2.1a). |
| G12 | The Back-Office no longer writes or shows deposits. |
| G10 | The stock-function edit was dropped from the branch (owner, 22 Sep 2026). |
| G14 | Print / e-mail access check — fixed 15 Sep 2026 (§3.10). |
| G11 | Dropped from the plan (owner, 22 Sep 2026) — this round does not touch PREPAY_INV. |
| G13 | Moot under G21 — a document is paid in full before it can be approved. |
| G15 | Decided (a): Print and Send Email on every status, PDF stamped before approval. |
9E. Developer notes — the full text, same numbers
Kept for whoever builds it. Nothing here needs the owner.
| # | Gap | Fix | Owner? | Size |
|---|---|---|---|---|
| G20 | New lifecycle not built (§2.1a, owner 21 Sep 2026). The app still has OPENED / PENDING, Confirm, the order deposit (/deposit, /deposit/refund, total_deposit_paid, current_deposit_paid, "Deposit received"), convert at PENDING regardless of payment, and direct-invoice "Pay & complete". | Data: no status migration — the vocabulary (DRAFT, FOR_APPROVAL, APPROVED, REJECTED, COMPLETED, VOIDED, RETURNED, CANCELLED) is already on main (20260921_0100_rename_workflow_status_vocabulary.py, PR 11094: OPENED → DRAFT, PENDING → DRAFT, CANCELED → CANCELLED; field-options.ts and field_options.py carry the words). Old PENDING rows are now DRAFT — part-paid drafts under the new rule, nothing to fix. No approval columns either — who / when / why live in workflow_status_log (Q2). Readers: BO_NOT_A_SALE_PREDICATE_H and the till sync predicate (sales_query.py) must exclude DRAFT, FOR_APPROVAL, REJECTED, VOIDED and a SALES_INV at APPROVED (Q4: it exists until Issue Invoice); derive_document_state() labels: Draft · For approval · Approved · Rejected · Completed · Voided, payment words from the money columns. API: POST …/submit (on a sales order: 409 ORDER_NOT_PAID_IN_FULL unless total_bal_amt = 0), POST …/approve, POST …/reject (reason required) on both routes; new permissions sales.order.approve, sales.invoice.approve (self-approval refused); POST /api/sales-orders/{id}/payments on a DRAFT or REJECTED order (same service as the invoice payment today, header total_amt_paid, no deposit columns) and create-time payments kept on the order create route; POST …/convert refuses unless APPROVED and total_bal_amt = 0, copies the payment rows onto the invoice and completes it in the same transaction; POST /api/sales-invoices/{id}/issue (APPROVED → COMPLETED, Q4 — replaces /complete); POST …/deposit and /deposit/refund retired; void (not cancel — D13) at APPROVED only, reason required, voids the payment rows in the same transaction (Q3); GET …/{id}/status-history on both routes (G24); every one of these moves goes through change_status() with the right extra_set and remarks (§2.1b), and both create routes call log_initial_status(). Screens: list status pills and filters with FOR_APPROVAL labelled "Approval pending"; the order list gains a Payment Status column (Pending Payment · Partial Payment · Paid) and both lists keep the Payment Status column and both view pages the Payment section (G21); Yes / No cells (Paid, For Delivery, Has Promo) as green / grey pills on both lists; detail bars per state (Draft: Edit · Record Payment · Submit for Approval greyed until paid in full; Approval pending: Reject + Approve; Approved: Convert to Invoice); rejected banner with reason; approver's queue (list filter "Approval pending"); create page: Payments tab on the order only; "Submit for Approval" primary on both. Tests: rewrite the confirm / deposit / convert / pay-and-complete suites. | Partly — see §2.1a assumptions (a)–(d) | 6–8 days |
| G16 | Step form for New / Edit — decided (owner, 22 Sep 2026): the step form with the steps in the footer (mockup/invoice-bo-step-form-footer.html) replaces the built single page (SalesDocumentPage.tsx). Order: Customer › Items › Payment › Review & send; invoice: Customer › Items › Review & send (its notes on the Customer step). A draft is saved after step 1 — create, then PUT /api/sales-orders/{id} / PUT /api/sales-invoices/{id}/draft per step; Save draft (steps 2 onward) writes the document and returns to the list, where the row shows as Draft (owner, 22 Sep 2026); the last step shows every earlier step read-only with Edit links and holds Submit for Approval. The header stores one address line per side today (bill_address_line_1, ship_address_line_1); the structured address is joined into it. Step 1 shows Payment terms, Contact and Email read-only from the customer (the write models carry none of these; doc_due_date comes from the customer's terms, G5). | Build it: 3–4 days frontend, on top of G17 (the grid, discount, tracking and quick-add work lands inside the step form's Items and Customer steps). | No | 3–4 days |
| G17 | Mockup decisions not yet built (target §6.5) in pages/sales/SalesDocumentPage.tsx. The built page still has: the "Draft" badge by the title; the item search row below the grid; the Deposit / Payments add row below its table; the grid columns Tax Type · Tax · Total; Net that subtracts the cart share (pricedLines net); a whole-invoice discount reason Input (hard-coded defaultValue="Bulk order goodwill", unbound) and an unbound "Recorded as" Manual / Promotion / Coupon Select; the full 100-row per-line share DataTable; LineDetailsDialog (a dialog) with a Warranty note and comma-separated serials; no serial / batch chip or inline details row; "Printed note" + an "Internal note" textarea (innerNote, never sent); no manual-discount min / max check. | In this order: (1) drop the badge; move the item search row above the DataTable and the Deposit / Payments add row above its table; rename "Printed note" → "Note" and delete the Internal note textarea + innerNote; tighten spacing (compact grid cells and panel bodies, subtitle beside the title); quick-add customer in the customer search — last suggestion "Add … as a new customer" opens an inline panel (Name, Mobile, Customer group, Email) that calls the existing POST /api/customers (CreateCustomerRequest: customer_name or first/last, customer_group_id required, cust_code omitted → auto) and selects the result. (2) itemColumns — remove tax_type, tax, total; right-align disc and label its toggle "RM" like the cart toggle (today currencyCode → "MYR"); footer = qty · Σ item discount · Σ net; Net = price × qty − own discount (the /calculate response keeps item_cart_prorata_disc_amt for the tax base — display only). (3) Discounts tab — drop the "Recorded as" select (sales_commit_service already writes the cart discount as a manual row via coupon_repo.insert_manual_discount, disc_type = FIXED, no reason saved); add cart_disc_reason to CreateOrderRequest / UpdateOrderRequest / CreateSaleRequest / UpdateInvoiceDraftRequest and pass it to insert_manual_discount → ft_sales_promo_disc.disc_reason; feed the reason Select from a new read-only GET /api/general-reasons?reason_type=MANUAL_DISCOUNT (no general-reasons endpoint exists; general_reasons is scoped by custom_company_id, NULL = system row, company row wins; add MANUAL_DISCOUNT to the general_reason.reason_type option list in core/field_options.py and lib/constants/field-options.ts, which omit it); replace the share table with the summary card (Total Net · Total Cart Disc · Tax by rate · Total) + "Show per-line share (N)" toggle (max-h-60 overflow-auto, sticky header). (4) Replace LineDetailsDialog with an inline sub-row: one general Note (ft_sales_detail.notes); for is_serialized lines a new full-page SerialEntryPage (same swap pattern as ItemSelection, same layout as the PO Serial Numbers page: count line, numbered text boxes with a bin, Add another, Paste a list ↔ Enter one by one, Back + Save in the pinned footer) writing serial_no comma-joined as today, duplicate and over-Qty refused, the sub-row showing the serials as chips + "Key / Edit serial numbers"; for is_batch_tracked lines a BatchEntryPage (rows of batch number + quantity, split must cover Qty) saved as one ft_sales_detail row per batch and re-grouped into one BO line on read (G22); a chip under the name and the row menu both open it; block the primary button while a tracked line is short; show the note under the item name once set. (5) Manual discount limits — neither use-document-cart.ts nor any service reads MIN_MANUAL_DISCOUNT / MAX_MANUAL_DISCOUNT (only the till does, new_sale_discount_promotion_dialog_controller.dart): validate in the cart hook (line: own discount ÷ price × qty; cart: cart_disc_amt ÷ subtotal; % modes compared directly) and refuse on the API with 422 MANUAL_DISCOUNT_OUT_OF_RANGE reading the company settings, so a stale browser cannot bypass it. | No | 3 days frontend + 1 day backend (reason column, reasons endpoint, limit check) |
| G22 | Batch split storage — decided (owner, 21 Sep 2026): one ft_sales_detail row per batch. The Batch Numbers page lets one BO line be split across several batches with a quantity each; ft_sales_detail.batch_no holds one batch per row. | On save the API writes one ft_sales_detail row per batch (same item, price and discount; qty = the batch quantity; batch_no set) and the BO grid re-groups those rows into one line for display and editing (group key: item + price + discount + note); the till and reports see N rows, as they do for a till sale with several batches. The /calculate call prices the grouped line once and the split inherits its per-unit figures; a line with no split yet saves as one row with batch_no NULL. A serial line stays one row with serial_no comma-joined as today. | No | in G17 (4) — ½ day backend (split / re-group in sales_document_write_service), ½ day frontend |
| G19 | Order type chosen by the user. Was: every Back-Office header hardcoded BO_ONLINE, a type the till's order_type_constants.dart never knew, the Sales lists in v2-sellable-api … reports_sales.py printed raw, and older rows carried NULL. | Replaced by the owner on 23 Sep 2026: the create page offers Delivery · In-Store · Online Delivery (Delivery by default), BO_ONLINE is deleted from the code, and no migration runs — old rows keep what they have and read as words through formatOrderType(). Still open outside this module: the report column label and the DDL comment in tables_pg.sql listing valid codes. | No | Built |
| G18 | custom_field columns are still wired in this module although the rule is that none is used. Header: CreateOrderRequest / UpdateOrderRequest (models/sales_order_write.py), CreateSaleRequest (models/sales_creation.py), UpdateInvoiceDraftRequest + UpdateInvoiceRequest.custom_field1 (models/sales_invoice.py); written by sales_commit_service (header), sales_order_write_service (draft header, confirmed allow-list custom_field1 / custom_field2, and convert copies the order's custom_field1..3 onto the invoice), sales_document_write_service, repositories sales_document_repository.update_confirmed_order_header, sales_invoice_repository.update_invoice_header_notes_po, sales_commit_repository; DetailsTab.tsx sends header.custom_field1 back on every notes save; read models still return custom_field1..3. Line: SalesDocumentPage.tsx writes the warranty note to line custom_field1 and nulls custom_field2..3; CreateOrderLineRequest / CreateSaleLineRequest carry them; sales_commit_service copies all three onto the row. Text that names the removed fields: sales_order_write_service.py 409 message "Only notes, customer PO number, and delivery window can be changed on a confirmed order." and the docstrings in api/sales_invoices.py, sales_invoice_service.py, sales_document_repository.py. Downstream: the Sales Orders report (v2-sellable-api … reports_sales.py) prints h.custom_field1 as "Ext Ref # (Custom)" — empty for Back-Office documents after this, still filled by the till. | Remove end to end: the fields from the write / line / update models and the two PUT allow-lists; the writes in sales_commit_service, sales_order_write_service (including the convert copy), sales_document_write_service and the three repositories (columns stay on the tables, untouched, for the till); the custom_field1 echo in DetailsTab; the warranty mapping in SalesDocumentPage; the 409 message → "Only notes can be changed on a confirmed order."; the docstrings; frontend types/sales-invoice.ts, types/sales-order.ts. Update the tests that assert on them: tests/unit/services/test_sales_order_write.py, test_sales_invoice_edit.py, test_sales_conversion_service.py, frontend/src/hooks/__tests__/use-sales-invoices.test.tsx, fixtures in SalesDocumentPage.edit.test.tsx / SalesInvoicePage.test.tsx. Leave the report column as is (till data). | No | 1 day |
| G21 | Direct invoice — how is it paid? With payment status on the sales order only (Revision 24), a sales invoice raised without an order has nowhere to record money. | Owner to choose: (a) retire the direct invoice — every invoice comes from a paid, approved order (simplest; New Sales Invoice disappears); or (b) keep it and let a Draft invoice take payments the same way an order does, still without a status word. Until decided the mockups keep the New Sales Invoice page but show no payment on it. | Yes | in G20 |
| G24 | Audit on the view page needs a read endpoint the workflow plan does not build (its §8 "Read side" is not planned, owner 22 Sep 2026). The mockup shows the log as a Audit section. | Owner to choose: (a) build the small read endpoint for sales only — GET /api/sales-orders/{id}/status-history and …/sales-invoices/{id}/status-history → SELECT … FROM workflow_status_log WHERE company_id = $1 AND entity_name = 'ft_sales_header' AND entity_id @> '{"sales_id": id}' ORDER BY created_date DESC, the module's own VIEW permission, app label from APP_TYPE_LABELS in types/role.ts (½ day); or (b) drop the Audit step from the view page until the workflow plan's read side is reversed. | Yes | ½ day |
| G13 | An approved invoice that is never paid (was: a Pending-payment invoice cannot be cancelled). Under §2.1a a converted invoice is always paid, so this only concerns a direct invoice at APPROVED with no payment. | Cancel allowed at APPROVED while total_amt_paid = 0 (folded into G20's cancel rule); with a part payment, void the payment first. | No (rule set by §2.1a) | in G20 |
| G15 | Print and Send Email show in every state. A Draft or a document waiting for approval can be printed or e-mailed; a rejected, cancelled or voided one too. | Show Send Email on Approved / Completed documents only; keep Print everywhere but stamp "DRAFT" (or "FOR APPROVAL") on the PDF of anything not yet approved. Raised 15 Sep 2026, not yet decided. | Yes | ½ day |
| G1 | Payment terms cannot be edited on the customer screen. The column and the API field (models/customer.py, payment_terms_day, 0–365) exist; the customer form has no input. Every customer is on 30 days until this ships. | Add the field to the customer form (view / edit). | No | ½ day |
| G2 | Confirmed orders cannot be edited from the UI (Notes). The draft edit screen exists; the confirmed-order allow-list edit has no screen. Note: UpdateOrderRequest is extra="forbid" and requires store_id, lines and calculation_token, and the confirmed branch refuses unless the lines and cart_disc_amt match the stored document — so a pencil must resend the whole document, not just notes + version. | Inline pencil on the order Details tab that resends the stored document with the new notes (or loosen UpdateOrderRequest for the confirmed branch to notes + version only — ½ day API). | No | 1 day |
| G3 | Void payment has no button. Endpoint + hook exist; allowed only on a Pending-payment invoice. | Row action on Payment Records for non-voided, non-change rows, with a reason prompt. | No | ½ day |
| G4 | Due date is not displayed on the Back-Office screens. doc_due_date is in the invoice detail / list responses but no page shows it; only the Overdue chip reflects it. The PDF does show it ("Pay within N days · due …"). | Show "Payment due" in Invoice Info and as a list column. | No | ½ day |
| G5 | Inputs that are never sent: create page "Payment due" (server computes it from the customer's terms) and "Shipping charge" (charges are out of scope); Record Payment panel "Payment Date" (server sets trx_date = now()). | Make "Payment due" read-only and derived; remove the shipping charge input until charges ship; drop or wire the payment date. | No | ½ day |
| G6 | Leftovers from the stock-hold design: POST …/reconfirm (no-op), pages/sales/components/HoldExpiryBanner.tsx (no importer), hooks/useDocumentCart.ts (2-line re-export shim), test_reconfirm_expired_hold.py. sales_reservation_repository.py stays — convert uses it for the negative-stock pre-check. | Delete the leftovers in one hygiene commit. | No | Hours |
| G9 | Stale comment in sales_commit_service.create_sale ("company setting override TODO") — the code already reads SALES_INVOICE_DOC_PREFIX from company settings. | Delete the comment. | No | Minutes |
| G11 | Stale text in the committed migration: the doc_type comment says "PENDING confirmed with stock held" and names PREPAY_INV as a Back-Office writer. | Small follow-up migration that rewrites the comment. | No | Hours |
| G8 | Two independent submissions (two tabs) mint two keys and are not deduplicated — only retries of one submission are. | Accepted limit of the route-namespaced key design. No action. | No | — |
| G23 | Label of FOR_APPROVAL — closed (owner, 22 Sep 2026). The field-options.ts label "Approval pending" stands; the mockup and this plan were changed from "Pending for approval". | Nothing to build. | No | — |
| G7 | ~~Forfeit a deposit~~ — closed 21 Sep 2026: there is no deposit on an order any more (§2.1a). | — | — | — |
| G12 | ~~current_deposit_paid label~~ — closed 21 Sep 2026: the Back-Office no longer writes or shows deposits (§2.1a); the till keeps its own meaning. | — | — | — |
10. Decisions that shaped the build (kept)
| # | Decision |
|---|---|
| D2 | No stage column; label derived from status + money + is_voided; source_type = 'BO' separates Back-Office rows from till rows. |
| D6 | One-to-one order → invoice enforced by the row lock plus the PENDING → COMPLETED compare-and-swap; nothing added to ft_sales_parent_child. |
| D7 | Conversion copies prices, discounts, tax; no repricing. |
| D8 | No stock hold on confirm; stock moves at invoice issue only. |
| D9 | Deposit = payment rows on the order + current_deposit_paid / total_deposit_paid on its header, exactly as the till stores a pre-order deposit; no PREPAY_INV document. Allowed at Draft and Confirmed, capped at the order total, carried to the invoice at conversion. Refund = negative payment row with a reason; original row kept; cancel only after a full refund (owner, 14 Sep 2026, after comparing Business Central, SAP B1, Odoo, NetSuite, Shopify, Square, Lightspeed, AutoCount, SQL Account). |
| D10 | customer.payment_terms_day bigint NOT NULL DEFAULT 30; walk-in due at issue date. |
| D12 | Route-namespaced idempotency key stored in the existing offline-id columns; advisory lock; GET /api/sales-requests look-up. No idempotency table. |
| D17 | Draft order fully editable; confirmed order → notes / PO no. / delivery window; invoice → notes / PO no. |
| D18 | Replaced 14 Sep 2026. A Back-Office invoice has three states: Draft (OPENED, direct invoice, editable), Pending Payment (PENDING, converted from an order, waiting for money) and Paid (COMPLETED). It becomes a sale — stock moves, reports count, till receives it — only at COMPLETED, i.e. when paid in full (owner chose "complete on full payment" over "complete at issue" because the latter needs a stock-function migration and ~15 reader changes). |
| D21 | Replaced 14 Sep 2026. A direct invoice is either saved as a Draft with no payment or paid in full at once; it can never wait for payment. Credit sales go through a sales order. Walk-in and named customer follow the same rule. |
| D23 | ~~custom_field1..3 = Customer PO no. / Delivery window / Project / Cost centre.~~ Superseded 16 Sep 2026: this module uses no custom_field column (G18). |
| D27 | Reader predicate NOT (source_type = 'BO' AND (status IN ('OPENED','CANCELED') OR (status = 'PENDING' AND doc_type = 'SALES_INV'))); sync predicate holds back a Back-Office invoice until COMPLETED and keeps confirmed / cancelled orders flowing to the till. |
| D30 | Coupons claimed only at completion (paid in full), re-validated at that moment; a draft or pending invoice never burns a coupon. |
| D32 | Cancel reason in voided_or_returned_notes with code CANCELED. |
| — | Back-Office invoice doc_sub_type = 'NORMAL_ORDER' so it lists under Sales on the till (owner, 9 Sep 2026). |
11. History
Revision 53 (30 Sep 2026) plans the build: §3.11b gives four phases (renderer › company setting › per-print API › page), files, gates, deploy order and risks; §3.11 gains the fixes from the API grill — the PATCH SQL that actually creates the key, the query parser dependency, the HTML preview plumbing on both sides, escaping of free text, the two-model contract test, the note that three new rows default on, and (after the critic) the jsonb_typeof guards against a stored JSON null, the bank join, the hand-placed Printing section and the manual database check in place of a live-DB test. The owner then chose (option 2) that the three new rows — sales rep, document note, bank details — default off, and the document note became its own key doc_note (19 keys) so the existing terms text keeps printing. Revision 52 (30 Sep 2026) grills the print-options plan against the code and rewrites its API / renderer part: receipt input model, preview route, per-document read route, e-mail body field, per-type jsonb_set PATCH, no hidden-key trick, bank and contact data sources, copies via joined PDFs; adds the routes, permissions and tests; the mockup keys now match the plan. Revision 51 (30 Sep 2026) records the owner's four answers on the print options: barcode as text, tax code optional on an invoice, defaults per document type, e-mail always the same as the print — the e-mail checkbox is gone from both mockups. Revision 50 (30 Sep 2026) moves the print options to the company setting (company_settings_json, one for all stores), drops the "Always included" card from both print-options mockups, drops the per-user memory (nothing in users.preferences), and adds §3.11a — the impact of the new key on the other modules. Revision 49 (29 Sep 2026) reviews every report against the Back-Office sale that has no shift, business date or register (§3.12): Sales Summary and Sales Details by Item cannot open, three exports show a blank column, shift reports are correct as they are. Revision 48 (28 Sep 2026) designs the print options — what the printed order / invoice includes, saved per store (§3.11) — with one mockup (the checklist beside a live preview); nothing built. Revisions 1–16 (2–11 Sep 2026) planned this work in detail — quotation, stock holds, discount approval, deposit, a six-migration batch, three mockup sets and a review trail. The quotation, stock holds and approval were dropped by the owner during planning; deposit was deferred; only one migration was needed. Revision 17 replaces all of that with the as-built description above; Revision 18 adds the order deposit and its refund; Revision 19 the Draft / Pending Payment / Paid invoice lifecycle and draft editing — all built on 14 Sep 2026. Revision 20 (15 Sep 2026) is the UI pass: Status / Payment Status wording, strips and chain removed, header fields trimmed, every table on DataTable, Summary on the section standard, line maths (Net, Total, per-unit % discount) corrected, phone layouts. Revision 47 (23 Sep 2026) lets the owner approve their own document and fixes the manual-discount giver being written as an e-mail. Revision 46 (23 Sep 2026) moves the Audit out of the footer steps onto a header icon and makes every header button icon-only. Revision 45 (23 Sep 2026) builds the order-type choice and deletes the BO_ONLINE type everywhere. Revision 44 (23 Sep 2026) turns the order type into a user choice (Delivery by default) and drops the back-fill migration — mockup first. Revision 43 (23 Sep 2026) builds the owner's four answers — customer required, stock dated on completion, void voids the payments, serials always unique. Revision 42 (22 Sep 2026) records the build (§0b) and the review fixes. Revision 41 (22 Sep 2026) adds the validation and safety net (§2.1c), the target tests and the acceptance list — ready. Revision 40 (22 Sep 2026) answers the readiness questions — ready with minor adjustments. Revision 39 (22 Sep 2026) adds the readiness review (§0a). Revision 38 (22 Sep 2026) records the owner's decisions and gives the invoice payments (G21). Revision 37 (22 Sep 2026) rewrites §9 for the owner's review. Revision 36 (22 Sep 2026) drops the stock-function edit (G10 gone). Revision 35 (22 Sep 2026) adds §6.5, the target screens as the standard. Revision 34 (22 Sep 2026) folds in the workflow-status standard (§2.1b, void instead of cancel, Audit, G24). Revision 33 (22 Sep 2026) keeps one mockup (steps in the footer) and closes G16. Revision 32 (22 Sep 2026) adds the status-colour standard (§6.3a, G23). Revision 31 (22 Sep 2026) aligns the payment status words and the view Summary to the built page. Revision 30 (21 Sep 2026, night) adds the order / invoice view pages to the footer step-form mockup. Revision 29 (21 Sep 2026, night) rebuilds serial / batch entry to the PO standard (G22 on batch split storage) and the Select Items header. Revision 28 (21 Sep 2026, late) replaces the row menu with a bin icon plus click-the-line for details and re-lays out the line-details card. Revision 27 (21 Sep 2026, late) confirms the per-unit line discount and labels it. Revision 26 (21 Sep 2026, late) polishes the step-form mockups and makes the invoice a three-step form. Revision 25 (21 Sep 2026, night) re-lays out the serial-number page to the Add Items standard and for many units. Revision 24 (21 Sep 2026, evening) moves payment status to the sales order only (payments while Draft, part payment keeps Draft; no payment status on an invoice), labels FOR_APPROVAL "Pending for approval", colours Yes / No list cells, drops the direct-invoice walkthrough (G21). Revision 23 (21 Sep 2026, later) is the new lifecycle — Draft › For approval › Approved / Rejected › Completed, ordinary payments on an approved order instead of a deposit, convert only when paid in full, no money on a draft — applied to all four mockups and written up as §2.1a and G20. Revision 22 (21 Sep 2026) added the quick-add customer, compact spacing, the live one-page mockup and the footer-steps variant. Revision 21 (16 Sep 2026) is the owner's pass on the three mockups — Draft badge gone, search above the grid, full-page item picker, manual-only discount with a reason drop-down, grid cut to Net with the summary + Total under it, manual-discount min/max from Company Settings, a dedicated serial-number page, quick-add customer in the search, compact spacing, "Next" instead of "Save & continue", a live one-page mockup, Line details inline under the item, one Note (no internal note, no custom_field columns), four-step form with a Review step (Screen basis / G16) — the single-page decisions are recorded in §6.2 and queued for the app as G17. This folder was never committed before Revision 17, so the Revision 16 text is not in git; grill-review.md and implementation-review-2026-09-10.md in this folder keep the review findings that led here.