Sales order → Invoice → Payment — as-built plan

StatusRevision 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 memoryserial 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.
ScopeBack-Office only, Online-type stores only. Sales order → Invoice → Payment.
Screen basisOne 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.
Companionplan.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.

#QuestionAnswer
Q2Where 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.
Q3Void: 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.
Q4What 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)

Developer notes — now written into the plan

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 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.

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.

Owner's answers — 23 Sep 2026

Four points the owner settled, all now built.

#The ruleWhere it is enforced
1A 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.
2A 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.
3Void 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.
4A 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.

WhatWhy
Payment dateA 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 rowsThe 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 filterThe list API has no payment_status filter, so the toolbar filters Status only. The column still shows and the balance still sorts.
Unsaved-changes guardCovers 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_ENABLEDA compile-time constant in the frontend (V21), not a runtime switch.

1. Scope

1.1 Built

AreaWhat the user can doWhere
Sales orderCreate 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 repSalesDocumentPage (mode order), SalesOrderPage, SalesOrderDetailPage
InvoiceCreate 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 repSalesDocumentPage (mode invoice), SalesInvoicePage, SalesInvoiceDetailPage
PaymentRecord one or several payments on a pending invoice; the last one completes it (stock moves, sale counts); cash change is computed; payment records listPaymentTab + RecordPaymentPanel on the invoice detail page
Cross-cuttingOnline-store-only, SO/SI numbering, audit fields, permissions + store scope, Back-Office drafts and cancellations excluded from sales figures, the till sync and e-invoicebackend repositories and v2-sellable-api readers (§3.9)

1.2 Built in the API only (no screen yet)

EndpointNote
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}/voidAllowed only while the invoice is Pending Payment. The hook useVoidInvoicePayment exists; no button calls it.
POST /api/sales-orders/{sales_id}/reconfirmLeftover 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)

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.

StageBack-Office sales order statusBack-Office sales invoice statusTill sales order
DraftDRAFTDRAFTDRAFT
Approval pending (label)FOR_APPROVALFOR_APPROVAL—
Approved or rejectedAPPROVED / REJECTEDAPPROVED / REJECTED—
CompletedCOMPLETEDCOMPLETEDCOMPLETED
UndoneVOIDED (reason asked)VOIDED (reason asked); RETURNED after a returnVOIDED (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.

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:

RuleWhat the sales order / invoice code does
One writerchange_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 ourschange_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 pathlog_initial_status() after the INSERT — the NULL → DRAFT row (a converted invoice: NULL → COMPLETED).
extra_setapprove / 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.
remarksuser 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 / whereActor 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 sideThe 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).
VocabularyD13: 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).

#RuleScreenAPI (inside the transaction)Error → what the user readsTest
V1Every 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.10404 / 403test_receipt_access.py pattern, one per new route
V2Only 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 → 409409 STATUS_CHANGED "Someone else changed this document. Reload it."two approvers at once → one 200, one 409
V3No 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 targetsecond identical request → the first result, no second rowreplay test per route
V4Edit only while editableEdit shows on Draft / RejectedPUT refused unless DRAFT / REJECTED; version must match409 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
V5A document needs at least one line, a customer and a store before it leaves DraftNext / Submit greyed with the reason/submit → 422 EMPTY_DOCUMENT"Add at least one item before sending."submit empty
V6Tracked lines complete before SubmitSubmit 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 wordsserial short, duplicate serial, batch over / under
V7Paid 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
V8Payments only while the document can take moneyRecord 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
V9Discount limitsLine 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
V10Approve / Reject only by an approver, never by the author — unless the caller is the company ownerReject / Approve are hidden from the author, and shown to the owner on their own documentsales.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
V11Reject and Void need a reasonReason card refuses an empty boxreason 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
V12Approve / Reject only from Approval pending; Convert / Issue only from Approved; Void only from Approvedbuttons 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
V13Convert / Issue only when paid in fullConvert / Issue greyed until paid/convert, /issue → 409 ORDER_NOT_PAID_IN_FULL unless total_bal_amt = 0as V7part-paid approved (only reachable by voiding a payment)
V14Convert 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-donefail the stock step → no invoice, order still Approved
V15Void voids the money with itReason card says soone 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
V16Void 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 wordsvoid completed
V17Every 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'
V18Drafts 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
V19No custom fields—the write models extra="forbid" refuse custom_field* (G18)422send custom_field1 → 422
V20Audit read is scoped—…/status-history behind the module's VIEW permission + require_record_store; rows filtered by company_id and entity_id403 / 404another company's id → 404
V21Roll-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.

LabelstatusExtra conditionMeaning
DraftOPENED—Saved, not confirmed. Not synced to tills, not counted in reports.
ConfirmedPENDING—Confirmed. No stock held. Synced to tills (Pre-orders list).
InvoicedCOMPLETEDhas a Back-Office SALES_INV child in ft_sales_parent_childConverted in the Back-Office; the invoice moved the stock.
CollectedCOMPLETEDno Back-Office childA till collected the pre-order.
CancelledCANCELEDvoided_or_returned_reason_code = 'CANCELED' + _notes, _by, _by_user_id, _datetimeCancelled with a reason.

Invoice — doc_type = 'SALES_INV', doc_sub_type = 'NORMAL_ORDER', source_type = 'BO'.

LabelstatusConditionMeaning
DraftOPENEDdirect invoice, saved without paymentFully editable. No stock, no report, not on the till.
Pending PaymentPENDINGtotal_amt_paid = 0Converted 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 PaymentPENDINGtotal_amt_paid > 0, or a carried deposit (0 < total_bal_amt < grand total)Same, with money already taken.
PaidCOMPLETEDis_paid = truePaid in full. Stock moved, coupons claimed, counted as a sale, reaches the till.
Overdue (red chip)PENDINGis_overdue — doc_due_date < today (company zone) and balance > 0; never storedShown beside Pending Payment / Partial Payment.
CancelledCANCELEDdraft cancelled with a reasonExcluded everywhere.
VoidedVOIDEDis_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

ConceptColumn
Lifecycleft_sales_header.status — the Back-Office writes only OPENED, PENDING, COMPLETED, CANCELED (both documents)
Payment dueft_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 — ruleNo 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 reasonvoided_or_returned_reason_code, _notes, _by, _by_user_id, _datetime
Paid statetotal_amt_paid, total_bal_amt, is_paid, paid_date — all four written by the payment service
Depositcurrent_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 idft_sales_header.offline_sales_id, ft_sales_payment.offline_sales_payment_id (§3.8)
Payment reference / cardft_sales_payment.payment_ref_no, payment_card_number, payment_card_holder_name, payment_card_auth_code
Void reasonft_sales_payment.return_reason
Link order ↔ invoiceft_sales_parent_child (read only through a company-scoped header join)
Addresses / driver notebill_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.

  1. ALTER TABLE customer ADD COLUMN payment_terms_day bigint NOT NULL DEFAULT 30 + comment. Existing customers read 30 through the default.
  2. 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 (adds CLOSED, CANCELLED → CANCELED) and trx_type (sign convention).
  3. Seeds the field_option row ft_sales_header.doc_sub_type → parent doc_type = 'SALES_INV', value NORMAL_ORDER. Mirrored in backend/app/core/field_options.py and frontend/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

3.2 Confirm

3.3 Cancel

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:

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":

3.4 Edit

3.5 Convert order → invoice

POST …/convert, one transaction:

  1. Lock the parent; must be PENDING and source_type = 'BO'. Customer, if any, must be active.
  2. New SALES_INV header: own SI number, own doc_trx_date, doc_sub_type = 'NORMAL_ORDER', status = 'PENDING', doc_due_date = doc_trx_date + customer.payment_terms_day (default 30); total_deposit_paid copied 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.
  3. Lines, item tax rows and promo rows are copied, not repriced; sales_id remapped.
  4. 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_invoice runs inside the same transaction and the invoice lands COMPLETED.
  5. ft_sales_parent_child link written with the real offline ids.
  6. Parent CAS PENDING → COMPLETED as 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):

3.6a Draft invoice — edit, complete, cancel

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:

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)

3.9 Readers and the till

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

E-mail — POST /api/sales-orders/{sales_id}/send-email and POST /api/sales-invoices/{sales_id}/send-email, body SendEmailRequest { to_email?: str }

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

What can be turned off — grouped as on the page; default in brackets. Everything else on the document is always printed.

GroupChoice (label the user sees)KeyDefaultNotes
HeaderCompany logologoon
HeaderLogo shape — Square / Wide (landscape) / Tall (portrait)logo_shapesquareNot 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
HeaderCompany address & phonecompany_addressonRequired 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
CustomerDeliver-to addressdeliver_toon
CustomerCustomer phone & e-mailcustomer_contactoncust_contact_no, cust_email (there is no contact-person column). Customer name always prints
CustomerSales rep namesales_repoffNew 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)
ItemsItem codeitem_codeon
ItemsItem barcodeitem_barcodeoffNew 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
ItemsItem noteitem_noteonserial numbers, installation notes
ItemsUnit priceunit_priceonRequired on a Sales Invoice (price per item). On a Sales Order turning it off also hides Discount
ItemsDiscountdiscountonForced off when Unit price is off
ItemsTax code & ratetax_codeonOptional 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 & paymentSubtotal, discount and tax linestotals_breakdownonRequired on a Sales Invoice (the tax amount must show). Total always prints
Totals & paymentAmount paid & balance duepaymentsonAmount due always prints
Totals & paymentLHDN e-Invoice QReinvoice_qronRequired on a Sales Invoice when the store has e-Invoice on; the choice cannot force it to appear otherwise
FooterSignature lines (Prepared / Approved / Received)signature_linesonSales Order only; the row is hidden on an invoice
FooterDocument notedoc_noteoffNew 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)
FooterNotes & termsnotes_termsonThe disclaimer / terms text from the footer file — what prints today
FooterBank detailsbank_detailsoffNew 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.

GroupChoiceKeyPOGRNPRNotes
HeaderCompany logo (+ logo shape)logo, logo_shapeononon
HeaderCompany address & phonecompany_addressonononBuyer company name always prints
SupplierSupplier addresssupplier_addressonononSupplier name always prints
SupplierSupplier contact person & phonesupplier_contactononon
SupplierBuyer name (staff who raised it)buyer_nameononon
DeliveryDeliver-to address (warehouse)deliver_toon——
DeliveryExpected delivery dateexpected_dateon——
DeliveryPayment termspayment_termson——
ItemsItem codeitem_codeononon
ItemsItem barcodeitem_barcodeoffoffoff
ItemsItem noteitem_noteononon
ItemsUnit priceunit_priceon—onPO: off also hides Discount
ItemsDiscountdiscounton——
ItemsTax code & ratetax_codeon——
ItemsBatch / expirybatch—onon
ItemsOrdered vs received differenceqty_difference—on—
TotalsSubtotal, discount and tax linestotals_breakdownon——Order total always prints
TotalsOther costs (freight, handling)other_costson——
TotalsCondition on arrivalcondition—on—
TotalsReturn reasonreturn_reason——on
FooterSignature linessignature_linesonononPO / PR: Prepared · Approved · Supplier acknowledgement. GRN: Received · Checked · Delivered by
FooterNotes & termsnotes_termsononon

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 }
}

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):

  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.

Report service (apps/v2-sellable-api/src/modules/report):

Screens, routes and permissions (CLAUDE.md rule 12)

Tests — the build is done when these pass

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)

  1. The item barcode prints as text under the item. The LHDN e-Invoice QR stays a scannable image.
  2. 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.
  3. Defaults are per document type.
  4. 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.

PlaceWhat happens todayRiskWhat to do
Backend model CompanySettingsJson (backend/app/models/company_settings.py:54)No extra= set, so unknown keys are ignored, not refusedSafeAdd 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 databaseReal risk: an old Company Settings screen, or any caller that sends a partial object, wipes the print optionsDo 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 requiresSafe once the key is in the file. Without it a new company simply has no key and the defaults applyAdd 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 thisTypecheck fails if the key is added to the type but not to the OmitAdd 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 tillSafe. A nested object under a new key is never cast or parsed by themNothing. 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 keysNot used here — the owner chose company-level onlyNothing 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)SafeSelect 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 replaceSafeNothing
Other Back-Office modules reading company_settings_json (stock settings, discount limits, dayshift payment, PSP threshold)Each reads its own keySafeNothing

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).

PhaseAppGoalSizeWhat a user gets when it ships
P1 Rendererv2-sellable-apiThe receipt follows request print_options › DOC_PRINT_OPTIONS › defaults; copies, stamp, remark and the HTML route workL · 3–4 daysNothing visible yet (no BO sends options). A company with the key already set prints its way
P2 Company settingv2-sellable-bo backend + frontendThe key on the model, the seed, the keep-when-None save rule, the PATCH, the Printing section on Company Settings › AdvancedM · 2 daysAn admin sets the printing choices once; every Print button, the e-mail PDF and the e-invoice mail follow them
P3 Per-print APIv2-sellable-bo backend?print_options= on /receipt, receipt-preview, GET print-options, print_options on send-emailS–M · 1.5 daysNothing new on screen; the API is ready for P4
P4 Print with options pagev2-sellable-bo frontendThe full page: checklist, live A4 preview, Print / Download / Send e-mail, save as the company defaultM–L · 3 daysThe whole §3.11 flow

About 10 dev-days, plus one for review fixes.

P1 — Renderer (apps/v2-sellable-api)

P2 — Company setting (apps/v2-sellable-bo, both sides)

P3 — Per-print API (apps/v2-sellable-bo/backend)

P4 — Print with options page (apps/v2-sellable-bo/frontend)

Deploy order and what breaks if it is wrong

Risks and the answer to each

  1. Unescaped free text in the PDF and the preview — html.escape on every new text in P1, test with <script>; sandboxed srcDoc frame in P4 (§3.11 "escaping").
  2. The PATCH writes nothing for existing companies — the ||-merge SQL in §3.11, tested against a real database with the key missing (P2).
  3. Copies block the event loop — asyncio.to_thread around the render, logo_shape replaces the network logo probe, each copy is its own PDF joined with pypdf so page numbers restart (P1).
  4. /html widens the report surface — receipt types only, same report_auth, and the BO calls it only after assert_receipt_access() (P1 + P3).
  5. A partial Company Settings PUT wipes the key; a missing TS Omit entry raises the typecheck count — keep-when-None rule, seed test and typecheck:ratchet all 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 / screenWhat you see todayWhyFixSize
Sales Summary (Reports)Cannot open — the page errors as soon as one Back-Office sale is in the date rangeThe 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 paidIn 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 reasonSame 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 emptySame COALESCE(d.business_date, h.doc_trx_date::date) in the select and the ORDER BY; model optionalS
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 documentsLEFT JOIN on the shift; sorted by document date, so nothing breaksSame COALESCE in the three selects so the column is never blankS
Sales Orders / Sales Invoices / Sales Returns list and view pages (salesListColumns.tsx, DetailsTab.tsx)"Business Date" shows "—" for a Back-Office documentModels already allow emptyEither show the document date there with a hint, or drop the column from the Back-Office lists. Owner to chooseXS
Printed order / invoice (receipt_renderer.py ~624, 748)The receipt header carries an empty business date fieldSame sourcePrint the document date; the print template does not show the business date today, so no visible defectXS
Shift Day / Business Day pages and reports (business_day_repository.py, ShiftDayPage, report_query.py ~176)Back-Office sales never appearThese are shift reports: they list shifts, not sales. A Back-Office sale has no shiftNo 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 countedEvery one filters fsh.dayshift_id = p_dayshift_idNo change, same reason—
Sales Collection, Void Bill, Cash Movement, Price Override, Credit Note, Exchange Item, Salesperson Performance, DashboardWork as beforeThey use the document date or are not sales reportsNone—

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

MethodPathPermission
GET/api/sales-orders · /{sales_id} · /dropdowns · /{sales_id}/receiptsales.order.view
POST/api/sales-orders (Idempotency-Key) · /calculate · /validate-couponsales.order.manage
PUT/api/sales-orders/{sales_id} · /{sales_id}/cashiersales.order.manage
POST/{sales_id}/confirm · /reconfirm · /cancel · /deposit · /deposit/refund (both Idempotency-Key) · /send-emailsales.order.manage
POST/{sales_id}/convertsales.order.manage and sales.invoice.manage

backend/app/api/sales_invoices.py

MethodPathPermission
GET/api/sales-invoices · /{sales_id} · /dropdowns · /sales-reps · /{sales_id}/receipt · /{sales_id}/payments · /{sales_id}/returnablesales.invoice.view
POST/api/sales-invoices (Idempotency-Key) · /calculate · /validate-coupon · /{sales_id}/complete (Idempotency-Key) · /{sales_id}/cancel · /{sales_id}/send-emailsales.invoice.manage
GET/giftcards/{giftcard_code}sales.invoice.manage
PUT/{sales_id} (notes, PO no.) · /{sales_id}/draft (full draft edit) · /{sales_id}/cashiersales.invoice.manage
POST/{sales_id}/payments (Idempotency-Key) · /{sales_id}/payments/{sales_payment_id}/voidsales.invoice.manage

backend/app/api/sales_requests.py

MethodPathPermission
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:

LayerCodeGates
L1 pagesales.order.view, sales.invoice.viewlist + detail pages, GET routes
L2 buttonsales.order.manageNew Sales Order, Edit (draft), Confirm, Cancel, Record Deposit, Refund Deposit, Change Sales Rep, Send Email (order)
L2 buttonsales.invoice.manageNew 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 buttonsales.invoice.emailSend 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 fieldsales.order.field.cost, sales.invoice.field.costCost / 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):

RoutePageGuardQuery params
/sales/ordersSalesOrderPagesales.order.viewfrom, to, store, doc_sub_type, page
/sales/orders/new · /sales/orders/:id/editSalesDocumentPage mode="order"sales.order.manageedit: Back-Office draft only
/sales/orders/:idSalesOrderDetailPagesales.order.viewmode, action=change-sales-rep|send-email
/sales/invoiceSalesInvoicePagesales.invoice.viewfrom, to, store, page
/sales/invoice/new · /sales/invoice/:id/editSalesDocumentPage mode="invoice"sales.invoice.manageedit: Back-Office draft only
/sales/invoice/:idSalesInvoiceDetailPagesales.invoice.viewmode, 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)

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".

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.

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:

ToneMeaningClasses (light)Sample
NeutralNot started; still editablebg-slate-100 text-slate-700Draft
WaitingParked until someone decidesbg-amber-100 text-amber-800Approval pending
In progressWork is happening on it right nowbg-violet-100 text-violet-800Processing
AcceptedApproved or partly done; not the endbg-blue-100 text-blue-800Approved
DoneFinished with a good outcomebg-green-100 text-green-800Completed
StoppedEnded on purpose, nothing wrongbg-gray-100 text-gray-700Cancelled
ProblemEnded badly or needs attentionbg-red-100 text-red-800Rejected

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.statusLabel (field-options)Used byToneClassesSample
DRAFTDraftorder, invoiceNeutralbg-slate-100 text-slate-700Draft
FOR_APPROVALApproval pendingorder, invoiceWaitingbg-amber-100 text-amber-800Approval pending
APPROVEDApprovedorder, invoiceAcceptedbg-blue-100 text-blue-800Approved
REJECTEDRejectedorder, invoiceProblembg-red-100 text-red-800Rejected
COMPLETEDCompletedorder, invoiceDonebg-green-100 text-green-800Completed
CANCELLEDCancelledorder, invoiceStoppedbg-gray-100 text-gray-700Cancelled
VOIDEDVoidedinvoiceProblembg-red-100 text-red-800Voided — a void is a correction and must stand out from a cancel — owner, 22 Sep 2026
RETURNEDReturnedinvoiceAcceptedbg-blue-100 text-blue-800Returned — a finished sale, not the plain way — owner, 22 Sep 2026

Not statuses, so outside the standard (they keep their own component colours):

WordWhereComponentClasses todaySample
Pending PaymentPayment Status column on both lists, view page header and Payment sectionDocumentStatusPillbg-amber-100 text-amber-800Pending Payment
Partial PaymentsameDocumentStatusPillbg-amber-100 text-amber-800Partial Payment
PaidsameDocumentStatusPillbg-emerald-100 text-emerald-800Paid
Overdue chipinvoice list / detailDocumentStatusPillas builtOverdue
Pre-Order / Normal / Special Order (type tags)list Type column, detail headerBadgeas 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 listspillgreen bg-emerald-100 / grey bg-gray-100 (owner, 21 Sep 2026)Yes No

Two things this standard changes for the module:

  1. Spelling CANCELLED — sales.ts on main keeps one spelling (CANCELLED, 2026-09-21 vocabulary note); the earlier CANCELED in §2.1a is replaced. The tone map accepts both, the field option is CANCELLED.
  2. Label of FOR_APPROVAL — field-options.ts on 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):

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

Sales Orders / Sales Invoices lists (DataTable, WORKFLOW_STATUS_CONFIGS.sales())

New / Edit — the step form (G16)

Select Items (ItemSelection / DataTable toolbar standard)

Serial Numbers / Batch Numbers pages (Purchase Order standard, §6.2)

View page — Sales Order / Sales Invoice (§6.3 for the full text)

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:

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

  1. A new order starts empty; Save draft on step 2 returns to the list with the row as Draft.
  2. Submit for Approval on a part-paid order or invoice is greyed on the screen and answers 409 on the API.
  3. A paid-in-full Draft submits to Approval pending; the author cannot approve it; an approver can, and a second approver gets 409.
  4. 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.
  5. 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.
  6. Issue on a paid, approved direct invoice completes it; stock moves; the till receives it.
  7. 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.
  8. Every status change appears in the Audit timeline (newest first, one arrow per step) with who, app, time and reason.
  9. 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.
  10. A line discount of 0.4 % or 50.1 % is refused on the screen and with 422 on the API.
  11. Status pills use the status-badges.ts tones; the invoice and order lists sort, filter and page like every other DataTable.
  12. pytest, npm test, npm run typecheck and check_workflow_registry.py all 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

#QuestionDecision
G21A 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".)
G24The 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).
G15Print / Send Email on every status.(a) Keep both on every status; stamp "DRAFT" / "APPROVAL PENDING" on the PDF of anything not yet approved.
G20The lifecycle.Build it; the exact scope is set when implementation starts.
G16The step form (steps in the footer).The new standard UI for New / Edit.
G22Batch split saved as one detail row per batch.Confirmed.
G18custom_field columns.Remove every use — this module uses no custom_field at all.
G19BO_ONLINE order type follow-ups.Keep (owner, 22 Sep 2026): teach the till the word, back-fill old Back-Office rows.
G1Payment terms on the customer screen.Editable on the customer screen; a new customer starts at 30 days by default.
G3 · G4Void-payment button; show the due date.Do them.
G11PREPAY_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

#WhatSizeNotes
G20The 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 daysScope fixed when implementation starts (owner). Replaces Confirm, deposits and Pending Payment. G13 is moot: a document is paid before it can be approved.
G16The step form (steps in the footer) replaces the single create / edit page — the new standard UI.3–4 daysDecided 22 Sep 2026.
G17Everything 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 backLands inside G16's Items and Customer steps.
G22Batch split saved as one detail row per batch, shown as one line.½ + ½ dayConfirmed 22 Sep 2026. Part of G17.
G24Audit read endpoint for sales orders / invoices (…/{id}/status-history) — also feeds the rejected / voided banner and "approved by" (Q2).½ dayDecided (a), 22 Sep 2026.
G15PDF stamp "DRAFT" / "APPROVAL PENDING" on anything not yet approved; Print and Send Email stay on every status.½ dayDecided (a), 22 Sep 2026.
G19Order 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).½ dayBuilt.
G18Remove every custom_field use from the sales models, services and screens.1 dayConfirmed 22 Sep 2026: this module uses no custom fields.

9C. Small fixes — no decision needed

#WhatSize
G1Payment terms on the customer screen: add the field, editable; a new customer starts at 30 days.½ day
G3Void payment has no button. Add a row action with a reason (the same service Void uses in one step, Q3).½ day
G4The due date is not shown on the Back-Office screens (only the PDF). Show "Payment due".Hours
G5Three inputs are never sent to the server (create-page Payment due, Shipping charge, payment date). Make them read-only or remove them.Hours
G2Notes on an approved order cannot be edited from the screen. Inline pencil.1 day
G6Leftovers from the old stock-hold design (a no-op endpoint, an unused banner, a shim, a test). Delete.Hours
G9One stale code comment. Delete.Minutes
G8Two tabs submitting at once are not deduplicated (only retries are). Accepted, no action.—

9D. Closed

#Closed how
G23Label of FOR_APPROVAL = "Approval pending" (owner, 22 Sep 2026).
G7No deposit on an order any more (§2.1a).
G12The Back-Office no longer writes or shows deposits.
G10The stock-function edit was dropped from the branch (owner, 22 Sep 2026).
G14Print / e-mail access check — fixed 15 Sep 2026 (§3.10).
G11Dropped from the plan (owner, 22 Sep 2026) — this round does not touch PREPAY_INV.
G13Moot under G21 — a document is paid in full before it can be approved.
G15Decided (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.

#GapFixOwner?Size
G20New 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
G16Step 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).No3–4 days
G17Mockup 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.No3 days frontend + 1 day backend (reason column, reasons endpoint, limit check)
G22Batch 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.Noin G17 (4) — ½ day backend (split / re-group in sales_document_write_service), ½ day frontend
G19Order 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.NoBuilt
G18custom_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).No1 day
G21Direct 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.Yesin G20
G24Audit 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
G13An 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
G15Print 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
G1Payment 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
G2Confirmed 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).No1 day
G3Void 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
G4Due 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
G5Inputs 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
G6Leftovers 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.NoHours
G9Stale 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.NoMinutes
G11Stale 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.NoHours
G8Two 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—
G23Label 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
D2No stage column; label derived from status + money + is_voided; source_type = 'BO' separates Back-Office rows from till rows.
D6One-to-one order → invoice enforced by the row lock plus the PENDING → COMPLETED compare-and-swap; nothing added to ft_sales_parent_child.
D7Conversion copies prices, discounts, tax; no repricing.
D8No stock hold on confirm; stock moves at invoice issue only.
D9Deposit = 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).
D10customer.payment_terms_day bigint NOT NULL DEFAULT 30; walk-in due at issue date.
D12Route-namespaced idempotency key stored in the existing offline-id columns; advisory lock; GET /api/sales-requests look-up. No idempotency table.
D17Draft order fully editable; confirmed order → notes / PO no. / delivery window; invoice → notes / PO no.
D18Replaced 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).
D21Replaced 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).
D27Reader 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.
D30Coupons claimed only at completion (paid in full), re-validated at that moment; a draft or pending invoice never burns a coupon.
D32Cancel 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.