Phase 4 O3 — Procurement and Sales Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Put procurement (requisition → order → receipt → purchase invoice, purchase returns) and sales (quotation → sales order → delivery → sales invoice, sales returns) on O1’s chain and O2’s stock ledger: receipts and deliveries drawn only from their order, the order’s approver kept off its receipt by the database, the three-way match at purchase-invoice approval with an audited Owner override, GRNI cleared with price variance and realised FX, the four operational reports, PDFs for orders and quotations, the HTTP surface, the screens and an end-to-end run of both chains.
Architecture: platform/trade gains O3’s services over O1’s tables: CreateStock (a receipt, delivery or return is built server-side from its source’s lines, in base units at the source price per base unit), TransitionStock (the header lock, the receipt’s separation-of-duties check and the “already invoiced” guard, then O2’s inventory.Transition), CreateCommitment/UpdateCommitment (quotation validity), a chain view and four reports. platform/payables imports platform/inventory (never trade) and reads trade_line by SQL: a purchase-invoice or credit-note line may name a receipt or purchase-return line with a quantity, the three-way match runs when the invoice enters its accrual state, and the accrual rule clears GRNI at the receipt’s frozen base, sends the price difference to purchase price variance and leaves the rate difference to the ledger’s fx_difference line. platform/receivables checks invoiced ≤ delivered − invoiced at issue; its posting is unchanged. One HTTP module, internal/modules/trade, serves the procurement and sales route groups, each behind its own install switch. The web gets an Operations sidebar group with one generic list/dialog/drawer set driven by a per-kind table.
Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · kernel/money · React + TanStack Router/Query + i18next · vitest + msw · Playwright.
Spec: docs/superpowers/specs/2026-09-30-operations-design.md — O3.1, O3.2, O3.3, Ruling R-1, owner decision OD-3, and the O3 drift-test bullet. Read it with this plan, and with the two plans it builds on: docs/superpowers/plans/2026-09-30-phase-4-o1-chain-items.md and docs/superpowers/plans/2026-09-30-phase-4-o2-stock-costing.md (their “Interfaces O3 consumes” sections and rulings are binding here).
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- O3 starts after O1 and O2 are merged, and consumes them exactly as their plans define. Names this plan uses from them:
- O1 (
platform/trade):Kind*constants,Document,Line,DocumentInput,LineInput,Filter,Cursor,DecodeCursor,TransitionInput,Create,Update,Get,List,Transition,Draft,OpenQuantity,Close,Document.Closed(), the sentinels (ErrNotFound,ErrNotDraft,ErrNotPreparer,ErrPairNotAllowed,ErrNotApproved,ErrSourceClosed,ErrPartyMismatch,ErrItemMismatch,ErrOverOpenQuantity,ErrSourceNotFound,ErrNothingOpen,ErrHasChildren,ErrNotClosable,ErrAlreadyClosed),*FieldError,*RevisionMismatchError, and the unexportedorgOf,fieldErr,lock,target,lockLines(O3’s files are in the same package). SQL:trade_semantic(org, instance),trade_open_quantity(org, line). Fixturetradetest.Env(Create,Commit,Move,UOM,Supplier,Customer). Seeded commitment graphs: requisition, order and sales orderdraft → pending_approval → approved (committed), quotationdraft → issued (committed)(Ruling O1-12).purchase_invoice_line.trade_line_id/sales_invoice_line.trade_line_idwith the kind and party guards of00702(Ruling O1-15). Module install keysprocurementandsales(both needinventory). - O2 (
platform/inventory):Kind*stock constants,ControlKindGRNI,Lot,SetLineLots,TransitionInput,Transition,Received,ReceivedValue,Rules(), and the errors itsstockProblemmaps (ErrInsufficientStock,ErrSerialHeld,ErrLayerConsumed,ErrValueExhausted,ErrAlreadyApplied,ErrHasChildren,ErrNothingReceived,ErrItemAccountMissing,ErrDocumentNotFound,ErrSerialRequired,ErrBatchRequired,ErrUntrackedLot,ErrLotsMismatch,ErrBatchExpiryMismatch,ErrLineNeedsItem).chart.PurchasePriceVariance. Posting states of the stock flows:goods_receipt→received,purchase_return→returned,delivery_order→delivered,sales_return→received; each cancels tocancelled(Owner seat, reason required). Fixturestocktest.Env(WH1,Unit,InventoryAccount,PurchaseAccount,COGSAccount,GRNI,PPV,Item,SupplierParty,CustomerParty,Doc,Post,Received,OnHand,SeedRate,EntryFor,LinesByAccount,AccountBalance,Qty,Amt,RequireQty,RequireAmount,RequirePg). Usestocktest(inventory 1580), nevertradetest.StockItem(1560), in any test that posts. - Boundaries:
platform/tradenever importsplatform/payablesorplatform/receivables;platform/payablesandplatform/receivablesnever importplatform/trade(they readtrade_line/trade_documentby SQL; payables importsplatform/inventoryforReceivedValueandControlKindGRNI).platform/inventoryimports neither.
- O1 (
- Migration range:
00740–00769(declared by O1 inmigrationranges_test.go). This plan uses00740–00743. - A float never touches money.
money.Amount/NUMERIC(19,4)/ OpenAPIMoneystring; quantitiesmoney.Rate/NUMERIC(19,6)/ O2’sStockQuantitystring. - Every new table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolation(USINGandWITH CHECK), composite(org_id, x)foreign keys, named constraints (none ending_check/_key/_excl). Trigger refusalsRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'. FunctionsSET search_path = pg_catalog, public, pg_temp, schema-qualified reads, noSECURITY DEFINER. - Every SQL string ties each org-scoped table to
org_id = $ndirectly or throughx.org_id = y.org_id; everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). - Retention (
internal/kernel/db/retention_test.go):purchase_invoice_match_override→appendOnly;purchase_match_tolerance→amendable. - The ledger is append-only; accounting reacts to semantic tags. Nothing here writes a journal line: payables’ accrual rule returns a
Draft; the stock kinds post through O2’s rules. - A claimant never picks their approver: requisition, order, quotation and sales-order approvals stay O1’s seeded grants. The order’s approver never posts its receipt (database-enforced, Task 1).
- Record text (reasons) is English or Bahasa Malaysia (
recordtext.Check), at most 500 characters. Every user-facing string in the web is an i18n key with en, ms and zh values. - The contract is
api/openapi.yaml. Paths first;make generatemust leave the tree clean (internal/gen/drift_test.go,web/src/drift/schema.test.ts,internal/kernel/vocab/vocab_test.go). Every route is behindhttpx.Protected;internal/app/routes_test.goand each module’sroutes_test.gopin them. - Presence before absence; watched failing once. Every refusal test shows the accepted case first. Every new guard has a Sabotage step: break it, run, read the red message, restore, quote the message in the commit body.
- Posting is process-global. A test that posts goes through
chaintest.New(Task 1) and never callst.Parallel(). - Tests: while iterating, run only the packages a task touches with
-count=1;go test -p 2 -count=1 ./...once, in Task 14. - Never bare
git stash. Commits end with a blank line andCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. Merging and pushing are the owner’s.
Rulings (where the spec is silent)
Section titled “Rulings (where the spec is silent)”- R-O3-1 Receipt only against an order (and a delivery only from a sales order, a return only from what it returns):
trade.CreateStockis the only API path that creates a stock document from a source, and every line names a source line. The database stays permissive (O2’s stock tests post sourceless receipts; O1’strade_line_guardalready refuses a wrong pair, party or over-draw). - R-O3-2 Stock documents are in base units.
CreateStockwrites each line in the item’s base unit at the source line’s price per base unit (unit_price ÷ uom_factor, 4 places). A text line (no item) cannot be received or delivered (R-O2-17). The web enters stock quantities in base units. - R-O3-3 The order’s approver is any member recorded as actor or authority of a decision into the order’s
committedstate. The guard is aBEFORE INSERTtrigger onworkflow_decision: a goods receipt’s decision into its accrual state by such a member is refused (goods_receipt_poster_not_order_approver) unless the receipt is flaggedposted_sole_owner— set only byTransitionStockafterworkflow.SoleOwnerExceptionre-counted the members under the organization lock (the00470shape; O1-18 lets a one-member organization approve its own order, so it must be able to receive it). The reason is on the audit row. - R-O3-4 Three-way quantity. An invoice line on a receipt line may take
received − already invoiced: the receipt line’s base quantity while the receipt is posted (its instance’saccrual_open), less the quantities of the other posted (not voided) invoices on it — the spec’s words. A purchase return is credited through a supplier credit note on the return line (returned − already credited), not deducted from what may be invoiced. - R-O3-5 Invoiced quantity is in base units: a matched AP line’s
quantityand a matched AR line’squantityare compared with the receipt’s or delivery’s base quantity. An AR line’sunit_codestays the preparer’s (MyInvois unchanged). - R-O3-6 Price tolerance. A matched line is within tolerance when
|invoice net − receipt value of the invoiced quantity|is at most the percentage of that value, or the difference per unit, converted to the base currency at the invoice’s rate, is at most the amount. Both default 0, which admits an exact match only. Stored per organization inpurchase_match_tolerance, read and set through/payables/match-tolerance(the rule belongs to AP approval), set underorg.profile.manage(O2’s R-O2-9 precedent for organization policy). - R-O3-7 The override (OD-3, BR-11 shape). A mismatch refuses approval with 409
three_way_mismatchlisting each line. Approving with amatch_override_reasonneedspayables.invoice.match_override(the Owner’s by default) and an approver who is not the preparer (Go, and thepurchase_invoice_match_overrideinsert trigger); it writes an append-only override row and an audit row. There is no sole-owner path: AP already refuses a preparer’s own invoice approval with no exception, so the case cannot arise. - R-O3-8 A matched AP line’s posting (spec O2.3 row 2, R-1): the service charges the line to the GRNI control account (the caller’s account is ignored) and refuses analytic shares on it (the receipt carried them). The accrual debits GRNI at the receipt’s value of the invoiced quantity in the document currency with the receipt’s frozen base, and sends the rest of what the line charges (its net, plus SST that has no account of its own) to purchase price variance at the invoice’s rate. The base residue between the receipt’s rate and the invoice’s is
ledger.Post’sfx_differenceline — the realised exchange difference. A matched invoice is in its order’s currency. A credit note on a return line is the mirror. - R-O3-9 An invoiced stock document is not cancelled. A receipt, return, delivery or sales return with a posted invoice or credit-note line on it is refused (
ErrInvoiced, 409invoiced_document); void the invoice first. Checked byTransitionStockunder the document’s line locks. - R-O3-10 Quotation validity is
due_date, required on a quotation. Expired is derived:due_date < todayand not closed. An expired quotation cannot feed a new sales order (ErrQuotationExpired, 409quotation_expired). - R-O3-11 (withdrawn). O1’s
Draftbase-unit price is fixed in O1’s own plan (controller ruling); O3 relies on it and does not touchchain.go. - R-O3-12 Reports (open purchase orders, GRNI aging, open sales orders with backorders flagged, delivered not invoiced) live in
platform/trade/report.goand are served as JSON by the trade module.platform/reportingis the ledger query layer (its Ruling R3: every figure a sum over journal lines), and these read the chain; O2’s R-O2-14 did the same for stock reports. No export kind. - R-O3-13 Permissions.
procurement.requisition|order|receipt.{read,create,transition}andsales.quotation|order|delivery.{read,create,transition}. Purchase returns ride onprocurement.receipt.*, sales returns onsales.delivery.*, a manual close on.transition, the chain view on the order key of its side. Provisioning: Owner gets every key (as always); Finance all eighteen; Employee the three requisition keys and O1’sinventory.item.read(a requisition picks items and units); Approverprocurement.requisition.readand.transition(a requisition is approved by the requester’s superior, O1-12).payables.invoice.match_overrideis the Owner’s only. Existing organizations get the same by migration00742. - R-O3-14 One HTTP module, two install keys.
internal/modules/traderegisters the procurement routes onoptional("procurement")and the sales routes onoptional("sales"); the permissions’ first segment is the install key, so switching either off removes its keys from every member. - R-O3-15 PDFs. Two C4 layout kinds,
purchase_orderandquotation. Statutory (always printed): number, date, both parties, our TIN/BRN/SST, the currency, the lines’ description and amount, and the total excluding tax (trade lines carry a tax code but no tax amount; tax is computed on the invoice). - R-O3-16 Chain view is the document’s ancestors and descendants (not its siblings) plus the AP and AR documents with a line on any of their lines.
- R-O3-17 GRNI aging is the outstanding quantity of each posted receipt line — received − returned − invoiced + credited — valued at the receipt’s base pro rata and aged from the receipt date, as of today.
- R-O3-18 Web. A new
operationssidebar group holds the eight document kinds, the reports, and O1’s Items and Locations (moved fromaccounting, O1-17). Serials are entered as a comma list; a batch line names one batch and optional expiry (several batches: several lines on the same source line).
Interfaces this plan produces
Section titled “Interfaces this plan produces”// package trade (internal/platform/trade)type StockLineInput struct { SourceLineID uuid.UUID; Quantity money.Rate; Lots []inventory.Lot }type StockInput struct { Kind string; SourceDocumentID, LocationID uuid.UUID; Lines []StockLineInput }func CreateStock(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in StockInput) (Document, error)func TransitionStock(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Document, workflow.Decision, error)func CreateCommitment(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in DocumentInput) (Document, error)func UpdateCommitment(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, in DocumentInput) (Document, error)func (d Document) Expired(today time.Time) boolfunc Chain(ctx context.Context, tx pgx.Tx, id uuid.UUID) (ChainView, error)func OpenOrders(ctx context.Context, tx pgx.Tx, kind string, today time.Time) ([]OpenOrderRow, error)func GRNIAging(ctx context.Context, tx pgx.Tx, today time.Time) ([]GRNIRow, error)func DeliveredNotInvoiced(ctx context.Context, tx pgx.Tx) ([]UninvoicedRow, error)var ErrNotStockKind, ErrReceiverApprovedOrder, ErrInvoiced, ErrNotCommitmentKind, ErrQuotationExpired error
// package payablestype LineInput struct { /* existing fields */ TradeLineID *uuid.UUID; Quantity *money.Rate }type Line struct { /* existing fields */ TradeLineID *uuid.UUID; Quantity *money.Rate }type TransitionInput struct { /* existing fields */ MatchOverrideReason string }type MatchTolerance struct { Percent money.Rate; Amount money.Amount; Revision int }func GetMatchTolerance(ctx context.Context, tx pgx.Tx) (MatchTolerance, error)func SetMatchTolerance(ctx context.Context, tx pgx.Tx, c clock.Clock, by uuid.UUID, expectedRevision int, in MatchTolerance) (MatchTolerance, error)type Mismatch struct { LineNo int; Rule string; Quantity, Available money.Rate; Amount, Expected money.Amount }type MatchError struct{ Mismatches []Mismatch }type MatchableLine struct { TradeLineID, DocumentID uuid.UUID; DocumentNumber string; LineNo int; ItemID *uuid.UUID; Description, Currency string; Available money.Rate; UnitPrice money.Amount }func MatchableLines(ctx context.Context, tx pgx.Tx, supplierID uuid.UUID, kind string) ([]MatchableLine, error)var ErrOverrideByPreparer, ErrNothingToClear error
// package receivablestype LineInput struct { /* existing fields */ TradeLineID *uuid.UUID }type Line struct { /* existing fields */ TradeLineID *uuid.UUID }type DeliveryMatchError struct { LineNo int; Invoiced, Available money.Rate }type MatchableLine struct { TradeLineID, DocumentID uuid.UUID; DocumentNumber string; LineNo int; ItemID *uuid.UUID; Description, Currency string; Available money.Rate; UnitPrice money.Amount }func MatchableLines(ctx context.Context, tx pgx.Tx, customerID uuid.UUID, kind string) ([]MatchableLine, error)
// package printingconst KindPurchaseOrder = "purchase_order"; const KindQuotation = "quotation"type TradeLine struct { No int; Description string; Quantity money.Rate; Unit string; UnitPrice, Amount money.Amount; TaxCode string }type TradeDocument struct { Kind, Number string; Date time.Time; Due *time.Time; FromName, FromAddress, FromTIN, FromBRN, FromSST, ToName, ToAddress, ToTIN, Currency string; Lines []TradeLine; NetTotal money.Amount }func TradeFields(v TradeDocument, l Layout) [][2]stringfunc RenderTradeDocument(v TradeDocument, s Settings) ([]byte, error)
// package chaintest (internal/platform/trade/chaintest) — the O3 test worldtype Env struct { *stocktest.Env; AP *payablestest.Env; AR *receivablestest.Env }File Structure
Section titled “File Structure”Create
| Path | Responsibility |
|---|---|
db/migrations/00740_receipt_poster_guard.sql |
trade_document.posted_sole_owner; the workflow_decision trigger keeping the order’s approver off its receipt |
db/migrations/00741_purchase_match.sql |
purchase_match_tolerance; purchase_invoice_line.quantity; purchase_invoice_match_override and its not-the-preparer trigger |
db/migrations/00742_trade_grants.sql |
Owner, Finance and Employee get O3’s keys in existing organizations |
db/migrations/00743_trade_layouts.sql |
purchase_order and quotation join document_layout_kind_known |
internal/platform/trade/stock.go |
CreateStock, TransitionStock, the approver and invoiced checks |
internal/platform/trade/commit.go |
CreateCommitment, UpdateCommitment, Document.Expired |
internal/platform/trade/report.go |
the four reports |
internal/platform/trade/chainview.go |
Chain |
internal/platform/trade/chaintest/chaintest.go |
the O3 test world |
internal/platform/trade/{stock,quotation,report,chainview,migration00742}_test.go |
their tests |
internal/platform/payables/match.go |
tolerance, matched-line checks, the three-way match, the override, GRNI clearing, matchable lines |
internal/platform/payables/{match_line,match,posting_match}_test.go |
its tests |
internal/platform/receivables/deliverymatch.go, deliverymatch_test.go |
delivery matching at issue, matchable lines |
internal/platform/printing/trade.go |
the order/quotation renderer |
internal/modules/trade/{http,permissions,routes,handlers,report_handlers,errors}.go |
the HTTP surface |
internal/modules/trade/{routes,env,handlers,grants}_test.go |
its tests |
internal/modules/payables/match_handlers_test.go |
the override key and the tolerance over HTTP |
web/src/features/trade/{kinds.ts,api.ts,DocumentsPage.tsx,DocumentDialog.tsx,StockDialog.tsx,DocumentDrawer.tsx,ChainView.tsx,ReportsPage.tsx,routes.tsx} |
the screens |
web/src/features/trade/__tests__/trade.test.tsx |
their tests |
web/src/features/payables/MatchToleranceCard.tsx |
the tolerance on the invoices page |
web/src/lib/i18n/locales/{en,ms,zh}/trade.json |
screen copy |
web/e2e/operations.spec.ts |
both chains end to end |
Modify — internal/platform/trade/invoicelink_test.go (the link now carries a quantity), internal/platform/payables/{invoice,posting,transition}.go, internal/platform/receivables/{document,transition}.go, internal/platform/printing/{layout,render,sample,printing_test}.go, internal/modules/payables/{permissions,routes,invoice_handlers,errors,routes_test,errors_test}.go, internal/modules/receivables/{routes,document_handlers,errors,errors_test}.go, internal/kernel/db/retention_test.go, internal/app/{app,routes_test}.go, cmd/api/main.go, cmd/{orgadmin,migrate}/{main,wiring_test}.go, constants.yaml (+ generated vocab), api/openapi.yaml (+ generated internal/gen/api/api.gen.go, web/src/lib/api/schema.d.ts), web/src/app/{routes.ts,shell/nav.ts}, web/src/features/payables/{api.ts,InvoiceDialog.tsx,InvoiceDrawer.tsx,InvoicesPage.tsx,__tests__/InvoicesPage.test.tsx}, web/src/features/receivables/{api.ts,InvoiceDialog.tsx,__tests__/InvoiceDialog.test.tsx}, web/src/features/printing/LayoutsPage.tsx, locale files shell, common, team, printing, payables, receivables (×3), web/e2e/support.ts, docs/ROADMAP.md, docs/DEPLOY_NOTES.md, docs/LOCK_ORDER.md.
Task 1: Receipts, deliveries and returns from their source; the order’s approver kept off its receipt
Section titled “Task 1: Receipts, deliveries and returns from their source; the order’s approver kept off its receipt”Files:
- Create:
db/migrations/00740_receipt_poster_guard.sql - Create:
internal/platform/trade/stock.go - Create:
internal/platform/trade/chaintest/chaintest.go - Test:
internal/platform/trade/stock_test.go
Interfaces:
-
Consumes: O1
trade.Create,Get,lock,target,lockLines,orgOf,fieldErr,Kind*,ErrPairNotAllowed,ErrOverOpenQuantity,EntityDocument; O2inventory.GetItem,inventory.SetLineLots,inventory.Transition,inventory.Lot;workflow.LockSoleOwner,workflow.SoleOwnerException;stocktest.Newand helpers. -
Produces:
trade.StockLineInput,trade.StockInput,trade.CreateStock,trade.TransitionStock,trade.ErrNotStockKind,trade.ErrReceiverApprovedOrder,trade.ErrInvoiced; columntrade_document.posted_sole_owner; constraintgoods_receipt_poster_not_order_approver;chaintest.Env,chaintest.New,chaintest.Posted,chaintest.ItemLine,chaintest.Take,(*Env).BoxItem,Order,SalesOrder,Stock,StockErr,MoveStockErr,PostStock,CancelStockErr,Receive,Deliver,Stocked,SetActive. -
Step 1: Write the test world
Create internal/platform/trade/chaintest/chaintest.go:
// Package chaintest is O3's test world: stocktest's organization (the stock// accounts, warehouse WH1 and the real Poster) with the AP and AR posting// rules registered too, and helpers that walk an order to its receipt and a// sales order to its delivery. Posting is process-global: a test using it// never calls t.Parallel().package chaintest
import ( "context" "slices" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/stocktest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest" "github.com/mcom/erp/internal/platform/receivables" "github.com/mcom/erp/internal/platform/receivables/receivablestest" "github.com/mcom/erp/internal/platform/trade")
type Env struct { *stocktest.Env AP *payablestest.Env AR *receivablestest.Env}
func New(t *testing.T) *Env { t.Helper() s := stocktest.New(t) e := &Env{Env: s, AP: &payablestest.Env{Env: s.Env.Env}, AR: &receivablestest.Env{Env: s.Env.Env}} e.UsePoster(t, slices.Concat(inventory.Rules(), payables.Rules(), receivables.Rules())...) return e}
// Posted is each stock kind's posting state (O2's seeded flows).var Posted = map[string]string{trade.KindReceipt: "received", trade.KindPurchaseReturn: "returned", trade.KindDelivery: "delivered", trade.KindSalesReturn: "received"}
// ItemLine is qty of item in uom at price, on a commitment document.func ItemLine(item, uom uuid.UUID, qty, price string) trade.LineInput { return trade.LineInput{ItemID: &item, Description: "Goods", Quantity: money.MustParseRate(qty), UOMID: uom, UnitPrice: money.MustParse(price)}}
// Take is one stock line: qty (base units) of source line, with its lots.func Take(line uuid.UUID, qty string, lots ...inventory.Lot) trade.StockLineInput { return trade.StockLineInput{SourceLineID: line, Quantity: money.MustParseRate(qty), Lots: lots}}
// BoxItem is a stock item kept in C62 and bought in BX of 12, on the// provisioned stock accounts.func (e *Env) BoxItem(t *testing.T, code string) uuid.UUID { t.Helper() box := e.UOM(t, "BX") inv, cogs, purchase := e.InventoryAccount, e.COGSAccount, e.PurchaseAccount var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { it, err := inventory.CreateItem(ctx, tx, e.Clock, inventory.ItemInput{Code: code, Type: inventory.TypeStock, Tracking: inventory.TrackingNone, Names: inventory.Names{"en": code + " item"}, BaseUOMID: e.Unit, InventoryAccountID: &inv, COGSAccountID: &cogs, PurchaseAccountID: &purchase, IsActive: true, UOMs: []inventory.ItemUOMInput{{UOMID: box, Factor: money.MustParseRate("12")}}}) id = it.ID return err }) return id}
// Order is a purchase order Staff prepares and Owner approves: committed,// and Owner is its approver. A foreign order carries a nominal header rate// (O1-3); a receipt is valued at its own date's rate (R-O2-3).func (e *Env) Order(t *testing.T, supplier uuid.UUID, currency string, lines ...trade.LineInput) trade.Document { t.Helper() in := trade.DocumentInput{Kind: trade.KindOrder, PartyID: &supplier, Currency: currency, Lines: lines} if currency != "MYR" { rate := money.MustParseRate("4.2") in.FXRate = &rate } return e.Commit(t, e.Create(t, in))}
// SalesOrder is a MYR sales order Staff prepares and Owner confirms.func (e *Env) SalesOrder(t *testing.T, customer uuid.UUID, lines ...trade.LineInput) trade.Document { t.Helper() return e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindSalesOrder, PartyID: &customer, Currency: "MYR", Lines: lines}))}
// StockErr drafts a stock document of kind from src as by, at WH1.func (e *Env) StockErr(by uuid.UUID, kind string, src trade.Document, lines ...trade.StockLineInput) (trade.Document, error) { var d trade.Document err := e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = trade.CreateStock(ctx, tx, e.Clock, by, trade.StockInput{Kind: kind, SourceDocumentID: src.ID, LocationID: e.WH1, Lines: lines}) return err }) return d, err}
func (e *Env) Stock(t *testing.T, by uuid.UUID, kind string, src trade.Document, lines ...trade.StockLineInput) trade.Document { t.Helper() d, err := e.StockErr(by, kind, src, lines...) require.NoError(t, err) return d}
// MoveStockErr takes one edge of a stock document through TransitionStock.func (e *Env) MoveStockErr(by uuid.UUID, d trade.Document, from, to, reason string) (trade.Document, error) { var out trade.Document err := e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error out, _, err = trade.TransitionStock(ctx, tx, e.Clock, trade.TransitionInput{ID: d.ID, From: from, To: to, Actor: by, Reason: reason}) return err }) return out, err}
// PostStock posts a draft stock document as its preparer.func (e *Env) PostStock(t *testing.T, d trade.Document) trade.Document { t.Helper() out, err := e.MoveStockErr(d.PreparedBy, d, "draft", Posted[d.Kind], "") require.NoError(t, err) return out}
// CancelStockErr cancels a posted stock document as the Owner seat.func (e *Env) CancelStockErr(d trade.Document) error { _, err := e.MoveStockErr(e.Owner, d, Posted[d.Kind], "cancelled", "Entered in error") return err}
// Receive drafts (Staff) and posts a receipt against a committed order.func (e *Env) Receive(t *testing.T, order trade.Document, lines ...trade.StockLineInput) trade.Document { t.Helper() return e.PostStock(t, e.Stock(t, e.Staff, trade.KindReceipt, order, lines...))}
// Deliver drafts (Staff) and posts a delivery against a confirmed sales order.func (e *Env) Deliver(t *testing.T, so trade.Document, lines ...trade.StockLineInput) trade.Document { t.Helper() return e.PostStock(t, e.Stock(t, e.Staff, trade.KindDelivery, so, lines...))}
// Stocked brings qty of item into WH1 at price through a sourceless O2// receipt (a test shortcut; the API only receives against an order).func (e *Env) Stocked(t *testing.T, item uuid.UUID, qty, price string) { t.Helper() sup := e.SupplierParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(item, qty, price)))}
// SetActive switches member on or off, as the one-member tests need.func (e *Env) SetActive(t *testing.T, member uuid.UUID, active bool) { t.Helper() _, err := e.H.Admin.Exec(context.Background(), `UPDATE organization_member SET active = $3 WHERE org_id = $1 AND id = $2`, e.Org, member, active) require.NoError(t, err)}- Step 2: Write the failing tests
Create internal/platform/trade/stock_test.go:
package trade_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/stocktest" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/chaintest" "github.com/mcom/erp/internal/platform/workflow")
// R-O3-1: a receipt is drawn from an order, line by line, and from nothing else.func TestAReceiptIsDrawnOnlyFromAnOrder(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") order := e.Order(t, e.SupplierParty(t), "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00"))
// PRESENCE: from the order, at its price, into WH1. grn := e.Stock(t, e.Staff, trade.KindReceipt, order, chaintest.Take(order.Lines[0].ID, "4")) require.Equal(t, trade.KindReceipt, grn.Kind) require.Equal(t, e.WH1, *grn.LocationID) require.Equal(t, order.Lines[0].ID, *grn.Lines[0].SourceLineID) stocktest.RequireAmount(t, "3", grn.Lines[0].UnitPrice)
// ABSENCE: drawn from a requisition, as another kind, from a foreign line, past the open quantity. req := e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindRequisition, Currency: "MYR", Lines: []trade.LineInput{chaintest.ItemLine(bolt, e.Unit, "1", "3.00")}})) _, err := e.StockErr(e.Staff, trade.KindReceipt, req, chaintest.Take(req.Lines[0].ID, "1")) require.ErrorIs(t, err, trade.ErrPairNotAllowed) _, err = e.StockErr(e.Staff, trade.KindAdjustment, order, chaintest.Take(order.Lines[0].ID, "1")) require.ErrorIs(t, err, trade.ErrNotStockKind) _, err = e.StockErr(e.Staff, trade.KindReceipt, order, chaintest.Take(req.Lines[0].ID, "1")) var fe *trade.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "source_line", fe.Rule) _, err = e.StockErr(e.Staff, trade.KindReceipt, order, chaintest.Take(order.Lines[0].ID, "7")) require.ErrorIs(t, err, trade.ErrOverOpenQuantity, "4 of 10 are already on a receipt")}
// R-O3-2: bought in boxes, received in units at the price per unit.func TestAStockDocumentIsInBaseUnitsAtThePricePerBaseUnit(t *testing.T) { e := chaintest.New(t) nut := e.BoxItem(t, "NUT") order := e.Order(t, e.SupplierParty(t), "MYR", chaintest.ItemLine(nut, e.UOM(t, "BX"), "2", "24.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "5")) require.Equal(t, e.Unit, grn.Lines[0].UOMID) stocktest.RequireAmount(t, "2", grn.Lines[0].UnitPrice) stocktest.RequireAmount(t, "10", grn.Lines[0].NetAmount) stocktest.RequireAmount(t, "10", e.Received(t, grn.Lines[0].ID).Base) stocktest.RequireQty(t, "5", e.OnHand(t, nut, e.WH1, ""))}
// The spec's O3 drift test, first half: the order's approver cannot post// its receipt — refused in Go, and by the database behind it.func TestTheOrderApproverCannotPostItsReceipt(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") order := e.Order(t, e.SupplierParty(t), "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) // Owner approved it post := func(by uuid.UUID, d trade.Document) error { return e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.Transition(ctx, tx, e.Clock, inventory.TransitionInput{DocumentID: d.ID, From: "draft", To: "received", Actor: by}) return err }) }
// PRESENCE: Staff, who did not approve the order, posts past every Go check. staffs := e.Stock(t, e.Staff, trade.KindReceipt, order, chaintest.Take(order.Lines[0].ID, "4")) require.NoError(t, post(e.Staff, staffs)) e.PostStock(t, e.Stock(t, e.Staff, trade.KindReceipt, order, chaintest.Take(order.Lines[0].ID, "1"))) stocktest.RequireQty(t, "5", e.OnHand(t, bolt, e.WH1, ""))
// ABSENCE: the Owner prepares a receipt and cannot post it — in Go ... owners := e.Stock(t, e.Owner, trade.KindReceipt, order, chaintest.Take(order.Lines[0].ID, "3")) _, err := e.MoveStockErr(e.Owner, owners, "draft", "received", "") require.ErrorIs(t, err, trade.ErrReceiverApprovedOrder) // ... and in the database, bypassing it. stocktest.RequirePg(t, post(e.Owner, owners), "23514", "goods_receipt_poster_not_order_approver") stocktest.RequireQty(t, "5", e.OnHand(t, bolt, e.WH1, ""), "the refused receipt moved nothing")}
// R-O3-3: a one-member organization receives its own order, with a reason.func TestAOneMemberOrganizationReceivesItsOwnOrderWithAReason(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") order := e.Order(t, e.SupplierParty(t), "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) e.SetActive(t, e.Staff, false) own := e.Stock(t, e.Owner, trade.KindReceipt, order, chaintest.Take(order.Lines[0].ID, "3"))
_, err := e.MoveStockErr(e.Owner, own, "draft", "received", "") require.ErrorIs(t, err, workflow.ErrSoleOwnerReasonRequired) d, err := e.MoveStockErr(e.Owner, own, "draft", "received", "Only member; I received the goods myself") require.NoError(t, err) require.Equal(t, "received", d.State) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM trade_document WHERE org_id = $1 AND id = $2 AND posted_sole_owner`, e.Org, own.ID)) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_id = $2 AND action = 'receipt_sole_owner' AND after->>'reason' = 'Only member; I received the goods myself'`, e.Org, own.ID))}Add "github.com/google/uuid" to the import block.
- Step 3: Run the tests to verify they fail
Run: go test ./internal/platform/trade/ -run 'TestAReceiptIsDrawn|TestAStockDocumentIsIn|TestTheOrderApprover|TestAOneMember' -count=1
Expected: FAIL to compile — undefined: trade.CreateStock.
- Step 4: Write the migration
Create db/migrations/00740_receipt_poster_guard.sql:
-- +goose Up-- Phase 4 O3.1 separation of duties (R-O3-3): the member who approved a-- purchase order cannot post a goods receipt against it. The decision row is-- the one fact every path writes, so the guard sits on it: a goods receipt's-- decision into its accrual state by a member recorded as actor or authority-- of a decision into any of its orders' committed state is refused. A-- one-member organization may, under the audited sole-owner exception: the-- receipt is flagged by trade.TransitionStock after it re-counted the members-- under the organization lock (00470's shape); the reason is on the audit row.ALTER TABLE trade_document ADD COLUMN posted_sole_owner BOOLEAN NOT NULL DEFAULT false, ADD CONSTRAINT trade_document_sole_owner_receipt CHECK (NOT posted_sole_owner OR kind = 'goods_receipt');
-- +goose StatementBeginCREATE FUNCTION goods_receipt_poster_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE doc_id UUID; sole BOOLEAN;BEGIN SELECT d.id, d.posted_sole_owner INTO doc_id, sole FROM public.trade_document d WHERE d.org_id = NEW.org_id AND d.instance_id = NEW.instance_id AND d.kind = 'goods_receipt'; IF NOT FOUND OR sole THEN RETURN NEW; END IF; IF NOT EXISTS (SELECT 1 FROM public.workflow_state s WHERE s.org_id = NEW.org_id AND s.definition_id = NEW.definition_id AND s.state_key = NEW.to_key AND s.semantic = 'accrual') THEN RETURN NEW; END IF; IF EXISTS ( SELECT 1 FROM public.trade_line r JOIN public.trade_line o ON o.org_id = r.org_id AND o.id = r.source_line_id JOIN public.trade_document od ON od.org_id = o.org_id AND od.id = o.document_id JOIN public.workflow_decision dd ON dd.org_id = od.org_id AND dd.instance_id = od.instance_id JOIN public.workflow_state s ON s.org_id = dd.org_id AND s.definition_id = dd.definition_id AND s.state_key = dd.to_key WHERE r.org_id = NEW.org_id AND r.document_id = doc_id AND r.withdrawn_at IS NULL AND s.semantic = 'committed' AND NEW.actor_member_id IN (dd.actor_member_id, dd.authority_member_id)) THEN RAISE EXCEPTION 'the member who approved the order cannot post a receipt against it' USING ERRCODE = '23514', CONSTRAINT = 'goods_receipt_poster_not_order_approver'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER goods_receipt_poster_guard BEFORE INSERT ON workflow_decision FOR EACH ROW EXECUTE FUNCTION goods_receipt_poster_guard();
-- +goose Down-- A receipt posted under the exception would lose its flag; refuse instead-- of rewriting the record.-- +goose StatementBeginDO $$BEGIN IF EXISTS (SELECT 1 FROM trade_document WHERE posted_sole_owner) THEN RAISE EXCEPTION '00740 down: a receipt was posted under the sole-owner exception'; END IF;END $$;-- +goose StatementEndDROP TRIGGER goods_receipt_poster_guard ON workflow_decision;DROP FUNCTION goods_receipt_poster_guard();ALTER TABLE trade_document DROP COLUMN posted_sole_owner;- Step 5: Write
stock.go
Create internal/platform/trade/stock.go:
package trade
import ( "context" "errors" "fmt" "slices" "strings"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
var ( ErrNotStockKind = errors.New("trade: receipts, deliveries and returns only") ErrReceiverApprovedOrder = errors.New("trade: the member who approved the order cannot post its receipt") ErrInvoiced = errors.New("trade: an invoice or credit note is posted against this document; void it first"))
// stockSource is each stock kind's one source kind (spec O3.1, O3.2).var stockSource = map[string]string{KindReceipt: KindOrder, KindPurchaseReturn: KindReceipt, KindDelivery: KindSalesOrder, KindSalesReturn: KindDelivery}
// StockLineInput is one line of a receipt, delivery or return: the source// line it takes from, the quantity in the item's base unit (R-O3-2), and// its serials or batches.type StockLineInput struct { SourceLineID uuid.UUID Quantity money.Rate Lots []inventory.Lot}
// StockInput is a receipt (from an order), a purchase return (from a// receipt), a delivery (from a sales order) or a sales return (from a// delivery), at a location.type StockInput struct { Kind string SourceDocumentID uuid.UUID LocationID uuid.UUID Lines []StockLineInput}
// CreateStock drafts a stock document from its source (R-O3-1): every line// names a line of the source and carries its item, description, tax code// and shares, in the item's base unit at the source's price per base unit// (R-O3-2). O1's chain guard refuses a line past its source's open quantity.func CreateStock(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in StockInput) (Document, error) { want, ok := stockSource[in.Kind] if !ok { return Document{}, ErrNotStockKind } if len(in.Lines) == 0 || len(in.Lines) > 200 { return Document{}, fieldErr("lines", "lines_count", "a document has 1 to 200 lines") } src, err := Get(ctx, tx, in.SourceDocumentID) if errors.Is(err, ErrNotFound) { return Document{}, fieldErr("source_document_id", "source_document", "no such document in this organization") } if err != nil { return Document{}, err } if src.Kind != want { return Document{}, ErrPairNotAllowed } byID := make(map[uuid.UUID]Line, len(src.Lines)) for _, l := range src.Lines { byID[l.ID] = l } one := money.OneRate() rate, loc := src.FXRate, in.LocationID doc := DocumentInput{Kind: in.Kind, PartyID: src.PartyID, Currency: src.Currency, FXRate: &rate, LocationID: &loc} for i, l := range in.Lines { field := fmt.Sprintf("lines[%d].source_line_id", i) s, ok := byID[l.SourceLineID] switch { case !ok: return Document{}, fieldErr(field, "source_line", "a line of the source document") case s.ItemID == nil: return Document{}, fieldErr(field, "item_line", "a text line has no goods to receive, deliver or return") } it, err := inventory.GetItem(ctx, tx, *s.ItemID) if err != nil { return Document{}, err } price, err := s.UnitPrice.MulDiv(one, s.UOMFactor) if err != nil { return Document{}, err } sourceLine := s.ID doc.Lines = append(doc.Lines, LineInput{ItemID: s.ItemID, Description: s.Description, Quantity: l.Quantity, UOMID: it.BaseUOMID, UnitPrice: price, TaxCodeID: s.TaxCodeID, Shares: s.Shares, SourceLineID: &sourceLine}) } d, err := Create(ctx, tx, c, actor, doc) if err != nil { return Document{}, err } if _, err := lock(ctx, tx, d.ID); err != nil { // SetLineLots' contract: the header held FOR UPDATE return Document{}, err } for i, l := range in.Lines { if len(l.Lots) == 0 { continue } if err := inventory.SetLineLots(ctx, tx, d.Lines[i].ID, l.Lots); err != nil { return Document{}, err } } return d, nil}
// TransitionStock moves a receipt, delivery or return (the commitment kinds// use Transition). ORDER (LOCK_ORDER.md, O3): in a one-member organization,// organization FOR UPDATE (workflow.LockSoleOwner) → header FOR UPDATE → on// a cancel or rejection, its live lines FOR UPDATE in id order → the O3// checks (the receipt's poster, what has been invoiced), reading without// locks → inventory.Transition (costing locks, moves, workflow.Transition,// its audit) → the sole-owner audit row, last.func TransitionStock(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Document, workflow.Decision, error) { org, err := orgOf(ctx) if err != nil { return Document{}, workflow.Decision{}, err } if err := workflow.LockSoleOwner(ctx, tx); err != nil { return Document{}, workflow.Decision{}, err } d, err := lock(ctx, tx, in.ID) if err != nil { return Document{}, workflow.Decision{}, err } if _, ok := stockSource[d.Kind]; !ok { return Document{}, workflow.Decision{}, ErrNotStockKind } to, err := target(ctx, tx, d.InstanceID, in.To) if err != nil { return Document{}, workflow.Decision{}, err } sole := false switch to.Semantic { case posting.Accrual: if d.Kind != KindReceipt { break } approvers, err := orderApprovers(ctx, tx, org, d.ID) if err != nil { return Document{}, workflow.Decision{}, err } if !slices.Contains(approvers, in.Actor) { break } ok, err := workflow.SoleOwnerException(ctx, tx, in.Actor, in.Reason) if err != nil { return Document{}, workflow.Decision{}, err } if !ok { return Document{}, workflow.Decision{}, ErrReceiverApprovedOrder } if _, err := tx.Exec(ctx, `UPDATE trade_document SET posted_sole_owner = true WHERE org_id = $1 AND id = $2`, org, d.ID); err != nil { return Document{}, workflow.Decision{}, fmt.Errorf("trade: flag sole owner: %w", err) } sole = true case posting.Cancelled, posting.Rejected: if err := lockLines(ctx, tx, org, d.ID); err != nil { return Document{}, workflow.Decision{}, err } n, err := postedInvoiceLines(ctx, tx, org, d.ID) if err != nil { return Document{}, workflow.Decision{}, err } if n > 0 { return Document{}, workflow.Decision{}, ErrInvoiced // R-O3-9 } } dec, err := inventory.Transition(ctx, tx, c, inventory.TransitionInput{DocumentID: d.ID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) var pg *pgconn.PgError if errors.As(err, &pg) && pg.ConstraintName == "goods_receipt_poster_not_order_approver" { return Document{}, workflow.Decision{}, ErrReceiverApprovedOrder } if err != nil { return Document{}, workflow.Decision{}, err } after, err := Get(ctx, tx, d.ID) if err != nil { return Document{}, workflow.Decision{}, err } if sole { if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityDocument, EntityID: d.ID, Action: "receipt_sole_owner", After: map[string]any{"sole_owner_exception": true, "reason": strings.TrimSpace(in.Reason)}}); err != nil { return Document{}, workflow.Decision{}, err } } return after, dec, nil}
// orderApprovers is every member recorded as actor or authority of a// decision into the committed state of an order the receipt draws from// (R-O3-3; 00740's trigger asks the same question).func orderApprovers(ctx context.Context, tx pgx.Tx, org, receiptID uuid.UUID) ([]uuid.UUID, error) { rows, err := tx.Query(ctx, ` SELECT DISTINCT m FROM trade_line r JOIN trade_line o ON o.org_id = r.org_id AND o.id = r.source_line_id JOIN trade_document od ON od.org_id = o.org_id AND od.id = o.document_id JOIN workflow_decision dd ON dd.org_id = od.org_id AND dd.instance_id = od.instance_id JOIN workflow_state s ON s.org_id = dd.org_id AND s.definition_id = dd.definition_id AND s.state_key = dd.to_key CROSS JOIN LATERAL unnest(ARRAY[dd.actor_member_id, dd.authority_member_id]) AS m WHERE r.org_id = $1 AND r.document_id = $2 AND r.withdrawn_at IS NULL AND s.semantic = 'committed'`, org, receiptID) if err != nil { return nil, fmt.Errorf("trade: order approvers: %w", err) } return pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])}
// postedInvoiceLines counts the AP and AR lines of posted, not voided,// invoices and credit notes on any live line of document id (R-O3-9).func postedInvoiceLines(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) (int, error) { var n int err := tx.QueryRow(ctx, ` SELECT (SELECT count(*) FROM trade_line l JOIN purchase_invoice_line pl ON pl.org_id = l.org_id AND pl.trade_line_id = l.id AND pl.withdrawn_at IS NULL JOIN purchase_invoice pi ON pi.org_id = pl.org_id AND pi.id = pl.invoice_id JOIN workflow_instance wi ON wi.org_id = pi.org_id AND wi.id = pi.instance_id WHERE l.org_id = $1 AND l.document_id = $2 AND wi.accrual_open) + (SELECT count(*) FROM trade_line l JOIN sales_invoice_line sl ON sl.org_id = l.org_id AND sl.trade_line_id = l.id AND sl.withdrawn_at IS NULL JOIN sales_invoice si ON si.org_id = sl.org_id AND si.id = sl.invoice_id JOIN workflow_instance wi ON wi.org_id = si.org_id AND wi.id = si.instance_id WHERE l.org_id = $1 AND l.document_id = $2 AND wi.accrual_open)`, org, id).Scan(&n) if err != nil { return 0, fmt.Errorf("trade: invoiced lines: %w", err) } return n, nil}- Step 6: Run the tests to verify they pass
Run: make generate && go test ./internal/platform/trade/... ./internal/kernel/db/ -count=1
Expected: PASS (TestEveryTenantQueryCarriesAnExplicitOrgPredicate, TestEveryConstraintIsNamed included).
- Step 7: Sabotage
In 00740, change AND s.semantic = 'committed' to AND s.semantic = 'accrual'; run go test ./internal/platform/trade/ -run TestTheOrderApproverCannotPostItsReceipt -count=1; read the red (RequirePg gets no error: the Owner’s receipt posts); restore. Then in TransitionStock delete the if !ok { return … ErrReceiverApprovedOrder } branch; run -run TestAOneMember; read the red; restore.
- Step 8: Commit
git add db/migrations/00740_receipt_poster_guard.sql internal/platform/trade/stock.go \ internal/platform/trade/stock_test.go internal/platform/trade/chaintestgit commit -m "Phase 4 O3: stock documents from their source; the order approver cannot post its receipt
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 2: Quotation validity and commitments
Section titled “Task 2: Quotation validity and commitments”Files:
- Create:
internal/platform/trade/commit.go - Test:
internal/platform/trade/quotation_test.go
Interfaces:
-
Consumes: O1
trade.Create,Update,Get,Document.Closed,orgOf,fieldErr;workflow.CommitmentKinds;chaintest(Task 1). -
Produces:
trade.CreateCommitment,trade.UpdateCommitment,(trade.Document).Expired(today time.Time) bool,trade.ErrNotCommitmentKind,trade.ErrQuotationExpired. -
Step 1: Write the failing tests
Create internal/platform/trade/quotation_test.go:
package trade_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/chaintest")
func commitErr(e *chaintest.Env, in trade.DocumentInput) (trade.Document, error) { var d trade.Document err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = trade.CreateCommitment(ctx, tx, e.Clock, e.Staff, in) return err }) return d, err}
// R-O3-10: a quotation says how long it is valid; expiry is derived; an// expired quotation feeds no sales order.func TestAQuotationStatesItsValidityAndExpiresByTheCalendar(t *testing.T) { e := chaintest.New(t) cust := e.CustomerParty(t) bolt := e.Item(t, "BOLT", "stock", "none") in := trade.DocumentInput{Kind: trade.KindQuotation, PartyID: &cust, Currency: "MYR", Lines: []trade.LineInput{chaintest.ItemLine(bolt, e.Unit, "5", "9.00")}} _, err := commitErr(e, in) var fe *trade.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "valid_until_required", fe.Rule)
until := expensestest.Today.AddDate(0, 0, 7) in.DueDate = &until q, err := commitErr(e, in) require.NoError(t, err) q = e.Commit(t, q) require.False(t, q.Expired(until), "valid through its last day") require.True(t, q.Expired(until.AddDate(0, 0, 1)))
line := chaintest.ItemLine(bolt, e.Unit, "2", "9.00") line.SourceLineID = &q.Lines[0].ID so := trade.DocumentInput{Kind: trade.KindSalesOrder, PartyID: &cust, Currency: "MYR", Lines: []trade.LineInput{line}} _, err = commitErr(e, so) require.NoError(t, err, "PRESENCE: while valid, it feeds a sales order")
e.Clock = clock.Fixed(until.AddDate(0, 0, 1)) _, err = commitErr(e, so) require.ErrorIs(t, err, trade.ErrQuotationExpired) _, err = commitErr(e, trade.DocumentInput{Kind: trade.KindReceipt, Currency: "MYR"}) require.ErrorIs(t, err, trade.ErrNotCommitmentKind)}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/trade/ -run TestAQuotationStates -count=1
Expected: FAIL to compile — undefined: trade.CreateCommitment.
- Step 3: Write
commit.go
Create internal/platform/trade/commit.go:
package trade
import ( "context" "errors" "fmt" "slices" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/workflow")
var ( ErrNotCommitmentKind = errors.New("trade: requisitions, orders, quotations and sales orders only") ErrQuotationExpired = errors.New("trade: the quotation's validity has passed; issue a new one"))
// Expired: a quotation past its validity date (spec O3.2: derived, never// stored). A closed quotation is closed, not expired.func (d Document) Expired(today time.Time) bool { return d.Kind == KindQuotation && d.DueDate != nil && d.DueDate.Before(today) && !d.Closed()}
// CreateCommitment drafts a requisition, order, quotation or sales order// with O3's rules on top of Create's (R-O3-10).func CreateCommitment(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in DocumentInput) (Document, error) { if err := checkCommitment(ctx, tx, c, in); err != nil { return Document{}, err } return Create(ctx, tx, c, actor, in)}
// UpdateCommitment is Update with the same rules; the kind is the document's.func UpdateCommitment(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, in DocumentInput) (Document, error) { d, err := Get(ctx, tx, id) if err != nil { return Document{}, err } in.Kind = d.Kind if err := checkCommitment(ctx, tx, c, in); err != nil { return Document{}, err } return Update(ctx, tx, c, id, actor, expectedRevision, in)}
func checkCommitment(ctx context.Context, tx pgx.Tx, c clock.Clock, in DocumentInput) error { if !slices.Contains(workflow.CommitmentKinds, in.Kind) { return ErrNotCommitmentKind } if in.Kind == KindQuotation && in.DueDate == nil { return fieldErr("due_date", "valid_until_required", "a quotation states the date it is valid until") } if in.Kind != KindSalesOrder { return nil } var sources []uuid.UUID for _, l := range in.Lines { if l.SourceLineID != nil { sources = append(sources, *l.SourceLineID) } } if len(sources) == 0 { return nil } org, err := orgOf(ctx) if err != nil { return err } var expired bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id WHERE l.org_id = $1 AND l.id = ANY($2) AND d.kind = 'quotation' AND d.closed_at IS NULL AND d.due_date < $3)`, org, sources, clock.Today(ctx, c)).Scan(&expired); err != nil { return fmt.Errorf("trade: quotation validity: %w", err) } if expired { return ErrQuotationExpired } return nil}- Step 4: Run the tests to verify they pass
Run: go test ./internal/platform/trade/... -count=1
Expected: PASS.
- Step 5: Sabotage
In checkCommitment change d.due_date < $3 to d.due_date < $3 - 30; run -run TestAQuotationStates; read the red (ErrQuotationExpired expected, nil got); restore.
- Step 6: Commit
git add internal/platform/trade/commit.go internal/platform/trade/quotation_test.gogit commit -m "Phase 4 O3: quotation validity and commitment checks
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 3: A purchase-invoice line names its receipt line; the tolerance and the override record
Section titled “Task 3: A purchase-invoice line names its receipt line; the tolerance and the override record”Files:
- Create:
db/migrations/00741_purchase_match.sql - Create:
internal/platform/payables/match.go - Modify:
internal/platform/payables/invoice.go(Line,LineInput,loadLines,checkInput,writeLines) - Modify:
internal/platform/trade/invoicelink_test.go(O1’s link test now sets the quantity the link requires) - Modify:
internal/platform/trade/chaintest/chaintest.go(AP helpers) - Modify:
internal/kernel/db/retention_test.go - Test:
internal/platform/payables/match_line_test.go
Interfaces:
-
Consumes: O1’s
purchase_invoice_line.trade_line_id(00702); O2’sinventory.ControlKindGRNI;chart.ResolveControl;chaintest(Task 1);payablestest.Env.Doc-style document upload throughexpensestest.Env.Document. -
Produces:
payables.LineInput.TradeLineID *uuid.UUID,payables.LineInput.Quantity *money.Rate, the same two onpayables.Line;payables.MatchTolerance,payables.GetMatchTolerance,payables.SetMatchTolerance; tablespurchase_match_tolerance,purchase_invoice_match_override(constraintpurchase_invoice_match_override_not_preparer);chaintest.Matched,(*chaintest.Env).APSupplier,APInvoiceErr,APInvoice. -
Step 1: Add the AP helpers to the test world
Append to internal/platform/trade/chaintest/chaintest.go:
// APSupplier is the payables view of a supplier party.func (e *Env) APSupplier(t *testing.T, party uuid.UUID) payables.Supplier { t.Helper() var s payables.Supplier e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error s, err = payables.GetSupplier(ctx, tx, party) return err }) return s}
// Matched is an AP line on a receipt (invoice) or purchase return (credit// note) line: qty in base units, net in the document currency, no tax.func Matched(line uuid.UUID, qty, net string) payables.LineInput { q := money.MustParseRate(qty) return payables.LineInput{Description: "Goods received", NetAmount: money.MustParse(net), TaxAmount: money.Zero(), TradeLineID: &line, Quantity: &q}}
// APInvoiceErr files a draft AP document of kind for the supplier party as// by, dated today, with a verified document and a relaxation-receipt basis,// so it is submittable as it stands. rate is nil in MYR.func (e *Env) APInvoiceErr(t *testing.T, by uuid.UUID, kind string, supplier uuid.UUID, currency string, rate *money.Rate, lines ...payables.LineInput) (payables.Invoice, error) { t.Helper() basis, reason := "relaxation_receipt", "Supplier issued a paper invoice" doc := e.Document(t, by, "application/pdf", []byte("invoice "+uuid.NewString())) var inv payables.Invoice err := e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error inv, err = payables.CreateInvoice(ctx, tx, e.Clock, by, payables.InvoiceInput{Kind: kind, SupplierID: supplier, SupplierInvoiceNo: "INV-" + uuid.NewString()[:8], InvoiceDate: clock.Today(e.Ctx, e.Clock), Currency: currency, FXRate: rate, DocumentID: &doc, DocumentBasis: &basis, RelaxationReason: &reason, Lines: lines}) return err }) return inv, err}
// APInvoice is APInvoiceErr as Staff, which must succeed.func (e *Env) APInvoice(t *testing.T, kind string, supplier uuid.UUID, currency string, rate *money.Rate, lines ...payables.LineInput) payables.Invoice { t.Helper() inv, err := e.APInvoiceErr(t, e.Staff, kind, supplier, currency, rate, lines...) require.NoError(t, err) return inv}Add "github.com/mcom/erp/internal/kernel/clock" to chaintest’s imports.
- Step 2: Write the failing tests
Create internal/platform/payables/match_line_test.go:
package payables_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/inventory/stocktest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest" "github.com/mcom/erp/internal/platform/trade/chaintest")
// R-O3-8 at save: a matched line names a receipt line of its supplier, in// the order's currency, with a quantity; the service charges it to GRNI.func TestAMatchedLineIsChargedToGRNIAndNamesItsReceipt(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "10")) line := grn.Lines[0].ID
inv := e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(line, "10", "30.00")) require.Equal(t, e.GRNI, inv.Lines[0].AccountID, "PRESENCE: saved, on GRNI") require.Equal(t, line, *inv.Lines[0].TradeLineID) stocktest.RequireQty(t, "10", *inv.Lines[0].Quantity)
refused := func(rule, kind string, supplier uuid.UUID, currency string, l payables.LineInput) { t.Helper() var rate *money.Rate if currency != "MYR" { r := money.MustParseRate("4.3") rate = &r } _, err := e.APInvoiceErr(t, e.Staff, kind, supplier, currency, rate, l) var fe *payables.FieldError require.ErrorAs(t, err, &fe, rule) require.Equal(t, rule, fe.Rule) } noQty := chaintest.Matched(line, "1", "3.00") noQty.Quantity = nil refused("quantity_positive", payables.KindInvoice, sup, "MYR", noQty) refused("trade_line_kind", payables.KindCreditNote, sup, "MYR", chaintest.Matched(line, "1", "3.00")) refused("trade_line_party", payables.KindInvoice, e.SupplierParty(t), "MYR", chaintest.Matched(line, "1", "3.00")) refused("currency_matches_order", payables.KindInvoice, sup, "USD", chaintest.Matched(line, "1", "3.00")) unmatched := payablestest.Line(e.Account(t, "6100"), "5.00") q := money.MustParseRate("1") unmatched.Quantity = &q refused("quantity_only_matched", payables.KindInvoice, sup, "MYR", unmatched)}
// R-O3-6: the tolerance is exact until set; setting it is audited and// guarded by its revision.func TestTheMatchToleranceIsExactUntilSet(t *testing.T) { e := chaintest.New(t) var tol payables.MatchTolerance e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error tol, err = payables.GetMatchTolerance(ctx, tx) return err }) stocktest.RequireQty(t, "0", tol.Percent) stocktest.RequireAmount(t, "0", tol.Amount) require.Zero(t, tol.Revision)
set := func(rev int, pct, amt string) (payables.MatchTolerance, error) { var out payables.MatchTolerance err := e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { var err error out, err = payables.SetMatchTolerance(ctx, tx, e.Clock, e.Owner, rev, payables.MatchTolerance{Percent: money.MustParseRate(pct), Amount: money.MustParse(amt)}) return err }) return out, err } got, err := set(0, "2.5", "0.10") require.NoError(t, err) require.Equal(t, 1, got.Revision) stocktest.RequireQty(t, "2.5", got.Percent) var stale *payables.RevisionMismatchError _, err = set(0, "3", "0") require.ErrorAs(t, err, &stale) var fe *payables.FieldError _, err = set(1, "101", "0") require.ErrorAs(t, err, &fe) require.Equal(t, "percent_range", fe.Rule) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'purchase_match_tolerance'`, e.Org))}
// R-O3-7, the database half: an override row names an approver who did not// prepare the invoice.func TestAMatchOverrideIsNeverThePreparers(t *testing.T) { e := payablestest.New(t) inv := e.Invoice(t, payables.KindInvoice, e.Supplier(t, "S1"), "INV-1", payablestest.Line(e.Account(t, "6100"), "10.00")) insert := func(by uuid.UUID) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO purchase_invoice_match_override (org_id, invoice_id, overridden_by, reason, mismatches, created_at) VALUES ($1, $2, $3, 'Agreed with the supplier', '[{"line_no":1}]', now())`, e.Org, inv.ID, by) return err }) } require.NoError(t, insert(e.Owner), "PRESENCE: the Owner, who did not prepare it") stocktest.RequirePg(t, insert(e.Staff), "23514", "purchase_invoice_match_override_not_preparer")}- Step 3: Run the tests to verify they fail
Run: go test ./internal/platform/payables/ -run 'TestAMatchedLine|TestTheMatchTolerance|TestAMatchOverride' -count=1
Expected: FAIL to compile — unknown field TradeLineID in struct literal of type payables.LineInput.
- Step 4: Write the migration
Create db/migrations/00741_purchase_match.sql:
-- +goose Up-- Phase 4 O3.1 three-way match (OD-3; O3 Rulings R-O3-4..R-O3-8).---- The tolerance a purchase invoice's price may stray from its receipt's, per-- organization: a percentage and an amount per unit in the base currency.-- No row: both 0, an exact match only.CREATE TABLE purchase_match_tolerance ( org_id UUID PRIMARY KEY REFERENCES organization (id), percent NUMERIC(7,4) NOT NULL CONSTRAINT purchase_match_tolerance_percent_range CHECK (percent >= 0 AND percent <= 100), amount NUMERIC(19,4) NOT NULL CONSTRAINT purchase_match_tolerance_amount_nonnegative CHECK (amount >= 0), revision INT NOT NULL DEFAULT 1 CONSTRAINT purchase_match_tolerance_revision_positive CHECK (revision >= 1), updated_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT purchase_match_tolerance_updated_by_fk FOREIGN KEY (org_id, updated_by) REFERENCES organization_member (org_id, id));ALTER TABLE purchase_match_tolerance ENABLE ROW LEVEL SECURITY;ALTER TABLE purchase_match_tolerance FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON purchase_match_tolerance USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON purchase_match_tolerance FROM erp_app;
-- A line matched to a receipt (or purchase return) line says how much of it,-- in the item's base unit (R-O3-5); an unmatched line has no quantity.ALTER TABLE purchase_invoice_line ADD COLUMN quantity NUMERIC(19,6), ADD CONSTRAINT purchase_invoice_line_quantity_with_link CHECK ((trade_line_id IS NULL) = (quantity IS NULL)), ADD CONSTRAINT purchase_invoice_line_quantity_positive CHECK (quantity IS NULL OR quantity > 0);
-- An approval past a three-way mismatch (R-O3-7): who, why, and what was-- out of line. Append-only; the audit row carries the same.CREATE TABLE purchase_invoice_match_override ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), invoice_id UUID NOT NULL, overridden_by UUID NOT NULL, reason TEXT NOT NULL CONSTRAINT purchase_invoice_match_override_reason_present CHECK (btrim(reason) <> '' AND length(reason) <= 500 AND reason !~ '[-ヿ㐀-䶿一-鿿가-]'), mismatches JSONB NOT NULL CONSTRAINT purchase_invoice_match_override_mismatches_listed CHECK (jsonb_typeof(mismatches) = 'array' AND jsonb_array_length(mismatches) > 0), created_at TIMESTAMPTZ NOT NULL, CONSTRAINT purchase_invoice_match_override_tenant_id UNIQUE (org_id, id), CONSTRAINT purchase_invoice_match_override_invoice_fk FOREIGN KEY (org_id, invoice_id) REFERENCES purchase_invoice (org_id, id), CONSTRAINT purchase_invoice_match_override_by_fk FOREIGN KEY (org_id, overridden_by) REFERENCES organization_member (org_id, id));CREATE INDEX purchase_invoice_match_override_by_invoice ON purchase_invoice_match_override (org_id, invoice_id);
-- +goose StatementBeginCREATE FUNCTION purchase_invoice_match_override_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE preparer UUID;BEGIN SELECT i.prepared_by INTO preparer FROM public.purchase_invoice i WHERE i.org_id = NEW.org_id AND i.id = NEW.invoice_id; IF NEW.overridden_by = preparer THEN RAISE EXCEPTION 'the member who prepared purchase invoice % cannot override its three-way match', NEW.invoice_id USING ERRCODE = '23514', CONSTRAINT = 'purchase_invoice_match_override_not_preparer'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER purchase_invoice_match_override_guard BEFORE INSERT ON purchase_invoice_match_override FOR EACH ROW EXECUTE FUNCTION purchase_invoice_match_override_guard();
ALTER TABLE purchase_invoice_match_override ENABLE ROW LEVEL SECURITY;ALTER TABLE purchase_invoice_match_override FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON purchase_invoice_match_override USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE UPDATE, DELETE ON purchase_invoice_match_override FROM erp_app;
-- +goose DownDROP TABLE purchase_invoice_match_override;DROP FUNCTION purchase_invoice_match_override_guard();ALTER TABLE purchase_invoice_line DROP COLUMN quantity;DROP TABLE purchase_match_tolerance;In internal/kernel/db/retention_test.go, add "purchase_invoice_match_override", // O3: an approval past a three-way mismatch to appendOnly and "purchase_match_tolerance", // O3: the organization's price tolerance, amended in place and audited to amendable.
In internal/platform/trade/invoicelink_test.go (O1), the link helper’s UPDATE now sets the quantity the new check pairs with the link; replace
_, err := tx.Exec(ctx, `UPDATE `+table+` SET trade_line_id = $3 WHERE org_id = $1 AND invoice_id = $2 AND withdrawn_at IS NULL`, e.Org, invoice, tradeLine)with
set := `trade_line_id = $3` if table == "purchase_invoice_line" { set += `, quantity = 1` // 00741: a matched AP line carries its quantity } _, err := tx.Exec(ctx, `UPDATE `+table+` SET `+set+` WHERE org_id = $1 AND invoice_id = $2 AND withdrawn_at IS NULL`, e.Org, invoice, tradeLine)- Step 5: Carry the link and quantity through the invoice
In internal/platform/payables/invoice.go:
(a) In type Line struct, after ClassificationCode *string, add:
// TradeLineID names the goods receipt line (an invoice) or purchase return // line (a credit note) the line is matched to, with Quantity in the // item's base unit (O3 R-O3-5); both nil on an unmatched line. TradeLineID *uuid.UUID Quantity *money.Rate(b) In type LineInput struct, after ClassificationCode *string, add:
// TradeLineID and Quantity match the line to a receipt or return line // (O3); AccountID is then ignored — the line is charged to GRNI. TradeLineID *uuid.UUID Quantity *money.Rate(c) In loadLines, change the SELECT to
SELECT id, line_no, description, account_id, tax_code_id, net_amount, tax_amount, net_base, tax_base, classification_code, shares, trade_line_id, quantity FROM purchase_invoice_line WHERE org_id = $1 AND invoice_id = $2 AND withdrawn_at IS NULL ORDER BY line_no`, org, id)and its Scan to
if err := rows.Scan(&l.ID, &l.LineNo, &l.Description, &l.AccountID, &l.TaxCodeID, &l.NetAmount, &l.TaxAmount, &l.NetBase, &l.TaxBase, &l.ClassificationCode, &raw, &l.TradeLineID, &l.Quantity); err != nil {(d) In checkInput, replace
if err := checkLineAccount(ctx, tx, l.AccountID); err != nil { return nil, fieldErr(f("account_id"), "account_usable", err.Error()) }with
switch { case l.TradeLineID != nil: grni, err := matchedLine(ctx, tx, in, l, f) if err != nil { return nil, err } l.AccountID = grni case l.Quantity != nil: return nil, fieldErr(f("quantity"), "quantity_only_matched", "a quantity is given only on a line matched to a receipt or a return") default: if err := checkLineAccount(ctx, tx, l.AccountID); err != nil { return nil, fieldErr(f("account_id"), "account_usable", err.Error()) } }(e) In writeLines, change the INSERT to
INSERT INTO purchase_invoice_line (org_id, invoice_id, line_no, description, account_id, tax_code_id, net_amount, tax_amount, net_base, tax_base, classification_code, shares, trade_line_id, quantity, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, org, id, i+1, l.Description, l.AccountID, l.TaxCodeID, l.NetAmount, l.TaxAmount, l.netBase, l.taxBase, l.ClassificationCode, l.shares, l.TradeLineID, l.Quantity, now); err != nil {- Step 6: Write the tolerance and the save-time rule
Create internal/platform/payables/match.go:
// The three-way match (Phase 4 O3.1, OD-3; O3 Rulings R-O3-4..R-O3-8): a// purchase invoice or supplier credit note line matched to a goods receipt or// purchase return line, checked for quantity and price when the document// enters its accrual state, and posted against GRNI.package payables
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/inventory")
// EntityMatchTolerance is the tolerance's audit entity kind.const EntityMatchTolerance = "purchase_match_tolerance"
// MatchTolerance is how far a matched line's price may stray from its// receipt's (R-O3-6): a percentage of the receipt's value, or an amount per// unit in the base currency. Revision 0: never set (both 0, exact only).type MatchTolerance struct { Percent money.Rate Amount money.Amount Revision int}
// GetMatchTolerance is the organization's tolerance; zeros when unset.func GetMatchTolerance(ctx context.Context, tx pgx.Tx) (MatchTolerance, error) { org, err := orgOf(ctx) if err != nil { return MatchTolerance{}, err } out := MatchTolerance{Percent: money.MustParseRate("0"), Amount: money.Zero()} err = tx.QueryRow(ctx, `SELECT percent, amount, revision FROM purchase_match_tolerance WHERE org_id = $1`, org). Scan(&out.Percent, &out.Amount, &out.Revision) if errors.Is(err, pgx.ErrNoRows) { return out, nil } if err != nil { return MatchTolerance{}, fmt.Errorf("payables: tolerance: %w", err) } return out, nil}
// SetMatchTolerance stores the tolerance against the revision the caller// read (0 for never set). Audited.func SetMatchTolerance(ctx context.Context, tx pgx.Tx, c clock.Clock, by uuid.UUID, expectedRevision int, in MatchTolerance) (MatchTolerance, error) { org, err := orgOf(ctx) if err != nil { return MatchTolerance{}, err } switch { case in.Percent.Cmp(money.MustParseRate("0")) < 0 || in.Percent.Cmp(money.MustParseRate("100")) > 0: return MatchTolerance{}, fieldErr("percent", "percent_range", "a percentage from 0 to 100") case in.Amount.IsNegative(): return MatchTolerance{}, fieldErr("amount", "nonnegative", "the amount cannot be negative") } before, err := GetMatchTolerance(ctx, tx) if err != nil { return MatchTolerance{}, err } if before.Revision != expectedRevision { return MatchTolerance{}, &RevisionMismatchError{Current: before.Revision} } tag, err := tx.Exec(ctx, ` INSERT INTO purchase_match_tolerance (org_id, percent, amount, updated_by, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (org_id) DO UPDATE SET percent = EXCLUDED.percent, amount = EXCLUDED.amount, revision = purchase_match_tolerance.revision + 1, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at WHERE purchase_match_tolerance.revision = $6`, org, in.Percent, in.Amount, by, c.Now(), expectedRevision) if err != nil { return MatchTolerance{}, fmt.Errorf("payables: set tolerance: %w", err) } if tag.RowsAffected() == 0 { cur, _ := GetMatchTolerance(ctx, tx) return MatchTolerance{}, &RevisionMismatchError{Current: cur.Revision} } after, err := GetMatchTolerance(ctx, tx) if err != nil { return MatchTolerance{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityMatchTolerance, EntityID: org, Action: "set", Before: map[string]string{"percent": before.Percent.String(), "amount": before.Amount.String()}, After: map[string]string{"percent": after.Percent.String(), "amount": after.Amount.String()}})}
// matchedKind is the stock kind each AP kind matches (O1 Ruling O1-15).var matchedKind = map[string]string{KindInvoice: inventory.KindGoodsReceipt, KindCreditNote: inventory.KindPurchaseReturn}
// matchedLine is R-O3-8 at save: the line names a live receipt line (an// invoice) or purchase return line (a credit note) of this supplier, in the// order's currency, with a positive quantity and no shares of its own (the// receipt carried them). It returns the GRNI account the line is charged to.// 00702's trigger re-checks kind and party.func matchedLine(ctx context.Context, tx pgx.Tx, in *InvoiceInput, l LineInput, f func(string) string) (uuid.UUID, error) { org, err := orgOf(ctx) if err != nil { return uuid.Nil, err } switch { case l.Quantity == nil || !l.Quantity.IsPositive(): return uuid.Nil, fieldErr(f("quantity"), "quantity_positive", "the quantity invoiced must be positive") case len(l.Shares) > 0: return uuid.Nil, fieldErr(f("shares"), "matched_line_shares", "a matched line takes its analysis from the receipt") } var kind, currency string var supplier *uuid.UUID err = tx.QueryRow(ctx, ` SELECT d.kind, d.currency, d.supplier_party_id FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id WHERE l.org_id = $1 AND l.id = $2 AND l.withdrawn_at IS NULL`, org, *l.TradeLineID).Scan(&kind, ¤cy, &supplier) if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, fieldErr(f("trade_line_id"), "trade_line", "no such receipt or return line") } if err != nil { return uuid.Nil, fmt.Errorf("payables: matched line: %w", err) } switch { case kind != matchedKind[in.Kind]: return uuid.Nil, fieldErr(f("trade_line_id"), "trade_line_kind", "an invoice matches a goods receipt line; a credit note, a purchase return line") case supplier == nil || *supplier != in.SupplierID: return uuid.Nil, fieldErr(f("trade_line_id"), "trade_line_party", "the line is another supplier's") case currency != in.Currency: return uuid.Nil, fieldErr(f("trade_line_id"), "currency_matches_order", "a matched document is in its order's currency, "+currency) } grni, err := chart.ResolveControl(ctx, tx, inventory.ControlKindGRNI) if err != nil { return uuid.Nil, err } return grni.ID, nil}- Step 7: Run the tests to verify they pass
Run: make generate && go test ./internal/platform/payables/ ./internal/platform/trade/ ./internal/kernel/db/ -count=1
Expected: PASS — the existing AP tests unchanged (an unmatched line takes the old path), O1’s TestAnInvoiceLineLinksOnlyToItsKindsLineOfTheSameParty green with the quantity.
- Step 8: Sabotage
Drop the IF NEW.overridden_by = preparer block from 00741’s function; run go test ./internal/platform/payables/ -run TestAMatchOverrideIsNeverThePreparers -count=1; read the red (RequirePg: no error); restore. Replace matchedKind[in.Kind] with inventory.KindGoodsReceipt; run -run TestAMatchedLine; read the red (credit note accepted); restore.
- Step 9: Commit
git add db/migrations/00741_purchase_match.sql internal/platform/payables internal/platform/trade/invoicelink_test.go \ internal/platform/trade/chaintest internal/kernel/db/retention_test.gogit commit -m "Phase 4 O3: purchase invoice lines match receipt lines; tolerance and override records
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 4: The three-way match at approval, and the Owner’s override
Section titled “Task 4: The three-way match at approval, and the Owner’s override”Files:
- Modify:
internal/platform/payables/match.go(append) - Modify:
internal/platform/payables/transition.go(TransitionInput,TransitionInvoice) - Modify:
internal/platform/trade/chaintest/chaintest.go(append) - Test:
internal/platform/payables/match_test.go
Interfaces:
-
Consumes: Task 3 (
matchedKind,GetMatchTolerance, the line fields,purchase_invoice_match_override); O2inventory.ReceivedValue. -
Produces:
payables.TransitionInput.MatchOverrideReason string;payables.Mismatch,payables.MatchError,payables.MismatchQuantity = "quantity",payables.MismatchPrice = "price",payables.ErrOverrideByPreparer,payables.ErrNothingToClear,payables.MatchableLine,payables.MatchableLines; unexportedgrniShare(ctx, tx, org uuid.UUID, kind string, l Line) (inventory.Received, error)(Task 5 posts with it);(*chaintest.Env).SetTolerance,ApproveErr,Approve. -
Step 1: Add the approval helpers to the test world
Append to internal/platform/trade/chaintest/chaintest.go:
// SetTolerance sets the organization's match tolerance as the Owner.func (e *Env) SetTolerance(t *testing.T, pct, amt string) { t.Helper() require.NoError(t, e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { cur, err := payables.GetMatchTolerance(ctx, tx) if err != nil { return err } _, err = payables.SetMatchTolerance(ctx, tx, e.Clock, e.Owner, cur.Revision, payables.MatchTolerance{Percent: money.MustParseRate(pct), Amount: money.MustParse(amt)}) return err }))}
// ApproveErr submits an AP document still in draft (as its preparer), then// approves it as by, with an override reason when one is given. It reads the// document's current state first, so a refused approval can be retried.func (e *Env) ApproveErr(t *testing.T, inv payables.Invoice, by uuid.UUID, override string) (payables.Invoice, error) { t.Helper() e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error inv, err = payables.GetInvoice(ctx, tx, inv.ID) return err }) if inv.State == "draft" { require.NoError(t, e.DB.InTx(e.As(inv.PreparedBy), func(ctx context.Context, tx pgx.Tx) error { _, _, err := payables.TransitionInvoice(ctx, tx, e.Clock, payables.TransitionInput{ID: inv.ID, From: "draft", To: "pending_review", Actor: inv.PreparedBy}) return err })) } var out payables.Invoice err := e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error out, _, err = payables.TransitionInvoice(ctx, tx, e.Clock, payables.TransitionInput{ID: inv.ID, From: "pending_review", To: "approved", Actor: by, MatchOverrideReason: override}) return err }) return out, err}
// Approve is ApproveErr by the Owner with no override, which must succeed.func (e *Env) Approve(t *testing.T, inv payables.Invoice) payables.Invoice { t.Helper() out, err := e.ApproveErr(t, inv, e.Owner, "") require.NoError(t, err) return out}- Step 2: Write the failing tests
Create internal/platform/payables/match_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/inventory/stocktest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/trade/chaintest")
// The spec's O3 drift test, second half: a three-way mismatch blocks// approval unless an Owner who is not the preparer overrides (OD-3).func TestAThreeWayMismatchBlocksApprovalUnlessAnOwnerWhoDidNotPrepareItOverrides(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "6"))
// PRESENCE: six received, six invoiced at the order's price. e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "6", "18.00")))
// ABSENCE by quantity: nothing of that receipt line is left. more := e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "1", "3.00")) _, err := e.ApproveErr(t, more, e.Owner, "") var me *payables.MatchError require.ErrorAs(t, err, &me) require.Equal(t, payables.MismatchQuantity, me.Mismatches[0].Rule) stocktest.RequireQty(t, "0", me.Mismatches[0].Available)
// ABSENCE by price: four more received, invoiced at 3.10 each. grn2 := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "4")) dear := e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn2.Lines[0].ID, "4", "12.40")) _, err = e.ApproveErr(t, dear, e.Owner, "") require.ErrorAs(t, err, &me) require.Equal(t, payables.MismatchPrice, me.Mismatches[0].Rule) stocktest.RequireAmount(t, "12", me.Mismatches[0].Expected) require.Zero(t, e.Count(t, `SELECT count(*) FROM purchase_invoice_match_override WHERE org_id = $1`, e.Org))
// OVERRIDE: the Owner, who did not prepare it, with a reason — recorded and audited. posted, err := e.ApproveErr(t, dear, e.Owner, "Price rise agreed with the supplier by phone") require.NoError(t, err) require.Equal(t, "approved", posted.State) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM purchase_invoice_match_override WHERE org_id = $1 AND invoice_id = $2 AND overridden_by = $3`, e.Org, dear.ID, e.Owner)) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_id = $2 AND action = 'match_override'`, e.Org, dear.ID))}
// R-O3-7: the preparer is never the one who overrides.func TestThePreparerCannotOverrideTheirOwnInvoicesMatch(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "4", "3.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "4")) inv, err := e.APInvoiceErr(t, e.Owner, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "4", "12.40")) require.NoError(t, err)
_, err = e.ApproveErr(t, inv, e.Owner, "") var me *payables.MatchError require.ErrorAs(t, err, &me, "PRESENCE: without a reason it is the mismatch that refuses") _, err = e.ApproveErr(t, inv, e.Owner, "I prepared it and I approve it") require.ErrorIs(t, err, payables.ErrOverrideByPreparer)}
// R-O3-6: within either limit is within tolerance; both 0 is exact.func TestAPriceWithinEitherToleranceLimitIsApproved(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "20", "10.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "20")) twoPct := e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "10", "102.00")) // 2%, 0.20 a unit fifteen := e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "10", "101.50")) // 1.5%, 0.15 a unit
var me *payables.MatchError _, err := e.ApproveErr(t, fifteen, e.Owner, "") require.ErrorAs(t, err, &me, "PRESENCE: at 0 and 0 any difference refuses")
e.SetTolerance(t, "1", "0.15") _, err = e.ApproveErr(t, twoPct, e.Owner, "") require.ErrorAs(t, err, &me, "2% and 0.20 a unit exceed both limits") _, err = e.ApproveErr(t, fifteen, e.Owner, "") require.NoError(t, err, "0.15 a unit is within the amount limit")
e.SetTolerance(t, "2", "0") _, err = e.ApproveErr(t, twoPct, e.Owner, "") require.NoError(t, err, "2% is within the percentage limit")}
// R-O3-4: a voided invoice gives its quantity back; the picker shows what is left.func TestAVoidedInvoiceGivesItsQuantityBack(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "5", "3.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "5")) first := e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "3", "9.00")))
var lines []payables.MatchableLine e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error lines, err = payables.MatchableLines(ctx, tx, sup, payables.KindInvoice) return err }) require.Len(t, lines, 1) require.Equal(t, grn.Lines[0].ID, lines[0].TradeLineID) stocktest.RequireQty(t, "2", lines[0].Available)
second := e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "5", "15.00")) _, err := e.ApproveErr(t, second, e.Owner, "") var me *payables.MatchError require.ErrorAs(t, err, &me) e.AP.Void(t, first) _, err = e.ApproveErr(t, second, e.Owner, "") require.NoError(t, err)}- Step 3: Run the tests to verify they fail
Run: go test ./internal/platform/payables/ -run 'TestAThreeWay|TestThePreparerCannot|TestAPriceWithin|TestAVoidedInvoice' -count=1
Expected: FAIL to compile — unknown field MatchOverrideReason in struct literal of type payables.TransitionInput.
- Step 4: Check the match on the way into the accrual state
In internal/platform/payables/transition.go:
(a) Replace type TransitionInput struct with:
type TransitionInput struct { ID uuid.UUID From, To string Actor uuid.UUID Reason string // MatchOverrideReason approves a purchase invoice or credit note past a // three-way mismatch (OD-3, R-O3-7). The handler checks the actor holds // payables.invoice.match_override; TransitionInvoice refuses the // preparer. Ignored when nothing mismatches and by every other move. MatchOverrideReason string}(b) In TransitionInvoice, change the doc comment’s ORDER line to
// TransitionInvoice moves an invoice or credit note. ORDER: header FOR// UPDATE → checks (into the accrual state: the matched receipt and return// lines FOR UPDATE in id order, the tolerance, an override's row and audit)// → cancelled_at → workflow.Transition (posts the accrual or its reversal)// → audit, last.and insert, immediately before the line if to.Semantic == posting.Cancelled || to.Semantic == posting.Rejected {:
if to.Semantic == posting.Accrual && !inv.IsOpening { if err := checkMatch(ctx, tx, c, inv, in.Actor, in.MatchOverrideReason); err != nil { return Invoice{}, workflow.Decision{}, err } }- Step 5: Write the match
Append to internal/platform/payables/match.go, and add "encoding/json", "strings" and "github.com/mcom/erp/internal/kernel/recordtext" to its imports:
// The rules a mismatch names.const ( MismatchQuantity = "quantity" MismatchPrice = "price")
// Mismatch is one matched line outside the three-way match (spec O3.1). A// quantity mismatch names what is left to take (Available); a price// mismatch names the receipt's value of the quantity (Expected).type Mismatch struct { LineNo int `json:"line_no"` Rule string `json:"rule"` Quantity money.Rate `json:"quantity"` Available money.Rate `json:"available"` Amount money.Amount `json:"amount"` Expected money.Amount `json:"expected"`}
// MatchError refuses an approval past a three-way mismatch (OD-3).type MatchError struct{ Mismatches []Mismatch }
func (e *MatchError) Error() string { parts := make([]string, len(e.Mismatches)) for i, m := range e.Mismatches { if m.Rule == MismatchQuantity { parts[i] = fmt.Sprintf("line %d takes %s where %s is left to invoice", m.LineNo, m.Quantity, m.Available) } else { parts[i] = fmt.Sprintf("line %d is %s against %s at the order's price", m.LineNo, m.Amount, m.Expected) } } return "payables: three-way match: " + strings.Join(parts, "; ")}
var ( ErrOverrideByPreparer = errors.New("payables: the member who prepared the invoice cannot override its three-way match") ErrNothingToClear = errors.New("payables: the matched receipt has nothing left to clear"))
// checkMatch is the three-way match (spec O3.1) of a document entering its// accrual state, under its header lock. Each matched receipt or return line// is locked (id order); the document may take no more of it than is left// (R-O3-4), and each line's price must be within tolerance of the receipt's// (R-O3-6). A mismatch is refused unless an override reason is given by an// approver who is not the preparer (R-O3-7), which is recorded and audited.func checkMatch(ctx context.Context, tx pgx.Tx, c clock.Clock, inv Invoice, actor uuid.UUID, override string) error { org, err := orgOf(ctx) if err != nil { return err } var ids []uuid.UUID own, first := map[uuid.UUID]money.Rate{}, map[uuid.UUID]int{} for _, l := range inv.Lines { if l.TradeLineID == nil { continue } id := *l.TradeLineID if _, ok := own[id]; !ok { ids = append(ids, id) own[id], first[id] = money.MustParseRate("0"), l.LineNo } own[id] = own[id].Add(*l.Quantity) } if len(ids) == 0 { return nil } if _, err := tx.Exec(ctx, `SELECT 1 FROM trade_line WHERE org_id = $1 AND id = ANY($2) ORDER BY id FOR UPDATE`, org, ids); err != nil { return fmt.Errorf("payables: lock matched lines: %w", err) } tol, err := GetMatchTolerance(ctx, tx) if err != nil { return err } var mm []Mismatch for _, id := range ids { left, err := matchable(ctx, tx, org, id, inv.ID) if err != nil { return err } if own[id].Cmp(left) > 0 { mm = append(mm, Mismatch{LineNo: first[id], Rule: MismatchQuantity, Quantity: own[id], Available: left, Amount: money.Zero(), Expected: money.Zero()}) } } for _, l := range inv.Lines { if l.TradeLineID == nil { continue } share, err := grniShare(ctx, tx, org, inv.Kind, l) if err != nil { return err } if !share.Quantity.IsPositive() { continue // nothing received: the quantity rule has refused it } if !withinTolerance(l.NetAmount, share.Amount, *l.Quantity, inv.FXRate, tol) { mm = append(mm, Mismatch{LineNo: l.LineNo, Rule: MismatchPrice, Quantity: *l.Quantity, Available: money.MustParseRate("0"), Amount: l.NetAmount, Expected: share.Amount}) } } if len(mm) == 0 { return nil } reason := strings.TrimSpace(override) if reason == "" { return &MatchError{Mismatches: mm} } if len([]rune(reason)) > 500 || recordtext.Check("match_override_reason", reason) != nil { return fieldErr("match_override_reason", "record_language", "the reason, up to 500 characters, in English or Bahasa Malaysia") } if actor == inv.PreparedBy { return ErrOverrideByPreparer } raw, err := json.Marshal(mm) if err != nil { return err } if _, err := tx.Exec(ctx, ` INSERT INTO purchase_invoice_match_override (org_id, invoice_id, overridden_by, reason, mismatches, created_at) VALUES ($1, $2, $3, $4, $5, $6)`, org, inv.ID, actor, reason, raw, c.Now()); err != nil { return fmt.Errorf("payables: record override: %w", err) } return audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityInvoice, EntityID: inv.ID, Action: "match_override", After: map[string]any{"reason": reason, "mismatches": mm}})}
// matchable is how much of trade line id another AP document may take// (R-O3-4): its base quantity while its receipt or return is posted, less// what the other posted documents on it took. excluding is the document// asking.func matchable(ctx context.Context, tx pgx.Tx, org, id, excluding uuid.UUID) (money.Rate, error) { var q money.Rate err := tx.QueryRow(ctx, ` SELECT CASE WHEN wi.accrual_open THEN l.quantity_base ELSE 0 END - coalesce((SELECT sum(pl.quantity) FROM purchase_invoice_line pl JOIN purchase_invoice pi ON pi.org_id = pl.org_id AND pi.id = pl.invoice_id JOIN workflow_instance w2 ON w2.org_id = pi.org_id AND w2.id = pi.instance_id WHERE pl.org_id = l.org_id AND pl.trade_line_id = l.id AND pl.withdrawn_at IS NULL AND pi.id <> $3 AND w2.accrual_open), 0) FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id JOIN workflow_instance wi ON wi.org_id = d.org_id AND wi.id = d.instance_id WHERE l.org_id = $1 AND l.id = $2`, org, id, excluding).Scan(&q) if err != nil { return money.Rate{}, fmt.Errorf("payables: matchable: %w", err) } return q, nil}
// grniShare is the receipt's value of a matched line's quantity (R-O3-8):// the line's own receipt line for an invoice, the returned receipt line for// a credit note. Amount is in the document currency, Base what GRNI was// credited with in the base currency. Quantity 0: nothing is received.func grniShare(ctx context.Context, tx pgx.Tx, org uuid.UUID, kind string, l Line) (inventory.Received, error) { receipt := *l.TradeLineID if kind == KindCreditNote { var src *uuid.UUID if err := tx.QueryRow(ctx, `SELECT source_line_id FROM trade_line WHERE org_id = $1 AND id = $2`, org, receipt).Scan(&src); err != nil { return inventory.Received{}, fmt.Errorf("payables: returned receipt line: %w", err) } if src == nil { return inventory.Received{}, nil } receipt = *src } rv, err := inventory.ReceivedValue(ctx, tx, receipt) if err != nil || !rv.Quantity.IsPositive() { return inventory.Received{}, err } amount, err := rv.Amount.MulDiv(*l.Quantity, rv.Quantity) if err != nil { return inventory.Received{}, err } base, err := rv.Base.MulDiv(*l.Quantity, rv.Quantity) if err != nil { return inventory.Received{}, err } return inventory.Received{Quantity: *l.Quantity, Amount: amount, Currency: rv.Currency, Base: base}, nil}
// withinTolerance is R-O3-6: the difference is within the percentage of the// receipt's value, or its amount per unit at the document's rate is within// the amount. With both limits 0 only an exact match passes.func withinTolerance(amount, expected money.Amount, qty, rate money.Rate, tol MatchTolerance) bool { diff := amount.Sub(expected) if diff.IsNegative() { diff = diff.Neg() } if diff.IsZero() { return true } if tol.Percent.IsPositive() && diff.Cmp(expected.Percent(tol.Percent)) <= 0 { return true } if tol.Amount.IsPositive() { perUnit, err := diff.MulDiv(rate, qty) if err == nil && perUnit.Cmp(tol.Amount) <= 0 { return true } } return false}
// MatchableLine is a receipt (or return) line with quantity left to invoice// (or credit), for the invoice screen's picker. UnitPrice is per base unit.type MatchableLine struct { TradeLineID uuid.UUID `json:"trade_line_id"` DocumentID uuid.UUID `json:"document_id"` DocumentNumber string `json:"document_number"` LineNo int `json:"line_no"` ItemID *uuid.UUID `json:"item_id"` Description string `json:"description"` Currency string `json:"currency"` Available money.Rate `json:"available"` UnitPrice money.Amount `json:"unit_price"`}
// MatchableLines is the supplier's posted receipt lines (kind invoice) or// purchase return lines (kind credit note) with quantity left, oldest first.func MatchableLines(ctx context.Context, tx pgx.Tx, supplierID uuid.UUID, kind string) ([]MatchableLine, error) { org, err := orgOf(ctx) if err != nil { return nil, err } stock, ok := matchedKind[kind] if !ok { return nil, fieldErr("kind", "kind_known", "a purchase invoice or a supplier credit note") } rows, err := tx.Query(ctx, ` SELECT line_id, document_id, number, line_no, item_id, description, currency, available, unit_price FROM ( SELECT l.id AS line_id, d.id AS document_id, d.number, l.line_no, l.item_id, l.description, d.currency, l.quantity_base - coalesce((SELECT sum(pl.quantity) FROM purchase_invoice_line pl JOIN purchase_invoice pi ON pi.org_id = pl.org_id AND pi.id = pl.invoice_id JOIN workflow_instance w2 ON w2.org_id = pi.org_id AND w2.id = pi.instance_id WHERE pl.org_id = l.org_id AND pl.trade_line_id = l.id AND pl.withdrawn_at IS NULL AND w2.accrual_open), 0) AS available, l.unit_price, d.document_date FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id JOIN workflow_instance wi ON wi.org_id = d.org_id AND wi.id = d.instance_id WHERE l.org_id = $1 AND d.kind = $2 AND d.supplier_party_id = $3 AND l.withdrawn_at IS NULL AND wi.accrual_open) m WHERE available > 0 ORDER BY document_date, number, line_no`, org, stock, supplierID) if err != nil { return nil, fmt.Errorf("payables: matchable lines: %w", err) } defer rows.Close() out := []MatchableLine{} for rows.Next() { var m MatchableLine if err := rows.Scan(&m.TradeLineID, &m.DocumentID, &m.DocumentNumber, &m.LineNo, &m.ItemID, &m.Description, &m.Currency, &m.Available, &m.UnitPrice); err != nil { return nil, err } out = append(out, m) } return out, rows.Err()}- Step 6: Run the tests to verify they pass
Run: go test ./internal/platform/payables/... ./internal/kernel/db/ -count=1
Expected: PASS — every existing AP test included (no line is matched there, so checkMatch returns at once).
- Step 7: Sabotage
In checkMatch delete the if actor == inv.PreparedBy { return ErrOverrideByPreparer } block; run -run TestThePreparerCannot; read the red (the trigger’s purchase_invoice_match_override_not_preparer surfaces instead of the sentinel — the database backstop works, the Go rule was missing); restore. In withinTolerance change <= 0 in the amount branch to < 0; run -run TestAPriceWithin; read the red; restore.
- Step 8: Commit
git add internal/platform/payables internal/platform/trade/chaintestgit commit -m "Phase 4 O3: three-way match at purchase invoice approval; audited Owner override
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 5: GRNI cleared, price variance and realised FX on the invoice; credit notes on returns; invoiced receipts stay
Section titled “Task 5: GRNI cleared, price variance and realised FX on the invoice; credit notes on returns; invoiced receipts stay”Files:
- Modify:
internal/platform/payables/posting.go(AccrualRule.Build) - Modify:
internal/platform/payables/match.go(appendclearGRNI) - Test:
internal/platform/payables/posting_match_test.go
Interfaces:
-
Consumes: Task 4 (
grniShare,ErrNothingToClear),chart.PurchasePriceVariance,chart.ResolveControl,inventory.ControlKindGRNI; Task 1 (TransitionStock’s R-O3-9 guard, tested here). -
Produces: unexported
clearGRNI(ctx, tx, inv Invoice, l Line, charged money.Amount, party uuid.UUID, memo string, side func(ledger.Line, money.Amount, money.Amount, bool) ledger.Line) ([]ledger.Line, error). -
Step 1: Write the failing tests
Create internal/platform/payables/posting_match_test.go:
package payables_test
import ( "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/inventory/stocktest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/chaintest")
// R-O3-8 and R-1: the invoice clears GRNI at the receipt's value; the price// difference goes to purchase price variance, not into stock.func TestAMatchedInvoiceClearsGRNIAndSendsThePriceDifferenceToPPV(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "10")) stocktest.RequireAmount(t, "-30", e.AccountBalance(t, e.GRNI), "PRESENCE: the receipt credited GRNI")
e.SetTolerance(t, "5", "0") inv := e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "10", "31.00"))) lines := stocktest.LinesByAccount(e.EntryFor(t, inv.InstanceID, ledger.SemanticAccrual)) stocktest.RequireAmount(t, "30", lines[e.GRNI].BaseDebit) stocktest.RequireAmount(t, "1", lines[e.PPV].BaseDebit) stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.GRNI)) stocktest.RequireAmount(t, "30", e.AccountBalance(t, e.InventoryAccount), "stock still held keeps the receipt's cost")}
// R-O3-8: the receipt's rate and the invoice's differ; the ledger's// fx_difference line is the realised exchange difference.func TestAForeignInvoicesRateDifferenceIsRealisedFX(t *testing.T) { e := chaintest.New(t) e.SeedRate(t, "USD", "2026-09-21", "4.20") bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "USD", chaintest.ItemLine(bolt, e.Unit, "10", "5.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "10")) stocktest.RequireAmount(t, "210", e.Received(t, grn.Lines[0].ID).Base)
rate := money.MustParseRate("4.3") inv := e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "USD", &rate, chaintest.Matched(grn.Lines[0].ID, "10", "50.00"))) en := e.EntryFor(t, inv.InstanceID, ledger.SemanticAccrual) var fx *ledger.PostedLine for i := range en.Lines { if en.Lines[i].Kind == ledger.KindFXDifference { fx = &en.Lines[i] } } require.NotNil(t, fx, "the rate difference posts as fx_difference") stocktest.RequireAmount(t, "5", fx.BaseDebit, "215 owed against 210 received: a loss of 5") stocktest.RequireAmount(t, "210", stocktest.LinesByAccount(en)[e.GRNI].BaseDebit) stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.GRNI))}
// Spec O3.1: the supplier credit note links to the return line and clears// the GRNI the return debited.func TestACreditNoteOnAPurchaseReturnClearsItsGRNI(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "10")) e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "10", "30.00"))) ret := e.PostStock(t, e.Stock(t, e.Staff, trade.KindPurchaseReturn, grn, chaintest.Take(grn.Lines[0].ID, "4"))) stocktest.RequireAmount(t, "12", e.AccountBalance(t, e.GRNI), "PRESENCE: the return debited GRNI")
over := e.APInvoice(t, payables.KindCreditNote, sup, "MYR", nil, chaintest.Matched(ret.Lines[0].ID, "5", "15.00")) _, err := e.ApproveErr(t, over, e.Owner, "") var me *payables.MatchError require.ErrorAs(t, err, &me, "five credited against four returned")
e.Approve(t, e.APInvoice(t, payables.KindCreditNote, sup, "MYR", nil, chaintest.Matched(ret.Lines[0].ID, "4", "12.00"))) stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.GRNI)) stocktest.RequireAmount(t, "18", e.AP.ControlBalanceByParty(t)[sup], "30 owed less 12 credited (credit − debit)")}
// R-O3-9: a posted invoice keeps its receipt from being cancelled.func TestAnInvoicedReceiptIsNotCancelled(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) loose := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "2")) require.NoError(t, e.CancelStockErr(loose), "PRESENCE: a receipt nobody invoiced cancels")
grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "5")) inv := e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "5", "15.00"))) require.ErrorIs(t, e.CancelStockErr(grn), trade.ErrInvoiced) e.AP.Void(t, inv) require.NoError(t, e.CancelStockErr(grn), "voided, the invoice lets it go")}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/payables/ -run 'TestAMatchedInvoiceClears|TestAForeignInvoices|TestACreditNoteOnAPurchaseReturn|TestAnInvoicedReceipt' -count=1
Expected: FAIL — TestAMatchedInvoiceClears…: want 30, got 31 on the GRNI debit (the line still posts its net to its account); TestAForeign…: no fx_difference line.
- Step 3: Clear GRNI in the accrual rule
Append to internal/platform/payables/match.go (add "github.com/mcom/erp/internal/platform/accounting/ledger" to its imports):
// clearGRNI is a matched line's posting (spec O2.3 row 2, R-1, R-O3-8): GRNI// at the receipt's value of the quantity, frozen at the receipt's base; the// rest of what the line charges (charged: its net, plus SST with no account// of its own) to purchase price variance at the document's rate. side// mirrors a credit note. The base residue between the receipt's rate and the// document's is ledger.Post's fx_difference line: the realised difference.func clearGRNI(ctx context.Context, tx pgx.Tx, inv Invoice, l Line, charged money.Amount, party uuid.UUID, memo string, side func(ledger.Line, money.Amount, money.Amount, bool) ledger.Line) ([]ledger.Line, error) { org, err := orgOf(ctx) if err != nil { return nil, err } share, err := grniShare(ctx, tx, org, inv.Kind, l) if err != nil { return nil, err } if !share.Quantity.IsPositive() || share.Currency != inv.Currency { return nil, fmt.Errorf("%w: line %d", ErrNothingToClear, l.LineNo) } grni, err := chart.ResolveControl(ctx, tx, inventory.ControlKindGRNI) if err != nil { return nil, err } var out []ledger.Line if share.Amount.IsPositive() { out = append(out, side(ledger.Line{AccountID: grni.ID, PartyID: &party, Memo: memo + " GRNI"}, share.Amount, share.Base, true)) } diff := charged.Sub(share.Amount) if diff.IsZero() { return out, nil } ppv, err := chart.PurchasePriceVariance(ctx, tx) if err != nil { return nil, err } amount, debit := diff, true if diff.IsNegative() { amount, debit = diff.Neg(), false } return append(out, side(ledger.Line{AccountID: ppv.ID, PartyID: &party, Memo: memo + " price variance"}, amount, amount.MulRate(inv.FXRate), debit)), nil}In internal/platform/payables/posting.go, in AccrualRule.Build’s line loop, replace
out = append(out, side(ledger.Line{AccountID: l.AccountID, TaxCodeID: l.TaxCodeID, PartyID: &party, Memo: memo, Distribution: l.Shares}, expense, expenseBase, true))with
if l.TradeLineID != nil { // O3: a matched line clears GRNI (R-O3-8) cleared, err := clearGRNI(ctx, tx, inv, l, expense, party, memo, side) if err != nil { return accounting.Draft{}, fmt.Errorf("payables: accrual: %w", err) } out = append(out, cleared...) } else { out = append(out, side(ledger.Line{AccountID: l.AccountID, TaxCodeID: l.TaxCodeID, PartyID: &party, Memo: memo, Distribution: l.Shares}, expense, expenseBase, true)) }- Step 4: Run the tests to verify they pass
Run: go test ./internal/platform/payables/... ./internal/platform/trade/... -count=1
Expected: PASS.
- Step 5: Sabotage
In clearGRNI pass amount.MulRate(inv.FXRate) for the GRNI line’s base instead of share.Base; run -run TestAForeignInvoices; read the red (no fx_difference line, GRNI balance −5); restore. In TransitionStock (Task 1) delete the if n > 0 { return … ErrInvoiced } branch; run -run TestAnInvoicedReceipt; read the red; restore.
- Step 6: Commit
git add internal/platform/payablesgit commit -m "Phase 4 O3: invoices clear GRNI, price variance to P&L, realised FX; credit notes on returns
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 6: Sales invoices and credit notes on deliveries and sales returns
Section titled “Task 6: Sales invoices and credit notes on deliveries and sales returns”Files:
- Create:
internal/platform/receivables/deliverymatch.go - Modify:
internal/platform/receivables/document.go(Line,LineInput,loadLines,checkInput,writeLines) - Modify:
internal/platform/receivables/transition.go(TransitionDocument) - Modify:
internal/platform/trade/chaintest/chaintest.go(append) - Test:
internal/platform/receivables/deliverymatch_test.go
Interfaces:
-
Consumes: O1’s
sales_invoice_line.trade_line_id(00702); Task 1 (Deliver,Stocked,TransitionStock’s R-O3-9 guard);receivablestest.Env(Invoice,Issue,StdLine,CreditNote,Submit,IssueCreditNote). -
Produces:
receivables.LineInput.TradeLineID,receivables.Line.TradeLineID,receivables.DeliveryMatchError{LineNo int; Invoiced, Available money.Rate},receivables.MatchableLine,receivables.MatchableLines(ctx, tx, customerID uuid.UUID, kind string);chaintest.SalesLine,(*chaintest.Env).ARCustomer. -
Step 1: Add the AR helpers to the test world
Append to internal/platform/trade/chaintest/chaintest.go:
// ARCustomer is the receivables view of a customer party.func (e *Env) ARCustomer(t *testing.T, party uuid.UUID) receivables.Customer { t.Helper() var c receivables.Customer e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error c, err = receivables.GetCustomer(ctx, tx, party) return err }) return c}
// SalesLine is l pointed at a delivery (invoice) or sales return (credit// note) line; its quantity is in base units (R-O3-5).func SalesLine(l receivables.LineInput, tradeLine uuid.UUID) receivables.LineInput { l.TradeLineID = &tradeLine return l}- Step 2: Write the failing tests
Create internal/platform/receivables/deliverymatch_test.go:
package receivables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/inventory/stocktest" "github.com/mcom/erp/internal/platform/receivables" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/chaintest")
func moveErr(e *chaintest.Env, d receivables.Document, by string, from, to string) error { actor := e.Staff if by == "owner" { actor = e.Owner } return e.DB.InTx(e.As(actor), func(ctx context.Context, tx pgx.Tx) error { _, _, err := receivables.TransitionDocument(ctx, tx, e.Clock, receivables.TransitionInput{ID: d.ID, From: from, To: to, Actor: actor, Reason: "test"}) return err })}
// Spec O3.2: invoiced ≤ delivered − already invoiced; MyInvois and the// posting are unchanged; an invoiced delivery stays (R-O3-9).func TestASalesInvoiceTakesNoMoreThanWasDelivered(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") e.Stocked(t, bolt, "10", "3.00") cust := e.CustomerParty(t) so := e.SalesOrder(t, cust, chaintest.ItemLine(bolt, e.Unit, "8", "9.00")) do := e.Deliver(t, so, chaintest.Take(so.Lines[0].ID, "5")) c := e.ARCustomer(t, cust)
// PRESENCE: five delivered, five invoiced: issued, posting revenue as before O3. inv := e.AR.Issue(t, e.AR.Invoice(t, c, chaintest.SalesLine(e.AR.StdLine(t, "5", "9.00"), do.Lines[0].ID))) require.Equal(t, do.Lines[0].ID, *inv.Lines[0].TradeLineID) en := e.EntryFor(t, inv.InstanceID, ledger.SemanticAccrual) stocktest.RequireAmount(t, "45", stocktest.LinesByAccount(en)[e.Account(t, "4100")].BaseCredit)
// ABSENCE: one more is refused at issue. more := e.AR.Invoice(t, c, chaintest.SalesLine(e.AR.StdLine(t, "1", "9.00"), do.Lines[0].ID)) var de *receivables.DeliveryMatchError require.ErrorAs(t, moveErr(e, more, "staff", "draft", "issued"), &de) stocktest.RequireQty(t, "0", de.Available)
require.ErrorIs(t, e.CancelStockErr(do), trade.ErrInvoiced)}
// Spec O3.2: the customer credit note links to the return line, and takes// no more than was returned.func TestACustomerCreditNoteTakesNoMoreThanWasReturned(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") e.Stocked(t, bolt, "10", "3.00") cust := e.CustomerParty(t) so := e.SalesOrder(t, cust, chaintest.ItemLine(bolt, e.Unit, "5", "9.00")) do := e.Deliver(t, so, chaintest.Take(so.Lines[0].ID, "5")) c := e.ARCustomer(t, cust) inv := e.AR.Issue(t, e.AR.Invoice(t, c, chaintest.SalesLine(e.AR.StdLine(t, "5", "9.00"), do.Lines[0].ID))) ret := e.PostStock(t, e.Stock(t, e.Staff, trade.KindSalesReturn, do, chaintest.Take(do.Lines[0].ID, "2")))
// A line may point only at its own kind's line. _, err := func() (receivables.Document, error) { var d receivables.Document err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = receivables.CreateDocument(ctx, tx, e.Clock, e.Staff, receivables.DocumentInput{Kind: receivables.KindInvoice, CustomerID: cust, Currency: "MYR", Lines: []receivables.LineInput{chaintest.SalesLine(e.AR.StdLine(t, "1", "9.00"), ret.Lines[0].ID)}}) return err }) return d, err }() var fe *receivables.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "trade_line_kind", fe.Rule)
over := e.AR.Submit(t, e.AR.CreditNote(t, inv, "Goods returned", chaintest.SalesLine(e.AR.StdLine(t, "3", "9.00"), ret.Lines[0].ID))) var de *receivables.DeliveryMatchError require.ErrorAs(t, moveErr(e, over, "owner", "pending_approval", "issued"), &de, "three credited against two returned")
cn := e.AR.IssueCreditNote(t, e.AR.CreditNote(t, inv, "Goods returned", chaintest.SalesLine(e.AR.StdLine(t, "2", "9.00"), ret.Lines[0].ID))) require.Equal(t, "issued", cn.State, "PRESENCE: two against two")}- Step 3: Run the tests to verify they fail
Run: go test ./internal/platform/receivables/ -run 'TestASalesInvoiceTakesNoMore|TestACustomerCreditNoteTakes' -count=1
Expected: FAIL to compile — unknown field TradeLineID in struct literal of type receivables.LineInput.
- Step 4: Carry the link through the sales document
In internal/platform/receivables/document.go:
(a) In type Line struct, after Shares []analytic.Share, add:
// TradeLineID names the delivery line (an invoice) or sales return line // (a credit note) the line bills; Quantity is then in base units (O3 // R-O3-5). nil on an unlinked line. TradeLineID *uuid.UUID(b) In type LineInput struct, after Shares []analytic.Share, add TradeLineID *uuid.UUID.
(c) In loadLines, add , trade_line_id after shares in the SELECT, and , &l.TradeLineID after &raw in the Scan.
(d) In checkInput, immediately before the line net := l.UnitPrice.MulRateCents(l.Quantity).Sub(l.DiscountAmount) // R10, add:
if l.TradeLineID != nil { if err := matchedSalesLine(ctx, tx, in, *l.TradeLineID, f); err != nil { return nil, err } }(e) In writeLines, change the INSERT to
INSERT INTO sales_invoice_line (org_id, invoice_id, line_no, description, classification_code, quantity, unit_code, unit_price, discount_amount, net_amount, account_id, tax_code_id, tax_code, tax_pack_kind, tax_rate, tax_amount, tax_exemption_reason, net_base, tax_base, shares, trade_line_id, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)`, org, id, i+1, l.Description, l.ClassificationCode, l.Quantity, l.UnitCode, l.UnitPrice, l.DiscountAmount, l.net, l.AccountID, l.TaxCodeID, l.code, l.packKind, l.rate, l.tax, l.TaxExemptionReason, l.netBase, l.taxBase, l.shares, l.TradeLineID, now); err != nil {- Step 5: Check the delivery at issue
In internal/platform/receivables/transition.go, in TransitionDocument, replace
if to.Semantic == posting.Accrual { if err := issue(ctx, tx, c, org, &d); err != nil {with
if to.Semantic == posting.Accrual { if err := checkDeliveryMatch(ctx, tx, org, d); err != nil { // O3.2: invoiced ≤ delivered − invoiced return Document{}, workflow.Decision{}, err } if err := issue(ctx, tx, c, org, &d); err != nil {and change the ORDER sentence of its doc comment to … → a credit note's original FOR UPDATE → linked delivery and return lines FOR UPDATE in id order → checks → ….
Create internal/platform/receivables/deliverymatch.go:
// Sales documents on the chain (Phase 4 O3.2): an invoice line may bill a// delivery line and a credit note line a sales return line, never more than// is left of it. The posting and MyInvois are unchanged: cost of sales// posted at delivery (O2), revenue posts here as it always has.package receivables
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money")
// deliveryKind is the stock kind each AR kind may point at (O1 Ruling O1-15).var deliveryKind = map[string]string{KindInvoice: "delivery_order", KindCreditNote: "sales_return"}
// DeliveryMatchError: a document takes more of a delivery (or sales return)// line than is left of it.type DeliveryMatchError struct { LineNo int Invoiced, Available money.Rate}
func (e *DeliveryMatchError) Error() string { return fmt.Sprintf("receivables: line %d bills %s where %s is left to bill", e.LineNo, e.Invoiced, e.Available)}
// matchedSalesLine is the save-time rule: the line names a live delivery// line (an invoice) or sales return line (a credit note) of this customer.// 00702's trigger re-checks both.func matchedSalesLine(ctx context.Context, tx pgx.Tx, in *DocumentInput, id uuid.UUID, f func(string) string) error { org, err := orgOf(ctx) if err != nil { return err } var kind string var customer *uuid.UUID err = tx.QueryRow(ctx, ` SELECT d.kind, d.customer_party_id FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id WHERE l.org_id = $1 AND l.id = $2 AND l.withdrawn_at IS NULL`, org, id).Scan(&kind, &customer) if errors.Is(err, pgx.ErrNoRows) { return fieldErr(f("trade_line_id"), "trade_line", "no such delivery or return line") } if err != nil { return fmt.Errorf("receivables: matched line: %w", err) } switch { case kind != deliveryKind[in.Kind]: return fieldErr(f("trade_line_id"), "trade_line_kind", "an invoice bills a delivery line; a credit note, a sales return line") case customer == nil || *customer != in.CustomerID: return fieldErr(f("trade_line_id"), "trade_line_party", "the line is another customer's") } return nil}
// checkDeliveryMatch locks the delivery and return lines d bills (id order)// and refuses a line that takes more than is left: the line's base// quantity while its document is posted, less other issued documents'.func checkDeliveryMatch(ctx context.Context, tx pgx.Tx, org uuid.UUID, d Document) error { var ids []uuid.UUID own, first := map[uuid.UUID]money.Rate{}, map[uuid.UUID]int{} for _, l := range d.Lines { if l.TradeLineID == nil { continue } id := *l.TradeLineID if _, ok := own[id]; !ok { ids = append(ids, id) own[id], first[id] = money.MustParseRate("0"), l.LineNo } own[id] = own[id].Add(l.Quantity) } if len(ids) == 0 { return nil } if _, err := tx.Exec(ctx, `SELECT 1 FROM trade_line WHERE org_id = $1 AND id = ANY($2) ORDER BY id FOR UPDATE`, org, ids); err != nil { return fmt.Errorf("receivables: lock delivery lines: %w", err) } for _, id := range ids { var left money.Rate if err := tx.QueryRow(ctx, ` SELECT CASE WHEN wi.accrual_open THEN l.quantity_base ELSE 0 END - coalesce((SELECT sum(sl.quantity) FROM sales_invoice_line sl JOIN sales_invoice si ON si.org_id = sl.org_id AND si.id = sl.invoice_id JOIN workflow_instance w2 ON w2.org_id = si.org_id AND w2.id = si.instance_id WHERE sl.org_id = l.org_id AND sl.trade_line_id = l.id AND sl.withdrawn_at IS NULL AND si.id <> $3 AND w2.accrual_open), 0) FROM trade_line l JOIN trade_document td ON td.org_id = l.org_id AND td.id = l.document_id JOIN workflow_instance wi ON wi.org_id = td.org_id AND wi.id = td.instance_id WHERE l.org_id = $1 AND l.id = $2`, org, id, d.ID).Scan(&left); err != nil { return fmt.Errorf("receivables: delivered: %w", err) } if own[id].Cmp(left) > 0 { return &DeliveryMatchError{LineNo: first[id], Invoiced: own[id], Available: left} } } return nil}
// MatchableLine is a delivery (or sales return) line with quantity left to// bill (or credit), for the invoice screen's picker. UnitPrice is per base unit.type MatchableLine struct { TradeLineID uuid.UUID `json:"trade_line_id"` DocumentID uuid.UUID `json:"document_id"` DocumentNumber string `json:"document_number"` LineNo int `json:"line_no"` ItemID *uuid.UUID `json:"item_id"` Description string `json:"description"` Currency string `json:"currency"` Available money.Rate `json:"available"` UnitPrice money.Amount `json:"unit_price"`}
// MatchableLines is the customer's posted delivery lines (kind invoice) or// sales return lines (kind credit note) with quantity left, oldest first.func MatchableLines(ctx context.Context, tx pgx.Tx, customerID uuid.UUID, kind string) ([]MatchableLine, error) { org, err := orgOf(ctx) if err != nil { return nil, err } stock, ok := deliveryKind[kind] if !ok { return nil, fieldErr("kind", "kind_known", "a sales invoice or a customer credit note") } rows, err := tx.Query(ctx, ` SELECT line_id, document_id, number, line_no, item_id, description, currency, available, unit_price FROM ( SELECT l.id AS line_id, d.id AS document_id, d.number, l.line_no, l.item_id, l.description, d.currency, l.quantity_base - coalesce((SELECT sum(sl.quantity) FROM sales_invoice_line sl JOIN sales_invoice si ON si.org_id = sl.org_id AND si.id = sl.invoice_id JOIN workflow_instance w2 ON w2.org_id = si.org_id AND w2.id = si.instance_id WHERE sl.org_id = l.org_id AND sl.trade_line_id = l.id AND sl.withdrawn_at IS NULL AND w2.accrual_open), 0) AS available, l.unit_price, d.document_date FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id JOIN workflow_instance wi ON wi.org_id = d.org_id AND wi.id = d.instance_id WHERE l.org_id = $1 AND d.kind = $2 AND d.customer_party_id = $3 AND l.withdrawn_at IS NULL AND wi.accrual_open) m WHERE available > 0 ORDER BY document_date, number, line_no`, org, stock, customerID) if err != nil { return nil, fmt.Errorf("receivables: matchable lines: %w", err) } defer rows.Close() out := []MatchableLine{} for rows.Next() { var m MatchableLine if err := rows.Scan(&m.TradeLineID, &m.DocumentID, &m.DocumentNumber, &m.LineNo, &m.ItemID, &m.Description, &m.Currency, &m.Available, &m.UnitPrice); err != nil { return nil, err } out = append(out, m) } return out, rows.Err()}- Step 6: Run the tests to verify they pass
Run: go test ./internal/platform/receivables/... ./internal/platform/einvoice/... ./internal/kernel/db/ -count=1
Expected: PASS — every existing AR and e-Invoice test unchanged (no line is linked there).
- Step 7: Sabotage
In checkDeliveryMatch change own[id].Cmp(left) > 0 to own[id].Cmp(left) > 1; run -run TestASalesInvoiceTakesNoMore; read the red (DeliveryMatchError expected, the extra invoice issued); restore.
- Step 8: Commit
git add internal/platform/receivables internal/platform/trade/chaintestgit commit -m "Phase 4 O3: sales invoices bill delivery lines, credit notes sales return lines
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 7: The operational reports and the chain view
Section titled “Task 7: The operational reports and the chain view”Files:
- Create:
internal/platform/trade/report.go,internal/platform/trade/chainview.go - Test:
internal/platform/trade/report_test.go,internal/platform/trade/chainview_test.go
Interfaces:
-
Consumes: O1
trade_semantic,trade_open_quantity,Get,Close; O2receipt_valuation; Tasks 1–6 (chaintestwith AP/AR helpers). -
Produces:
trade.OpenOrderRow,trade.OpenOrders(ctx, tx, kind string, today time.Time),trade.GRNIRow,trade.GRNIAging(ctx, tx, today time.Time),trade.UninvoicedRow,trade.DeliveredNotInvoiced(ctx, tx),trade.ChainNode,trade.ChainInvoice,trade.ChainView,trade.Chain(ctx, tx, id). Row and view types carry snake_case JSON tags (the module serves them as they are); dates areYYYY-MM-DDstrings. -
Step 1: Write the failing tests
Create internal/platform/trade/report_test.go:
package trade_test
import ( "context" "testing" "time"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/inventory/stocktest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/chaintest")
func openOrders(t *testing.T, e *chaintest.Env, kind string, today time.Time) []trade.OpenOrderRow { t.Helper() var rows []trade.OpenOrderRow e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = trade.OpenOrders(ctx, tx, kind, today) return err }) return rows}
// Spec O3.3: open orders, and open sales orders with the late ones flagged// as backorders (R-O3-12).func TestTheOpenOrderReportsShowWhatIsStillToCome(t *testing.T) { e := chaintest.New(t) today := expensestest.Today bolt := e.Item(t, "BOLT", "stock", "none") order := e.Order(t, e.SupplierParty(t), "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "4"))
rows := openOrders(t, e, trade.KindOrder, today) require.Len(t, rows, 1, "PRESENCE: six still to receive") stocktest.RequireQty(t, "6", rows[0].Open) stocktest.RequireAmount(t, "18", rows[0].OpenAmount) require.False(t, rows[0].Late)
cust := e.CustomerParty(t) so := e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindSalesOrder, PartyID: &cust, Currency: "MYR", DueDate: &today, Lines: []trade.LineInput{chaintest.ItemLine(bolt, e.Unit, "3", "9.00")}})) sales := openOrders(t, e, trade.KindSalesOrder, today.AddDate(0, 0, 1)) require.Len(t, sales, 1) require.Equal(t, so.Number, sales[0].Number) require.True(t, sales[0].Late, "past its due date: a backorder")
require.NoError(t, e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { _, err := trade.Close(ctx, tx, e.Clock, order.ID, e.Owner, "Supplier discontinued the item") return err })) require.Empty(t, openOrders(t, e, trade.KindOrder, today), "closed by hand, it leaves the report")}
// R-O3-17: GRNI aging is received − returned − invoiced + credited, at the// receipt's value, aged from the receipt date.func TestGRNIAgingIsWhatWasReceivedAndNotYetInvoiced(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "10")) e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "6", "18.00"))) aging := func() []trade.GRNIRow { var rows []trade.GRNIRow e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = trade.GRNIAging(ctx, tx, expensestest.Today.AddDate(0, 0, 10)) return err }) return rows } rows := aging() require.Len(t, rows, 1, "PRESENCE: four received and not invoiced") stocktest.RequireQty(t, "4", rows[0].Outstanding) stocktest.RequireAmount(t, "12", rows[0].OutstandingBase) require.Equal(t, 10, rows[0].AgeDays)
e.PostStock(t, e.Stock(t, e.Staff, trade.KindPurchaseReturn, grn, chaintest.Take(grn.Lines[0].ID, "1"))) stocktest.RequireQty(t, "3", aging()[0].Outstanding) e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "3", "9.00"))) require.Empty(t, aging()) stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.GRNI), "the report agrees with the ledger")}
// Spec O3.3: delivered not invoiced.func TestDeliveredNotInvoicedIsWhatLeftAndWasNotBilled(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") e.Stocked(t, bolt, "10", "3.00") cust := e.CustomerParty(t) so := e.SalesOrder(t, cust, chaintest.ItemLine(bolt, e.Unit, "8", "9.00")) do := e.Deliver(t, so, chaintest.Take(so.Lines[0].ID, "5")) pending := func() []trade.UninvoicedRow { var rows []trade.UninvoicedRow e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = trade.DeliveredNotInvoiced(ctx, tx) return err }) return rows } rows := pending() require.Len(t, rows, 1, "PRESENCE") stocktest.RequireQty(t, "5", rows[0].Uninvoiced) stocktest.RequireAmount(t, "45", rows[0].UninvoicedAmount) e.AR.Issue(t, e.AR.Invoice(t, e.ARCustomer(t, cust), chaintest.SalesLine(e.AR.StdLine(t, "5", "9.00"), do.Lines[0].ID))) require.Empty(t, pending())}Create internal/platform/trade/chainview_test.go:
package trade_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/chaintest")
// R-O3-16: from any document, its ancestors, its descendants and the// invoices on their lines — and nothing of another chain.func TestTheChainViewWalksBothWaysAndNamesTheInvoices(t *testing.T) { e := chaintest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) order := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "10", "3.00")) grn := e.Receive(t, order, chaintest.Take(order.Lines[0].ID, "5")) ret := e.PostStock(t, e.Stock(t, e.Staff, trade.KindPurchaseReturn, grn, chaintest.Take(grn.Lines[0].ID, "1"))) inv := e.Approve(t, e.APInvoice(t, payables.KindInvoice, sup, "MYR", nil, chaintest.Matched(grn.Lines[0].ID, "5", "15.00"))) other := e.Order(t, sup, "MYR", chaintest.ItemLine(bolt, e.Unit, "1", "3.00"))
chain := func(id uuid.UUID) trade.ChainView { var v trade.ChainView e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error v, err = trade.Chain(ctx, tx, id) return err }) return v } ids := func(v trade.ChainView) []uuid.UUID { var out []uuid.UUID for _, n := range v.Documents { out = append(out, n.ID) } return out } fromOrder := chain(order.ID) require.ElementsMatch(t, []uuid.UUID{order.ID, grn.ID, ret.ID}, ids(fromOrder), "PRESENCE: down the chain") require.Len(t, fromOrder.Invoices, 1) require.Equal(t, inv.ID, fromOrder.Invoices[0].ID) require.ElementsMatch(t, []uuid.UUID{order.ID, grn.ID, ret.ID}, ids(chain(ret.ID)), "up the chain") require.Equal(t, []uuid.UUID{other.ID}, ids(chain(other.ID)), "ABSENCE: another order's chain is its own") require.Empty(t, chain(other.ID).Invoices)}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/trade/ -run 'TestTheOpenOrderReports|TestGRNIAging|TestDeliveredNotInvoiced|TestTheChainView' -count=1
Expected: FAIL to compile — undefined: trade.OpenOrders.
- Step 3: Write
report.go
Create internal/platform/trade/report.go:
package trade
import ( "context" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money")
// reportDay is the wire form of a report's dates.const reportDay = "2006-01-02"
// OpenOrderRow is an order (or sales order) line with quantity still to// receive (deliver). Late: its due date is past — on the sales side, a// backorder (spec O3.3).type OpenOrderRow struct { DocumentID uuid.UUID `json:"document_id"` Number string `json:"number"` PartyID uuid.UUID `json:"party_id"` PartyName string `json:"party_name"` DocumentDate string `json:"document_date"` DueDate *string `json:"due_date"` Late bool `json:"late"` LineNo int `json:"line_no"` ItemID *uuid.UUID `json:"item_id"` Description string `json:"description"` Ordered money.Rate `json:"ordered"` Open money.Rate `json:"open"` Currency string `json:"currency"` OpenAmount money.Amount `json:"open_amount"`}
// OpenOrders is every committed, unclosed order (kind purchase_order) or// sales order (kind sales_order) line with open quantity, earliest due first.func OpenOrders(ctx context.Context, tx pgx.Tx, kind string, today time.Time) ([]OpenOrderRow, error) { if kind != KindOrder && kind != KindSalesOrder { return nil, fieldErr("kind", "order_kind", "purchase_order or sales_order") } org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT d.id, d.number, p.id, p.legal_name, d.document_date, d.due_date, d.currency, l.line_no, l.item_id, l.description, l.quantity_base, trade_open_quantity(l.org_id, l.id), l.net_amount FROM trade_document d JOIN trade_line l ON l.org_id = d.org_id AND l.document_id = d.id JOIN party p ON p.org_id = d.org_id AND p.id = coalesce(d.supplier_party_id, d.customer_party_id) WHERE d.org_id = $1 AND d.kind = $2 AND d.closed_at IS NULL AND l.withdrawn_at IS NULL AND trade_semantic(d.org_id, d.instance_id) = 'committed' AND trade_open_quantity(l.org_id, l.id) > 0 ORDER BY d.due_date NULLS LAST, d.number, l.line_no`, org, kind) if err != nil { return nil, fmt.Errorf("trade: open orders: %w", err) } defer rows.Close() out := []OpenOrderRow{} for rows.Next() { var r OpenOrderRow var date time.Time var due *time.Time var net money.Amount if err := rows.Scan(&r.DocumentID, &r.Number, &r.PartyID, &r.PartyName, &date, &due, &r.Currency, &r.LineNo, &r.ItemID, &r.Description, &r.Ordered, &r.Open, &net); err != nil { return nil, err } r.DocumentDate = date.Format(reportDay) if due != nil { s := due.Format(reportDay) r.DueDate, r.Late = &s, due.Before(today) } if r.OpenAmount, err = net.MulDiv(r.Open, r.Ordered); err != nil { return nil, err } out = append(out, r) } return out, rows.Err()}
// GRNIRow is a posted receipt line not yet fully invoiced (R-O3-17).type GRNIRow struct { ReceiptID uuid.UUID `json:"receipt_id"` Number string `json:"number"` SupplierID uuid.UUID `json:"supplier_id"` SupplierName string `json:"supplier_name"` ReceivedOn string `json:"received_on"` AgeDays int `json:"age_days"` LineNo int `json:"line_no"` ItemID *uuid.UUID `json:"item_id"` Description string `json:"description"` Outstanding money.Rate `json:"outstanding"` Currency string `json:"currency"` OutstandingBase money.Amount `json:"outstanding_base"`}
// GRNIAging is every receipt line's outstanding quantity — received −// returned − invoiced + credited — valued at the receipt's base pro rata,// aged from the receipt date to today, oldest first.func GRNIAging(ctx context.Context, tx pgx.Tx, today time.Time) ([]GRNIRow, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT d.id, d.number, p.id, p.legal_name, d.document_date, l.line_no, l.item_id, l.description, d.currency, rv.q, rv.b, coalesce(ret.q, 0), coalesce(inv.q, 0), coalesce(cred.q, 0) FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id JOIN party p ON p.org_id = d.org_id AND p.id = d.supplier_party_id JOIN LATERAL (SELECT sum(v.quantity) AS q, sum(v.base_amount) AS b FROM receipt_valuation v WHERE v.org_id = l.org_id AND v.trade_line_id = l.id) rv ON true LEFT JOIN LATERAL (SELECT sum(r.quantity_base) AS q FROM trade_line r JOIN trade_document rd ON rd.org_id = r.org_id AND rd.id = r.document_id JOIN workflow_instance rw ON rw.org_id = rd.org_id AND rw.id = rd.instance_id WHERE r.org_id = l.org_id AND r.source_line_id = l.id AND r.withdrawn_at IS NULL AND rd.kind = 'purchase_return' AND rw.accrual_open) ret ON true LEFT JOIN LATERAL (SELECT sum(pl.quantity) AS q FROM purchase_invoice_line pl JOIN purchase_invoice pi ON pi.org_id = pl.org_id AND pi.id = pl.invoice_id JOIN workflow_instance w2 ON w2.org_id = pi.org_id AND w2.id = pi.instance_id WHERE pl.org_id = l.org_id AND pl.trade_line_id = l.id AND pl.withdrawn_at IS NULL AND w2.accrual_open) inv ON true LEFT JOIN LATERAL (SELECT sum(pl.quantity) AS q FROM trade_line r JOIN purchase_invoice_line pl ON pl.org_id = r.org_id AND pl.trade_line_id = r.id AND pl.withdrawn_at IS NULL JOIN purchase_invoice pi ON pi.org_id = pl.org_id AND pi.id = pl.invoice_id JOIN workflow_instance w2 ON w2.org_id = pi.org_id AND w2.id = pi.instance_id WHERE r.org_id = l.org_id AND r.source_line_id = l.id AND w2.accrual_open) cred ON true WHERE l.org_id = $1 AND d.kind = 'goods_receipt' AND l.withdrawn_at IS NULL AND rv.q > 0 ORDER BY d.document_date, d.number, l.line_no`, org) if err != nil { return nil, fmt.Errorf("trade: GRNI aging: %w", err) } defer rows.Close() out := []GRNIRow{} for rows.Next() { var r GRNIRow var date time.Time var received, returned, invoiced, credited money.Rate var base money.Amount if err := rows.Scan(&r.ReceiptID, &r.Number, &r.SupplierID, &r.SupplierName, &date, &r.LineNo, &r.ItemID, &r.Description, &r.Currency, &received, &base, &returned, &invoiced, &credited); err != nil { return nil, err } r.Outstanding = received.Sub(returned).Sub(invoiced).Add(credited) if r.Outstanding.IsZero() { continue } if r.OutstandingBase, err = base.MulDiv(r.Outstanding, received); err != nil { return nil, err } r.ReceivedOn = date.Format(reportDay) r.AgeDays = int(today.Sub(date).Hours() / 24) out = append(out, r) } return out, rows.Err()}
// UninvoicedRow is a posted delivery line with quantity not yet billed.type UninvoicedRow struct { DeliveryID uuid.UUID `json:"delivery_id"` Number string `json:"number"` CustomerID uuid.UUID `json:"customer_id"` CustomerName string `json:"customer_name"` DeliveredOn string `json:"delivered_on"` LineNo int `json:"line_no"` ItemID *uuid.UUID `json:"item_id"` Description string `json:"description"` Delivered money.Rate `json:"delivered"` Invoiced money.Rate `json:"invoiced"` Uninvoiced money.Rate `json:"uninvoiced"` Currency string `json:"currency"` UninvoicedAmount money.Amount `json:"uninvoiced_amount"`}
// DeliveredNotInvoiced is every posted delivery line with quantity left to// bill, valued at the sales order's price, oldest first (spec O3.3).func DeliveredNotInvoiced(ctx context.Context, tx pgx.Tx) ([]UninvoicedRow, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT d.id, d.number, p.id, p.legal_name, d.document_date, l.line_no, l.item_id, l.description, d.currency, l.quantity_base, coalesce(inv.q, 0), l.net_amount FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id JOIN workflow_instance wi ON wi.org_id = d.org_id AND wi.id = d.instance_id JOIN party p ON p.org_id = d.org_id AND p.id = d.customer_party_id LEFT JOIN LATERAL (SELECT sum(sl.quantity) AS q FROM sales_invoice_line sl JOIN sales_invoice si ON si.org_id = sl.org_id AND si.id = sl.invoice_id JOIN workflow_instance w2 ON w2.org_id = si.org_id AND w2.id = si.instance_id WHERE sl.org_id = l.org_id AND sl.trade_line_id = l.id AND sl.withdrawn_at IS NULL AND w2.accrual_open) inv ON true WHERE l.org_id = $1 AND d.kind = 'delivery_order' AND l.withdrawn_at IS NULL AND wi.accrual_open AND l.quantity_base > coalesce(inv.q, 0) ORDER BY d.document_date, d.number, l.line_no`, org) if err != nil { return nil, fmt.Errorf("trade: delivered not invoiced: %w", err) } defer rows.Close() out := []UninvoicedRow{} for rows.Next() { var r UninvoicedRow var date time.Time var net money.Amount if err := rows.Scan(&r.DeliveryID, &r.Number, &r.CustomerID, &r.CustomerName, &date, &r.LineNo, &r.ItemID, &r.Description, &r.Currency, &r.Delivered, &r.Invoiced, &net); err != nil { return nil, err } r.DeliveredOn = date.Format(reportDay) r.Uninvoiced = r.Delivered.Sub(r.Invoiced) if r.UninvoicedAmount, err = net.MulDiv(r.Uninvoiced, r.Delivered); err != nil { return nil, err } out = append(out, r) } return out, rows.Err()}- Step 4: Write
chainview.go
Create internal/platform/trade/chainview.go:
package trade
import ( "context" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/posting")
// ChainNode is one trade document of a chain.type ChainNode struct { ID uuid.UUID `json:"id"` Kind string `json:"kind"` Number string `json:"number"` State string `json:"state"` Semantic posting.Semantic `json:"semantic"` DocumentDate string `json:"document_date"`}
// ChainInvoice is an AP or AR document with a line on the chain.type ChainInvoice struct { ID uuid.UUID `json:"id"` Kind string `json:"kind"` Reference string `json:"reference"` State string `json:"state"`}
type ChainView struct { Documents []ChainNode `json:"documents"` Invoices []ChainInvoice `json:"invoices"`}
// Chain is document id's ancestors and descendants, itself included, in// date order, and the invoices and credit notes on any of their lines// (R-O3-16).func Chain(ctx context.Context, tx pgx.Tx, id uuid.UUID) (ChainView, error) { org, err := orgOf(ctx) if err != nil { return ChainView{}, err } if _, err := Get(ctx, tx, id); err != nil { return ChainView{}, err } rows, err := tx.Query(ctx, ` WITH RECURSIVE up(id) AS ( SELECT $2::uuid UNION SELECT p.document_id FROM up JOIN trade_line c ON c.org_id = $1 AND c.document_id = up.id JOIN trade_line p ON p.org_id = c.org_id AND p.id = c.source_line_id ), down(id) AS ( SELECT $2::uuid UNION SELECT c.document_id FROM down JOIN trade_line p ON p.org_id = $1 AND p.document_id = down.id JOIN trade_line c ON c.org_id = p.org_id AND c.source_line_id = p.id AND c.withdrawn_at IS NULL ) SELECT d.id, d.kind, d.number, wi.state_key, ws.semantic, d.document_date FROM trade_document d JOIN workflow_instance wi ON wi.org_id = d.org_id AND wi.id = d.instance_id JOIN workflow_state ws ON ws.org_id = wi.org_id AND ws.definition_id = wi.definition_id AND ws.state_key = wi.state_key WHERE d.org_id = $1 AND d.id IN (SELECT id FROM up UNION SELECT id FROM down) ORDER BY d.document_date, d.created_at`, org, id) if err != nil { return ChainView{}, fmt.Errorf("trade: chain: %w", err) } out := ChainView{Documents: []ChainNode{}, Invoices: []ChainInvoice{}} var ids []uuid.UUID for rows.Next() { var n ChainNode var date time.Time if err := rows.Scan(&n.ID, &n.Kind, &n.Number, &n.State, &n.Semantic, &date); err != nil { rows.Close() return ChainView{}, err } n.DocumentDate = date.Format(reportDay) out.Documents = append(out.Documents, n) ids = append(ids, n.ID) } rows.Close() if err := rows.Err(); err != nil { return ChainView{}, err } inv, err := tx.Query(ctx, ` SELECT DISTINCT pi.id, pi.kind, pi.reference, wi.state_key FROM purchase_invoice_line pl JOIN trade_line l ON l.org_id = pl.org_id AND l.id = pl.trade_line_id JOIN purchase_invoice pi ON pi.org_id = pl.org_id AND pi.id = pl.invoice_id JOIN workflow_instance wi ON wi.org_id = pi.org_id AND wi.id = pi.instance_id WHERE pl.org_id = $1 AND pl.withdrawn_at IS NULL AND l.document_id = ANY($2) UNION SELECT DISTINCT si.id, si.kind, coalesce(si.reference, ''), wi.state_key FROM sales_invoice_line sl JOIN trade_line l ON l.org_id = sl.org_id AND l.id = sl.trade_line_id JOIN sales_invoice si ON si.org_id = sl.org_id AND si.id = sl.invoice_id JOIN workflow_instance wi ON wi.org_id = si.org_id AND wi.id = si.instance_id WHERE sl.org_id = $1 AND sl.withdrawn_at IS NULL AND l.document_id = ANY($2) ORDER BY 3`, org, ids) if err != nil { return ChainView{}, fmt.Errorf("trade: chain invoices: %w", err) } defer inv.Close() for inv.Next() { var i ChainInvoice if err := inv.Scan(&i.ID, &i.Kind, &i.Reference, &i.State); err != nil { return ChainView{}, err } out.Invoices = append(out.Invoices, i) } return out, inv.Err()}- Step 5: Run the tests to verify they pass
Run: go test ./internal/platform/trade/... ./internal/kernel/db/ -count=1
Expected: PASS.
- Step 6: Sabotage
In GRNIAging change .Sub(returned) to .Add(returned); run go test ./internal/platform/trade/ -run TestGRNIAging -count=1; read the red (want 3, got 5); restore. In Chain, replace the up CTE’s recursive branch (the SELECT p.document_id … after UNION) with SELECT NULL::uuid WHERE false; run -run TestTheChainView; read the red (the return’s chain lacks the order and the receipt); restore.
- Step 7: Commit
git add internal/platform/trade/report.go internal/platform/trade/chainview.go \ internal/platform/trade/report_test.go internal/platform/trade/chainview_test.gogit commit -m "Phase 4 O3: open orders, GRNI aging, delivered not invoiced, the chain view
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 8: Purchase orders and quotations print (two C4 layout kinds)
Section titled “Task 8: Purchase orders and quotations print (two C4 layout kinds)”Files:
- Create:
db/migrations/00743_trade_layouts.sql - Create:
internal/platform/printing/trade.go - Modify:
internal/platform/printing/layout.go(kinds, specs),render.go(labels),sample.go(samples) - Modify:
internal/platform/printing/printing_test.go - Modify:
api/openapi.yaml(the three layout-kind enums), regenerate
Interfaces:
-
Consumes:
printing.Layout,Settings,columns,begin,label,title,day,trimRate,joinNonEmpty,render(C4). -
Produces:
printing.KindPurchaseOrder = "purchase_order",printing.KindQuotation = "quotation",printing.TradeLine,printing.TradeDocument,printing.TradeFields(v TradeDocument, l Layout) [][2]string,printing.RenderTradeDocument(v TradeDocument, s Settings) ([]byte, error);document_layout_kind_knownaccepts both kinds. -
Step 1: Write the failing tests
In internal/platform/printing/printing_test.go, in TestALayoutIsChecked, change printing.Check("purchase_order", …) to printing.Check("delivery_order", …) (purchase_order is a kind now). Then append:
// O3 Ruling R-O3-15: an order and a quotation print both parties, our tax// identities, the currency and the total under any layout, and take only// their own optional lines.func TestAnOrderAndAQuotationKeepTheirStatutoryLines(t *testing.T) { v := printing.TradeDocument{Kind: printing.KindPurchaseOrder, Number: "PO-2026-0001", FromName: "Kami Sdn Bhd", FromTIN: "C2088", FromSST: "W10-1", ToName: "Pembekal Sdn Bhd", Currency: "MYR"} joined := func(l printing.Layout) string { var b strings.Builder for _, f := range printing.TradeFields(v, l) { b.WriteString(f[0] + "=" + f[1] + ";") } return b.String() } for _, want := range []string{"PO-2026-0001", "Kami Sdn Bhd", "C2088", "W10-1", "Pembekal Sdn Bhd", "MYR"} { require.Contains(t, joined(printing.Layout{Language: printing.LanguageMalay}), want) } var le *printing.LayoutError _, err := printing.Check(printing.KindQuotation, printing.Layout{Language: "en", Header: []string{"valid_until", "party_tin"}, Columns: []string{"unit", "unit_price"}}) require.NoError(t, err, "PRESENCE: a quotation's own lines") _, err = printing.Check(printing.KindPurchaseOrder, printing.Layout{Language: "en", Header: []string{"valid_until"}}) require.ErrorAs(t, err, &le, "a quotation's line on an order") _, err = printing.RenderTradeDocument(printing.TradeDocument{Kind: printing.KindSalesDocument}, printing.Settings{Layout: printing.Default(printing.KindSalesDocument)}) require.ErrorIs(t, err, printing.ErrUnknownKind)}
// 00743: the organization saves its order and quotation layouts.func TestTheOrderAndQuotationLayoutsAreSaved(t *testing.T) { e := expensestest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { for _, k := range []string{printing.KindPurchaseOrder, printing.KindQuotation} { st, err := printing.Save(ctx, tx, e.Clock, e.Owner, k, 0, printing.Default(k)) if err != nil { return err } require.Equal(t, 1, st.Revision, k) } return nil })}Add "context" to the imports if the file lacks it.
- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/printing/ -count=1
Expected: FAIL to compile — undefined: printing.TradeDocument.
- Step 3: Declare the kinds and write the renderer
In internal/platform/printing/layout.go, add to the kinds const block:
KindPurchaseOrder = "purchase_order" // Phase 4 O3.1 KindQuotation = "quotation" // Phase 4 O3.2change Kinds to
var Kinds = []string{KindSalesDocument, KindCustomerStatement, KindPaymentAdvice, KindClaimVoucher, KindPurchaseOrder, KindQuotation}and add to specs:
KindPurchaseOrder: {Kind: KindPurchaseOrder, OptionalHeader: []string{"delivery_date", "party_tin"}, OptionalColumns: []string{"quantity", "unit", "unit_price", "tax_code"}}, KindQuotation: {Kind: KindQuotation, OptionalHeader: []string{"valid_until", "party_tin"}, OptionalColumns: []string{"quantity", "unit", "unit_price", "tax_code"}},In internal/platform/printing/render.go, add to labels (before "page"):
"purchase_order": {"Purchase order", "Pesanan belian"}, "quotation": {"Quotation", "Sebut harga"}, "delivery_date": {"Delivery date", "Tarikh penghantaran"}, "valid_until": {"Valid until", "Sah sehingga"}, "unit": {"Unit", "Unit"},Create internal/platform/printing/trade.go:
package printing
import ( "time"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/reporting/render")
// TradeLine is one purchase order or quotation line as printed.type TradeLine struct { No int Description string Quantity money.Rate Unit string UnitPrice, Amount money.Amount TaxCode string}
// TradeDocument is a purchase order (Kind KindPurchaseOrder) or a quotation// (KindQuotation) as printed (O3 R-O3-15). From is this organization; To is// the supplier or customer. The total excludes tax: tax is computed on the// invoice.type TradeDocument struct { Kind string Number string Date time.Time Due *time.Time FromName, FromAddress, FromTIN, FromBRN string FromSST, ToName, ToAddress, ToTIN, Currency string Lines []TradeLine NetTotal money.Amount}
// TradeFields are the header lines an order or quotation prints under every// layout: its number and date, both parties, our tax identities, the currency.func TradeFields(v TradeDocument, l Layout) [][2]string { return [][2]string{{label(l, "number"), v.Number}, {label(l, "date"), day(v.Date)}, {label(l, "from"), joinNonEmpty(" · ", v.FromName, v.FromAddress)}, {label(l, "from") + " TIN / BRN / SST", joinNonEmpty(" / ", v.FromTIN, v.FromBRN, v.FromSST)}, {label(l, "to"), joinNonEmpty(" · ", v.ToName, v.ToAddress)}, {label(l, "currency"), v.Currency}}}
// RenderTradeDocument draws an order or quotation under s's layout.func RenderTradeDocument(v TradeDocument, s Settings) ([]byte, error) { if v.Kind != KindPurchaseOrder && v.Kind != KindQuotation { return nil, ErrUnknownKind } l := s.Layout w, _, _, err := render.New(render.FormatPDF) if err != nil { return nil, err } f := TradeFields(v, l) for _, h := range l.Header { switch { case (h == "delivery_date" || h == "valid_until") && v.Due != nil: f = append(f, [2]string{label(l, h), day(*v.Due)}) case h == "party_tin" && v.ToTIN != "": f = append(f, [2]string{label(l, "to") + " TIN", v.ToTIN}) } } cols := columns(l, []column{{"description", false, 20}}, map[string]column{ "quantity": {"quantity", false, 4}, "unit": {"unit", false, 3}, "unit_price": {"unit_price", true, 7}, "tax_code": {"tax_code", false, 4}, }, []column{{"amount", true, 8}}) ttl := title(l, v.Kind) if err := begin(w, l, s, ttl, f, ttl+" "+v.Number, "", cols); err != nil { return nil, err } for _, line := range v.Lines { cells := make([]render.Cell, 0, len(cols)) for _, c := range cols { switch c.key { case "description": cells = append(cells, render.Text(line.Description)) case "quantity": cells = append(cells, render.Text(trimRate(line.Quantity))) case "unit": cells = append(cells, render.Text(line.Unit)) case "unit_price": cells = append(cells, render.Money(line.UnitPrice)) case "tax_code": cells = append(cells, render.Text(line.TaxCode)) case "amount": cells = append(cells, render.Money(line.Amount)) } } if err := w.Row(cells); err != nil { return nil, err } } total := make([]render.Cell, len(cols)) total[0] = render.Bold(render.Text(label(l, "total_excluding_tax"))) total[len(cols)-1] = render.Bold(render.Money(v.NetTotal)) if err := w.Row(total); err != nil { return nil, err } return w.Finish()}In internal/platform/printing/sample.go, add before the final return nil, ErrUnknownKind:
if kind == KindPurchaseOrder || kind == KindQuotation { number, to := "PO-2026-0001", "Pembekal Sdn Bhd" if kind == KindQuotation { number, to = "QT-2026-0001", "Pelanggan Sdn Bhd" } due := d("2026-10-15") return RenderTradeDocument(TradeDocument{Kind: kind, Number: number, Date: d("2026-10-01"), Due: &due, FromName: "Syarikat Contoh Sdn Bhd", FromAddress: "Lot 5, Jalan Niaga, 40000 Shah Alam, Selangor", FromTIN: "C20880050010", FromBRN: "202001012345", FromSST: "W10-1234-56789012", ToName: to, ToAddress: "No. 3, Jalan Industri, 47500 Subang Jaya, Selangor", ToTIN: "C99887766554", Currency: "MYR", Lines: []TradeLine{ {No: 1, Description: "Copier paper A4, 80gsm", Quantity: money.MustParseRate("20"), Unit: "BX", UnitPrice: m("24.00"), Amount: m("480.00"), TaxCode: "SV8"}, {No: 2, Description: "Toner cartridge, black", Quantity: money.MustParseRate("2"), Unit: "C62", UnitPrice: m("310.00"), Amount: m("620.00"), TaxCode: "SV8"}, }, NetTotal: m("1100.00")}, s) }- Step 4: Let the database keep the two layouts
Create db/migrations/00743_trade_layouts.sql:
-- +goose Up-- Phase 4 O3 (R-O3-15): purchase orders and quotations have layouts.ALTER TABLE document_layout DROP CONSTRAINT document_layout_kind_known, ADD CONSTRAINT document_layout_kind_known CHECK (kind IN ( 'sales_document', 'customer_statement', 'payment_advice', 'claim_voucher', 'purchase_order', 'quotation'));
-- +goose Down-- Refused while an order or quotation layout is saved: the CHECK fails on it.ALTER TABLE document_layout DROP CONSTRAINT document_layout_kind_known, ADD CONSTRAINT document_layout_kind_known CHECK (kind IN ( 'sales_document', 'customer_statement', 'payment_advice', 'claim_voucher'));In api/openapi.yaml, change the three layout-kind enums (the two kind path parameters of /org/layouts/{kind}… and DocumentLayoutView.kind, today enum: [sales_document, customer_statement, payment_advice, claim_voucher]) to enum: [sales_document, customer_statement, payment_advice, claim_voucher, purchase_order, quotation].
- Step 5: Run the tests to verify they pass
Run: make generate && go test ./internal/platform/printing/ ./internal/modules/identity/ -count=1
Expected: PASS — TestEveryKindRendersItsSample renders both new kinds in all three label languages.
- Step 6: Sabotage
In TradeFields drop the "TIN / BRN / SST" line; run -run TestAnOrderAndAQuotation; read the red; restore.
- Step 7: Commit
git add db/migrations/00743_trade_layouts.sql internal/platform/printing api/openapi.yaml internal/gen web/src/lib/api/schema.d.tsgit commit -m "Phase 4 O3: purchase order and quotation layouts and PDFs
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 9: The procurement and sales HTTP surface, permissions, grants and wiring
Section titled “Task 9: The procurement and sales HTTP surface, permissions, grants and wiring”Files:
- Create:
internal/modules/trade/http.go(copied from payables),permissions.go,routes.go,handlers.go,report_handlers.go,errors.go - Test:
internal/modules/trade/routes_test.go,env_test.go,handlers_test.go,grants_test.go - Create:
db/migrations/00742_trade_grants.sql,internal/platform/trade/migration00742_test.go - Modify:
constants.yaml(permissions), regenerate vocab - Modify:
api/openapi.yaml(paths, responses, parameters, headers, schemas), regenerate - Modify:
internal/app/app.go,internal/app/routes_test.go,cmd/api/main.go,cmd/orgadmin/main.go,cmd/orgadmin/wiring_test.go,cmd/migrate/main.go,cmd/migrate/wiring_test.go
Interfaces:
-
Consumes: Tasks 1–8 (
trade.CreateStock,TransitionStock,CreateCommitment,UpdateCommitment,Chain,OpenOrders,GRNIAging,DeliveredNotInvoiced,printing.RenderTradeDocument); O1trade.Get,List,Transition,Draft,Close,DecodeCursor;workflow.AvailableTransitions,workflow.Decisions;payables.GetSupplier,receivables.GetCustomer,company.Get,inventory.GetUOM,chart.GetTaxCode. -
Produces: package
internal/modules/trade—Deps{DB *db.DB; Clock clock.Clock},Register(procurement, sales *gin.RouterGroup, deps Deps), the eighteenPermission*constants,Permissions []string; the routes of the table in Step 1; refusal codesnot_draft,pair_not_allowed,source_not_approved,source_closed,party_mismatch,item_mismatch,over_open_quantity,source_not_found,nothing_open,has_children,not_closable,already_closed,invoiced_document,quotation_expired,not_stock_kind,not_commitment_kind,receiver_approved_order,not_preparer,revision_mismatch, and O2’s stock codes (insufficient_stock,serial_held,layer_consumed,value_exhausted,already_applied,item_account_missing,nothing_received,serial_required,batch_required,untracked_lot,lots_mismatch,batch_expiry_mismatch,line_needs_item). -
Step 1: Write the failing route and grant tests
Create internal/modules/trade/routes_test.go:
package trade_test
import ( "testing"
"github.com/gin-gonic/gin" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/modules/trade")
// Every route and its exact permission (R-O3-13). A write route// re-registered under a read key would hand every reader the power to act.func TestEveryRouteRequiresItsExactPermission(t *testing.T) { gin.SetMode(gin.TestMode) httpx.ResetAuthorizedRoutes() _, api := httpx.NewRouter(httpx.Deps{}) trade.Register(api, api, trade.Deps{}) want := map[string]string{} family := func(path, read, create, transition string, stock bool) { want["GET /v1"+path] = read want["POST /v1"+path] = create want["GET /v1"+path+"/:id"] = read if !stock { want["PUT /v1"+path+"/:id"] = create } want["POST /v1"+path+"/:id/transitions"] = transition } family("/procurement/requisitions", trade.PermissionRequisitionRead, trade.PermissionRequisitionCreate, trade.PermissionRequisitionTransition, false) family("/procurement/orders", trade.PermissionOrderRead, trade.PermissionOrderCreate, trade.PermissionOrderTransition, false) family("/procurement/receipts", trade.PermissionReceiptRead, trade.PermissionReceiptCreate, trade.PermissionReceiptTransition, true) family("/procurement/returns", trade.PermissionReceiptRead, trade.PermissionReceiptCreate, trade.PermissionReceiptTransition, true) family("/sales/quotations", trade.PermissionQuotationRead, trade.PermissionQuotationCreate, trade.PermissionQuotationTransition, false) family("/sales/orders", trade.PermissionSalesOrderRead, trade.PermissionSalesOrderCreate, trade.PermissionSalesOrderTransition, false) family("/sales/deliveries", trade.PermissionDeliveryRead, trade.PermissionDeliveryCreate, trade.PermissionDeliveryTransition, true) family("/sales/returns", trade.PermissionDeliveryRead, trade.PermissionDeliveryCreate, trade.PermissionDeliveryTransition, true) for k, v := range map[string]string{ "GET /v1/procurement/orders/draft": trade.PermissionOrderCreate, "POST /v1/procurement/orders/:id/close": trade.PermissionOrderTransition, "GET /v1/procurement/orders/:id/pdf": trade.PermissionOrderRead, "GET /v1/procurement/documents/:id/chain": trade.PermissionOrderRead, "GET /v1/procurement/reports/open-orders": trade.PermissionOrderRead, "GET /v1/procurement/reports/grni-aging": trade.PermissionReceiptRead, "GET /v1/sales/orders/draft": trade.PermissionSalesOrderCreate, "POST /v1/sales/orders/:id/close": trade.PermissionSalesOrderTransition, "GET /v1/sales/quotations/:id/pdf": trade.PermissionQuotationRead, "GET /v1/sales/documents/:id/chain": trade.PermissionSalesOrderRead, "GET /v1/sales/reports/open-orders": trade.PermissionSalesOrderRead, "GET /v1/sales/reports/delivered-not-invoiced": trade.PermissionDeliveryRead, } { want[k] = v } require.Len(t, want, 48, "the table itself: 4×5 + 4×4 + 12") require.Equal(t, want, httpx.AuthorizedRoutes())}Create internal/modules/trade/grants_test.go:
package trade_test
import ( "context" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/trade" "github.com/mcom/erp/internal/platform/expenses/expensestest")
// R-O3-13: a new organization's Finance holds every O3 key, its Employee// the requisition keys (the Owner half is proven against 00742 in// platform/trade, because expensestest overrides the Owner's set).func TestANewOrganizationsRolesHoldTheirTradeKeys(t *testing.T) { e := expensestest.New(t) keysOf := func(role string) map[string]bool { rows, err := e.H.Admin.Query(context.Background(), ` SELECT rp.permission_key FROM role_permission rp JOIN role r ON r.org_id = rp.org_id AND r.id = rp.role_id WHERE r.org_id = $1 AND r.system AND r.name = $2`, e.Org, role) require.NoError(t, err) defer rows.Close() out := map[string]bool{} for rows.Next() { var k string require.NoError(t, rows.Scan(&k)) out[k] = true } return out } finance, employee := keysOf("Finance"), keysOf("Employee") require.NotEmpty(t, finance, "PRESENCE: the Finance role exists and holds keys") for _, k := range trade.Permissions { require.True(t, finance[k], k) } require.True(t, employee[trade.PermissionRequisitionCreate]) require.True(t, employee["inventory.item.read"], "a requester picks items and units") require.False(t, employee[trade.PermissionOrderCreate], "an employee requests; Finance orders")}Create internal/platform/trade/migration00742_test.go:
package trade_test
import ( "context" "database/sql" "testing"
"github.com/google/uuid" _ "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
func TestMigration00742GrantsTheTradeKeysToExistingRoles(t *testing.T) { h := testdb.StartAt(t, 741) ctx := context.Background() org := uuid.New() _, err := h.Admin.Exec(ctx, `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err) role := func(name string) uuid.UUID { var id uuid.UUID require.NoError(t, h.Admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1,$2,true) RETURNING id`, org, name).Scan(&id)) return id } owner, finance, employee, approver, admin := role("Owner"), role("Finance"), role("Employee"), role("Approver"), role("Admin") keys := func(id uuid.UUID) map[string]bool { rows, err := h.Admin.Query(ctx, `SELECT permission_key FROM role_permission WHERE role_id = $1`, id) require.NoError(t, err) defer rows.Close() out := map[string]bool{} for rows.Next() { var k string require.NoError(t, rows.Scan(&k)) out[k] = true } return out } require.Empty(t, keys(owner), "ABSENCE before: nothing granted yet")
conn, err := sql.Open("pgx", h.AdminDSN) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) require.NoError(t, goose.SetDialect("postgres")) require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 742, goose.WithAllowMissing()))
require.Len(t, keys(owner), 19, "eighteen trade keys and the match override") require.True(t, keys(owner)["payables.invoice.match_override"]) require.Len(t, keys(finance), 18) require.False(t, keys(finance)["payables.invoice.match_override"], "the override is the Owner's") require.Equal(t, map[string]bool{"procurement.requisition.read": true, "procurement.requisition.create": true, "procurement.requisition.transition": true, "inventory.item.read": true}, keys(employee)) require.Equal(t, map[string]bool{"procurement.requisition.read": true, "procurement.requisition.transition": true}, keys(approver), "the requester's superior approves") require.Empty(t, keys(admin), "no other role is touched")
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t))) require.Empty(t, keys(owner), "the Down removes them") require.Empty(t, keys(employee)) require.Empty(t, keys(approver))}- Step 2: Run them to verify they fail
Run: go test ./internal/modules/trade/ ./internal/platform/trade/ -run 'TestEveryRouteRequires|TestANewOrganizationsRoles|TestMigration00742' -count=1
Expected: FAIL to compile — package internal/modules/trade does not exist.
- Step 3: The module’s request plumbing and permissions
Run:
mkdir -p internal/modules/tradesed -e 's/^package payables$/package trade/' internal/modules/payables/http.go > internal/modules/trade/http.goThen in internal/modules/trade/http.go replace the package comment (every line above package trade) with:
// Package trade is the HTTP surface of procurement and sales (Phase 4 O3):// requisitions, purchase orders, goods receipts and purchase returns under// /procurement, quotations, sales orders, deliveries and sales returns under// /sales — each group behind its own module-install switch (R-O3-14).// internal/platform/trade owns every rule and writes every audit row; this// package parses requests, calls it inside one transaction and maps its// refusals onto HTTP statuses.//// Errors are RECORDED, never written: c.Error(...) and return. Every request// runs inside a transaction, reads included. The request plumbing below is a// copy of internal/modules/payables's (a module does not import another).Create internal/modules/trade/permissions.go:
package trade
import ( "github.com/mcom/erp/internal/platform/identity/member" "github.com/mcom/erp/internal/platform/provisioning")
// The eighteen keys of spec O3.3 (R-O3-13). Each family splits read /// create / transition the way payables does: create prepares or edits your// own draft, transition uses the workflow endpoint, and the authority to// decide still comes from the workflow's grants. The first segment is the// module-install key, so switching procurement or sales off removes its keys.const ( PermissionRequisitionRead = "procurement.requisition.read" PermissionRequisitionCreate = "procurement.requisition.create" PermissionRequisitionTransition = "procurement.requisition.transition" PermissionOrderRead = "procurement.order.read" PermissionOrderCreate = "procurement.order.create" PermissionOrderTransition = "procurement.order.transition" PermissionReceiptRead = "procurement.receipt.read" PermissionReceiptCreate = "procurement.receipt.create" PermissionReceiptTransition = "procurement.receipt.transition" PermissionQuotationRead = "sales.quotation.read" PermissionQuotationCreate = "sales.quotation.create" PermissionQuotationTransition = "sales.quotation.transition" PermissionSalesOrderRead = "sales.order.read" PermissionSalesOrderCreate = "sales.order.create" PermissionSalesOrderTransition = "sales.order.transition" PermissionDeliveryRead = "sales.delivery.read" PermissionDeliveryCreate = "sales.delivery.create" PermissionDeliveryTransition = "sales.delivery.transition")
var permissionText = []struct{ key, text string }{ {PermissionRequisitionRead, "Read purchase requisitions"}, {PermissionRequisitionCreate, "Raise purchase requisitions and edit their drafts"}, {PermissionRequisitionTransition, "Use the requisition transition endpoint; authority to approve still comes from the workflow's grants"}, {PermissionOrderRead, "Read purchase orders, their chain and the open orders report, and print orders"}, {PermissionOrderCreate, "Prepare purchase orders and edit their drafts"}, {PermissionOrderTransition, "Use the order transition endpoint and close an order by hand; authority still comes from the workflow's grants"}, {PermissionReceiptRead, "Read goods receipts, purchase returns and GRNI aging"}, {PermissionReceiptCreate, "Prepare goods receipts and purchase returns"}, {PermissionReceiptTransition, "Post and cancel goods receipts and purchase returns; the order's approver never posts its receipt"}, {PermissionQuotationRead, "Read and print quotations"}, {PermissionQuotationCreate, "Prepare quotations and edit their drafts"}, {PermissionQuotationTransition, "Issue and cancel quotations"}, {PermissionSalesOrderRead, "Read sales orders, their chain and the open sales orders report"}, {PermissionSalesOrderCreate, "Prepare sales orders and edit their drafts"}, {PermissionSalesOrderTransition, "Use the sales order transition endpoint and close one by hand; authority still comes from the workflow's grants"}, {PermissionDeliveryRead, "Read deliveries, sales returns and the delivered-not-invoiced report"}, {PermissionDeliveryCreate, "Prepare deliveries and sales returns"}, {PermissionDeliveryTransition, "Post and cancel deliveries and sales returns"},}
// Permissions is every key this module registers, in the order above.var Permissions = func() []string { out := make([]string, len(permissionText)) for i, p := range permissionText { out[i] = p.key } return out}()
func init() { for _, p := range permissionText { member.RegisterPermission(p.key, p.text) } // R-O3-13: Finance runs procurement and sales; anyone may request. provisioning.RegisterRolePermissions("Finance", Permissions...) // inventory.item.read (O1's key) lets a requester pick items and units. provisioning.RegisterRolePermissions("Employee", PermissionRequisitionRead, PermissionRequisitionCreate, PermissionRequisitionTransition, "inventory.item.read") // O1-12: the requester's superior approves; they read and move requisitions. provisioning.RegisterRolePermissions("Approver", PermissionRequisitionRead, PermissionRequisitionTransition)}- Step 4: Routes
Create internal/modules/trade/routes.go:
package trade
import ( "context" "fmt"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/db" apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/platform/identity/member" "github.com/mcom/erp/internal/platform/printing" pt "github.com/mcom/erp/internal/platform/trade")
type Deps struct { DB *db.DB Clock clock.Clock}
// family is one document kind's routes and keys.type family struct { kind, path string read, create, transition string}
// stock: created from its source, never edited (R-O3-1).func (f family) stock() bool { switch f.kind { case pt.KindReceipt, pt.KindPurchaseReturn, pt.KindDelivery, pt.KindSalesReturn: return true } return false}
var ( requisitions = family{pt.KindRequisition, "/procurement/requisitions", PermissionRequisitionRead, PermissionRequisitionCreate, PermissionRequisitionTransition} orders = family{pt.KindOrder, "/procurement/orders", PermissionOrderRead, PermissionOrderCreate, PermissionOrderTransition} receipts = family{pt.KindReceipt, "/procurement/receipts", PermissionReceiptRead, PermissionReceiptCreate, PermissionReceiptTransition} pReturns = family{pt.KindPurchaseReturn, "/procurement/returns", PermissionReceiptRead, PermissionReceiptCreate, PermissionReceiptTransition} quotations = family{pt.KindQuotation, "/sales/quotations", PermissionQuotationRead, PermissionQuotationCreate, PermissionQuotationTransition} salesOrders = family{pt.KindSalesOrder, "/sales/orders", PermissionSalesOrderRead, PermissionSalesOrderCreate, PermissionSalesOrderTransition} deliveries = family{pt.KindDelivery, "/sales/deliveries", PermissionDeliveryRead, PermissionDeliveryCreate, PermissionDeliveryTransition} sReturns = family{pt.KindSalesReturn, "/sales/returns", PermissionDeliveryRead, PermissionDeliveryCreate, PermissionDeliveryTransition})
// Register hangs the procurement routes on procurement and the sales routes// on sales: app.Router hands each the group behind its install switch. Every// route is behind httpx.Protected; routes_test.go pins each one's key.func Register(procurement, sales *gin.RouterGroup, deps Deps) { h := handlers{deps: deps} pl := permissionLoader{db: deps.DB} for _, g := range []struct { g *gin.RouterGroup fs []family }{{procurement, []family{requisitions, orders, receipts, pReturns}}, {sales, []family{quotations, salesOrders, deliveries, sReturns}}} { for _, f := range g.fs { httpx.Protected(g.g, pl, "GET", f.path, f.read, h.list(f)) httpx.Protected(g.g, pl, "POST", f.path, f.create, h.create(f)) httpx.Protected(g.g, pl, "GET", f.path+"/:id", f.read, h.get(f)) if !f.stock() { httpx.Protected(g.g, pl, "PUT", f.path+"/:id", f.create, h.update(f)) } httpx.Protected(g.g, pl, "POST", f.path+"/:id/transitions", f.transition, h.transition(f)) } } httpx.Protected(procurement, pl, "GET", "/procurement/orders/draft", PermissionOrderCreate, h.draft(pt.KindOrder)) httpx.Protected(procurement, pl, "POST", "/procurement/orders/:id/close", PermissionOrderTransition, h.close(orders)) httpx.Protected(procurement, pl, "GET", "/procurement/orders/:id/pdf", PermissionOrderRead, h.pdf(orders, printing.KindPurchaseOrder)) httpx.Protected(procurement, pl, "GET", "/procurement/documents/:id/chain", PermissionOrderRead, h.chain(true)) httpx.Protected(procurement, pl, "GET", "/procurement/reports/open-orders", PermissionOrderRead, h.openOrders(pt.KindOrder)) httpx.Protected(procurement, pl, "GET", "/procurement/reports/grni-aging", PermissionReceiptRead, h.grniAging) httpx.Protected(sales, pl, "GET", "/sales/orders/draft", PermissionSalesOrderCreate, h.draft(pt.KindSalesOrder)) httpx.Protected(sales, pl, "POST", "/sales/orders/:id/close", PermissionSalesOrderTransition, h.close(salesOrders)) httpx.Protected(sales, pl, "GET", "/sales/quotations/:id/pdf", PermissionQuotationRead, h.pdf(quotations, printing.KindQuotation)) httpx.Protected(sales, pl, "GET", "/sales/documents/:id/chain", PermissionSalesOrderRead, h.chain(false)) httpx.Protected(sales, pl, "GET", "/sales/reports/open-orders", PermissionSalesOrderRead, h.openOrders(pt.KindSalesOrder)) httpx.Protected(sales, pl, "GET", "/sales/reports/delivered-not-invoiced", PermissionDeliveryRead, h.deliveredNotInvoiced)}
type handlers struct{ deps Deps }
// permissionLoader adapts member.Permissions to httpx.PermissionLoader// (payables's, copied: a module does not import another).type permissionLoader struct{ db *db.DB }
var _ httpx.PermissionLoader = permissionLoader{}
func (p permissionLoader) Permissions(ctx context.Context, memberID uuid.UUID) (map[string]struct{}, error) { if p.db == nil { return nil, apperr.New(apperr.KindInternal, "authorization is not configured") } var out map[string]struct{} err := p.db.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error out, err = member.Permissions(ctx, tx, memberID) return err }) if err != nil { return nil, fmt.Errorf("trade: load permissions: %w", err) } return out, nil}- Step 5: Document handlers
Create internal/modules/trade/handlers.go:
package trade
import ( "context" "net/http" "strconv" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/analytic" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/posting" pt "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/workflow")
type shareDTO struct { AnalyticAccountID uuid.UUID `json:"analytic_account_id"` Percentage money.Rate `json:"percentage"`}
type lineDTO struct { ID uuid.UUID `json:"id"` LineNo int `json:"line_no"` ItemID *uuid.UUID `json:"item_id"` Description string `json:"description"` Quantity money.Rate `json:"quantity"` UOMID uuid.UUID `json:"uom_id"` UOMFactor money.Rate `json:"uom_factor"` QuantityBase money.Rate `json:"quantity_base"` UnitPrice money.Amount `json:"unit_price"` NetAmount money.Amount `json:"net_amount"` TaxCodeID *uuid.UUID `json:"tax_code_id"` Shares []shareDTO `json:"shares"` SourceLineID *uuid.UUID `json:"source_line_id"` OpenQuantity money.Rate `json:"open_quantity"`}
type documentDTO struct { ID uuid.UUID `json:"id"` Kind string `json:"kind"` Number string `json:"number"` PartyID *uuid.UUID `json:"party_id"` Currency string `json:"currency"` FXRate money.Rate `json:"fx_rate"` DocumentDate string `json:"document_date"` DueDate *string `json:"due_date"` LocationID *uuid.UUID `json:"location_id"` NetTotal money.Amount `json:"net_total"` NetBase money.Amount `json:"net_base"` ClosedAt *time.Time `json:"closed_at"` CloseReason *string `json:"close_reason"` Fulfilled bool `json:"fulfilled"` Closed bool `json:"closed"` Expired bool `json:"expired"` InstanceID uuid.UUID `json:"instance_id"` PreparedBy uuid.UUID `json:"prepared_by"` Revision int `json:"revision"` CreatedAt time.Time `json:"created_at"` State string `json:"state"` Semantic posting.Semantic `json:"semantic"` Lines []lineDTO `json:"lines"`}
// decisionDTO is the shape web/src/components/workflow/DecisionTimeline reads.type decisionDTO struct { From string `json:"from"` To string `json:"to"` ActionKey string `json:"action_key"` ActorMemberID uuid.UUID `json:"actor_member_id"` Reason string `json:"reason"` DecidedAt time.Time `json:"decided_at"`}
type viewDTO struct { Document documentDTO `json:"document"` AvailableTransitions []workflow.Available `json:"available_transitions"` Decisions []decisionDTO `json:"decisions"`}
func sharesOf(ss []analytic.Share) []shareDTO { out := make([]shareDTO, 0, len(ss)) for _, s := range ss { out = append(out, shareDTO{s.AnalyticAccountID, s.Percentage}) } return out}
func toDTO(d pt.Document, today time.Time) documentDTO { out := documentDTO{ID: d.ID, Kind: d.Kind, Number: d.Number, PartyID: d.PartyID, Currency: d.Currency, FXRate: d.FXRate, DocumentDate: d.DocumentDate.Format(dateLayout), LocationID: d.LocationID, NetTotal: d.NetTotal, NetBase: d.NetBase, ClosedAt: d.ClosedAt, CloseReason: d.CloseReason, Fulfilled: d.Fulfilled, Closed: d.Closed(), Expired: d.Expired(today), InstanceID: d.InstanceID, PreparedBy: d.PreparedBy, Revision: d.Revision, CreatedAt: d.CreatedAt.UTC(), State: d.State, Semantic: d.Semantic, Lines: make([]lineDTO, 0, len(d.Lines))} if d.DueDate != nil { s := d.DueDate.Format(dateLayout) out.DueDate = &s } for _, l := range d.Lines { out.Lines = append(out.Lines, lineDTO{ID: l.ID, LineNo: l.LineNo, ItemID: l.ItemID, Description: l.Description, Quantity: l.Quantity, UOMID: l.UOMID, UOMFactor: l.UOMFactor, QuantityBase: l.QuantityBase, UnitPrice: l.UnitPrice, NetAmount: l.NetAmount, TaxCodeID: l.TaxCodeID, Shares: sharesOf(l.Shares), SourceLineID: l.SourceLineID, OpenQuantity: l.OpenQuantity}) } return out}
func (h handlers) view(ctx context.Context, tx pgx.Tx, d pt.Document, who uuid.UUID) (viewDTO, error) { out := viewDTO{Document: toDTO(d, clock.Today(ctx, h.deps.Clock)), Decisions: []decisionDTO{}} var err error if out.AvailableTransitions, err = workflow.AvailableTransitions(ctx, tx, h.deps.Clock, d.InstanceID, who); err != nil { return out, err } ds, err := workflow.Decisions(ctx, tx, d.InstanceID) for _, x := range ds { out.Decisions = append(out.Decisions, decisionDTO{From: x.From, To: x.To, ActionKey: x.ActionKey, ActorMemberID: x.Actor, Reason: x.Reason, DecidedAt: x.DecidedAt.UTC()}) } return out, err}
// run is every document route's body: fn in one transaction, the view of// what it returns, and a posting refusal at commit mapped (ledger.MapError).func (h handlers) run(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pt.Document, error)) { who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return } var out viewDTO err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { d, err := fn(ctx, tx, who) if err != nil { return err } out, err = h.view(ctx, tx, d, who) return err }) if err = ledger.MapError(err); err != nil { _ = c.Error(mapErr(err)) return } setETag(c, out.Document.Revision) c.JSON(status, out)}
// ofFamily reads a document; one of another kind is not found through this// family's routes.func ofFamily(ctx context.Context, tx pgx.Tx, f family, id uuid.UUID) (pt.Document, error) { d, err := pt.Get(ctx, tx, id) if err == nil && d.Kind != f.kind { return pt.Document{}, pt.ErrNotFound } return d, err}
func (h handlers) list(f family) gin.HandlerFunc { return func(c *gin.Context) { flt := pt.Filter{Kind: f.kind, State: c.Query("state"), Open: c.Query("open") == "true", Limit: 50} if s := c.Query("party_id"); s != "" { id, err := uuid.Parse(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=party_id rule=uuid: party_id must be a UUID")) return } flt.PartyID = &id } if s := c.Query("limit"); s != "" { n, err := strconv.Atoi(s) if err != nil || n < 1 || n > 200 { _ = c.Error(apperr.New(apperr.KindValidation, "field=limit rule=range: limit is 1 to 200")) return } flt.Limit = n } if s := c.Query("cursor"); s != "" { cur, err := pt.DecodeCursor(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=cursor rule=cursor: not a cursor this API issued")) return } flt.After = &cur } out := struct { Documents []documentDTO `json:"documents"` NextCursor *string `json:"next_cursor"` }{Documents: []documentDTO{}} err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { ds, next, err := pt.List(ctx, tx, flt) if err != nil { return err } today := clock.Today(ctx, h.deps.Clock) for _, d := range ds { out.Documents = append(out.Documents, toDTO(d, today)) } if next != nil { s := next.Encode() out.NextCursor = &s } return nil }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, out) }}
func (h handlers) get(f family) gin.HandlerFunc { return func(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } h.run(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (pt.Document, error) { return ofFamily(ctx, tx, f, id) }) }}
type lineBody struct { ItemID *uuid.UUID `json:"item_id"` Description string `json:"description"` Quantity money.Rate `json:"quantity"` UOMID uuid.UUID `json:"uom_id"` UnitPrice money.Amount `json:"unit_price"` TaxCodeID *uuid.UUID `json:"tax_code_id"` Shares []shareDTO `json:"shares"` SourceLineID *uuid.UUID `json:"source_line_id"`}
// documentBody is a commitment document's input, and the draft route's answer.type documentBody struct { PartyID *uuid.UUID `json:"party_id"` Currency string `json:"currency"` FXRate *money.Rate `json:"fx_rate"` DueDate *string `json:"due_date"` Lines []lineBody `json:"lines"`}
func (b documentBody) input(kind string) (pt.DocumentInput, error) { in := pt.DocumentInput{Kind: kind, PartyID: b.PartyID, Currency: b.Currency, FXRate: b.FXRate} if b.DueDate != nil { d, err := parseDate(*b.DueDate) if err != nil { return pt.DocumentInput{}, apperr.New(apperr.KindValidation, "field=due_date rule=date: a date YYYY-MM-DD") } in.DueDate = &d } for _, l := range b.Lines { li := pt.LineInput{ItemID: l.ItemID, Description: l.Description, Quantity: l.Quantity, UOMID: l.UOMID, UnitPrice: l.UnitPrice, TaxCodeID: l.TaxCodeID, SourceLineID: l.SourceLineID} for _, s := range l.Shares { li.Shares = append(li.Shares, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } in.Lines = append(in.Lines, li) } return in, nil}
func inputDTO(in pt.DocumentInput) documentBody { out := documentBody{PartyID: in.PartyID, Currency: in.Currency, FXRate: in.FXRate, Lines: []lineBody{}} for _, l := range in.Lines { out.Lines = append(out.Lines, lineBody{ItemID: l.ItemID, Description: l.Description, Quantity: l.Quantity, UOMID: l.UOMID, UnitPrice: l.UnitPrice, TaxCodeID: l.TaxCodeID, Shares: sharesOf(l.Shares), SourceLineID: l.SourceLineID}) } return out}
type lotBody struct { SerialNo string `json:"serial_no"` BatchNo string `json:"batch_no"` ExpiresOn *string `json:"expires_on"` Quantity money.Rate `json:"quantity"`}
type stockLineBody struct { SourceLineID uuid.UUID `json:"source_line_id"` Quantity money.Rate `json:"quantity"` Lots []lotBody `json:"lots"`}
type stockBody struct { SourceDocumentID uuid.UUID `json:"source_document_id"` LocationID uuid.UUID `json:"location_id"` Lines []stockLineBody `json:"lines"`}
func (b stockBody) input(kind string) (pt.StockInput, error) { in := pt.StockInput{Kind: kind, SourceDocumentID: b.SourceDocumentID, LocationID: b.LocationID} for _, l := range b.Lines { li := pt.StockLineInput{SourceLineID: l.SourceLineID, Quantity: l.Quantity} for _, lot := range l.Lots { x := inventory.Lot{SerialNo: lot.SerialNo, BatchNo: lot.BatchNo, Quantity: lot.Quantity} if lot.ExpiresOn != nil { d, err := parseDate(*lot.ExpiresOn) if err != nil { return pt.StockInput{}, apperr.New(apperr.KindValidation, "field=expires_on rule=date: a date YYYY-MM-DD") } x.ExpiresOn = &d } li.Lots = append(li.Lots, x) } in.Lines = append(in.Lines, li) } return in, nil}
func (h handlers) create(f family) gin.HandlerFunc { return func(c *gin.Context) { if f.stock() { var body stockBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } in, err := body.input(f.kind) if err != nil { _ = c.Error(err) return } h.run(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pt.Document, error) { return pt.CreateStock(ctx, tx, h.deps.Clock, who, in) }) return } var body documentBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } in, err := body.input(f.kind) if err != nil { _ = c.Error(err) return } h.run(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pt.Document, error) { return pt.CreateCommitment(ctx, tx, h.deps.Clock, who, in) }) }}
func (h handlers) update(f family) gin.HandlerFunc { return func(c *gin.Context) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) var body documentBody if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } in, err := body.input(f.kind) if err != nil { _ = c.Error(err) return } h.run(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pt.Document, error) { if _, err := ofFamily(ctx, tx, f, id); err != nil { return pt.Document{}, err } return pt.UpdateCommitment(ctx, tx, h.deps.Clock, id, who, rev, in) }) }}
type transitionBody struct { From string `json:"from"` To string `json:"to"` Reason string `json:"reason"`}
func (h handlers) transition(f family) gin.HandlerFunc { return func(c *gin.Context) { id, err := parseID(c, "id") var body transitionBody if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.run(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pt.Document, error) { if _, err := ofFamily(ctx, tx, f, id); err != nil { return pt.Document{}, err } in := pt.TransitionInput{ID: id, From: body.From, To: body.To, Actor: who, Reason: body.Reason} move := pt.Transition if f.stock() { move = pt.TransitionStock } d, _, err := move(ctx, tx, h.deps.Clock, in) return d, err }) }}
type closeBody struct { Reason string `json:"reason"`}
func (h handlers) close(f family) gin.HandlerFunc { return func(c *gin.Context) { id, err := parseID(c, "id") var body closeBody if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.run(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pt.Document, error) { if _, err := ofFamily(ctx, tx, f, id); err != nil { return pt.Document{}, err } return pt.Close(ctx, tx, h.deps.Clock, id, who, body.Reason) }) }}
// draft is the copy-forward (O1's Draft): the lines of ?from= at their open// quantity, for the screen to start the target document from.func (h handlers) draft(target string) gin.HandlerFunc { return func(c *gin.Context) { from, err := uuid.Parse(c.Query("from")) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=from rule=uuid: from is the source document's id")) return } var out documentBody err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { in, err := pt.Draft(ctx, tx, from, target) out = inputDTO(in) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, out) }}- Step 6: Reports, the chain and the PDFs
Create internal/modules/trade/report_handlers.go:
package trade
import ( "context" "fmt" "net/http" "strings"
"github.com/gin-gonic/gin" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/printing" "github.com/mcom/erp/internal/platform/receivables" pt "github.com/mcom/erp/internal/platform/trade")
// procurementKinds are the kinds the procurement routes show.var procurementKinds = map[string]bool{pt.KindRequisition: true, pt.KindOrder: true, pt.KindReceipt: true, pt.KindPurchaseReturn: true}
// chain serves Chain for a document of its own side (R-O3-16).func (h handlers) chain(procurement bool) gin.HandlerFunc { return func(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var out pt.ChainView err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { d, err := pt.Get(ctx, tx, id) if err != nil { return err } if procurementKinds[d.Kind] != procurement { return pt.ErrNotFound } out, err = pt.Chain(ctx, tx, id) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, out) }}
// report runs one report read inside a transaction and answers {"rows": …}.func report[T any](h handlers, c *gin.Context, fn func(ctx context.Context, tx pgx.Tx) ([]T, error)) { var rows []T err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = fn(ctx, tx) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"rows": rows})}
func (h handlers) openOrders(kind string) gin.HandlerFunc { return func(c *gin.Context) { report(h, c, func(ctx context.Context, tx pgx.Tx) ([]pt.OpenOrderRow, error) { return pt.OpenOrders(ctx, tx, kind, clock.Today(ctx, h.deps.Clock)) }) }}
func (h handlers) grniAging(c *gin.Context) { report(h, c, func(ctx context.Context, tx pgx.Tx) ([]pt.GRNIRow, error) { return pt.GRNIAging(ctx, tx, clock.Today(ctx, h.deps.Clock)) })}
func (h handlers) deliveredNotInvoiced(c *gin.Context) { report(h, c, pt.DeliveredNotInvoiced)}
// pdf prints an order or a quotation under the organization's layout of// its kind (R-O3-15).func (h handlers) pdf(f family, layout string) gin.HandlerFunc { return func(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var body []byte var number string err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { d, err := ofFamily(ctx, tx, f, id) if err != nil { return err } number = d.Number v, err := printable(ctx, tx, d, layout) if err != nil { return err } set, err := printing.SettingsFor(ctx, tx, layout) if err != nil { return err } body, err = printing.RenderTradeDocument(v, set) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.pdf"`, number)) c.Data(http.StatusOK, "application/pdf", body) }}
func printable(ctx context.Context, tx pgx.Tx, d pt.Document, layout string) (printing.TradeDocument, error) { p, err := company.Get(ctx, tx) if err != nil { return printing.TradeDocument{}, err } v := printing.TradeDocument{Kind: layout, Number: d.Number, Date: d.DocumentDate, Due: d.DueDate, FromName: p.LegalName, FromAddress: p.RegisteredAddress, FromTIN: p.TIN, FromBRN: p.RegistrationNo, FromSST: p.SSTRegistrationNo, Currency: d.Currency, NetTotal: d.NetTotal} join := func(parts ...string) string { var kept []string for _, s := range parts { if s = strings.TrimSpace(s); s != "" { kept = append(kept, s) } } return strings.Join(kept, ", ") } deref := func(s *string) string { if s == nil { return "" } return *s } if d.PartyID != nil { if d.Kind == pt.KindOrder { s, err := payables.GetSupplier(ctx, tx, *d.PartyID) if err != nil { return printing.TradeDocument{}, err } a := s.Address v.ToName, v.ToAddress, v.ToTIN = s.LegalName, join(a.Line1, a.Line2, a.Postcode+" "+a.City, a.State), deref(s.TIN) } else { cu, err := receivables.GetCustomer(ctx, tx, *d.PartyID) if err != nil { return printing.TradeDocument{}, err } a := cu.Address v.ToName, v.ToAddress, v.ToTIN = cu.LegalName, join(a.Line1, a.Line2, a.Postcode+" "+a.City, a.State), deref(cu.TIN) } } for _, l := range d.Lines { u, err := inventory.GetUOM(ctx, tx, l.UOMID) if err != nil { return printing.TradeDocument{}, err } code := "" if l.TaxCodeID != nil { tc, err := chart.GetTaxCode(ctx, tx, *l.TaxCodeID) if err != nil { return printing.TradeDocument{}, err } code = tc.Code } v.Lines = append(v.Lines, printing.TradeLine{No: l.LineNo, Description: l.Description, Quantity: l.Quantity, Unit: u.Key, UnitPrice: l.UnitPrice, Amount: l.NetAmount, TaxCode: code}) } return v, nil}- Step 7: Refusals
Create internal/modules/trade/errors.go:
package trade
import ( "errors" "fmt" "strings"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/inventory" pt "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/workflow")
// coded are the sentinels with a stable code (web/src/lib/i18n common:refusals).var coded = []struct { target error kind apperr.Kind code string}{ {pt.ErrNotDraft, apperr.KindConflict, "not_draft"}, {pt.ErrPairNotAllowed, apperr.KindValidation, "pair_not_allowed"}, {pt.ErrNotApproved, apperr.KindConflict, "source_not_approved"}, {pt.ErrSourceClosed, apperr.KindConflict, "source_closed"}, {pt.ErrPartyMismatch, apperr.KindValidation, "party_mismatch"}, {pt.ErrItemMismatch, apperr.KindValidation, "item_mismatch"}, {pt.ErrOverOpenQuantity, apperr.KindConflict, "over_open_quantity"}, {pt.ErrSourceNotFound, apperr.KindValidation, "source_not_found"}, {pt.ErrNothingOpen, apperr.KindConflict, "nothing_open"}, {pt.ErrHasChildren, apperr.KindConflict, "has_children"}, {inventory.ErrHasChildren, apperr.KindConflict, "has_children"}, {pt.ErrNotClosable, apperr.KindConflict, "not_closable"}, {pt.ErrAlreadyClosed, apperr.KindConflict, "already_closed"}, {pt.ErrInvoiced, apperr.KindConflict, "invoiced_document"}, {pt.ErrQuotationExpired, apperr.KindConflict, "quotation_expired"}, {pt.ErrNotStockKind, apperr.KindValidation, "not_stock_kind"}, {pt.ErrNotCommitmentKind, apperr.KindValidation, "not_commitment_kind"}, {pt.ErrReceiverApprovedOrder, apperr.KindForbidden, "receiver_approved_order"}, {pt.ErrNotPreparer, apperr.KindForbidden, "not_preparer"}, {inventory.ErrInsufficientStock, apperr.KindConflict, "insufficient_stock"}, {inventory.ErrSerialHeld, apperr.KindConflict, "serial_held"}, {inventory.ErrLayerConsumed, apperr.KindConflict, "layer_consumed"}, {inventory.ErrValueExhausted, apperr.KindConflict, "value_exhausted"}, {inventory.ErrAlreadyApplied, apperr.KindConflict, "already_applied"}, {inventory.ErrItemAccountMissing, apperr.KindConflict, "item_account_missing"}, {inventory.ErrNothingReceived, apperr.KindConflict, "nothing_received"}, {inventory.ErrSerialRequired, apperr.KindValidation, "serial_required"}, {inventory.ErrBatchRequired, apperr.KindValidation, "batch_required"}, {inventory.ErrUntrackedLot, apperr.KindValidation, "untracked_lot"}, {inventory.ErrLotsMismatch, apperr.KindValidation, "lots_mismatch"}, {inventory.ErrBatchExpiryMismatch, apperr.KindValidation, "batch_expiry_mismatch"}, {inventory.ErrLineNeedsItem, apperr.KindValidation, "line_needs_item"},}
// mapErr turns platform refusals into RFC 7807 problems. 409: the same// request would succeed against the records in another state; 422: wrong// whatever the data holds.func mapErr(err error) error { var ( field *pt.FieldError revision *pt.RevisionMismatchError reason *workflow.ReasonError refused *workflow.RefusedError unroutable *workflow.SubmissionRefusedError period *fiscal.PeriodNotOpenError control *chart.NoControlAccountError noRule *accounting.NoPostingRuleError ) switch { case errors.As(err, &field): return apperr.New(apperr.KindValidation, field.Error()).With("field", field.Field) case errors.As(err, &revision): return apperr.New(apperr.KindConflict, fmt.Sprintf("the record is at revision %d; reload and retry", revision.Current)). With("code", "revision_mismatch") case errors.Is(err, pt.ErrNotFound), errors.Is(err, inventory.ErrDocumentNotFound): return apperr.New(apperr.KindNotFound, "no such record in this organization") case errors.Is(err, workflow.ErrStaleState): return apperr.New(apperr.KindConflict, "the document is no longer in the state you sent as from; reload it and retry") case errors.Is(err, workflow.ErrNoSuchEdge): return apperr.New(apperr.KindValidation, "to names no transition out of the current state") case errors.As(err, &reason): return apperr.New(apperr.KindValidation, err.Error()).With("field", "reason") case errors.Is(err, workflow.ErrSoleOwnerReasonRequired): return apperr.New(apperr.KindValidation, "field=reason rule=required: as the organization's only member you may do this yourself, with a reason"). With("code", "sole_owner_reason_required") case errors.Is(err, workflow.ErrReasonRequired): return apperr.New(apperr.KindValidation, "reason is required for this transition") case errors.As(err, &refused): return apperr.New(apperr.KindForbidden, "refused: "+refused.Refusal.Code+": "+refused.Refusal.Detail). With("code", refused.Refusal.Code) case errors.As(err, &unroutable): return apperr.New(apperr.KindValidation, err.Error()) case errors.As(err, &period): return apperr.New(apperr.KindConflict, fmt.Sprintf("the fiscal period this would post into (%s) is %s, not open", period.PeriodID, period.Status)) case errors.As(err, &control): return apperr.New(apperr.KindConflict, "accounting is not set up: no active account for control kind "+control.Kind) case errors.As(err, &noRule): return apperr.New(apperr.KindInternal, "this document cannot be posted; contact the operator") } for _, m := range coded { if errors.Is(err, m.target) { detail := strings.TrimPrefix(strings.TrimPrefix(err.Error(), "trade: "), "inventory: ") return apperr.New(m.kind, detail).With("code", m.code) } } return err}- Step 8: The grants migration
Create db/migrations/00742_trade_grants.sql:
-- +goose Up-- Phase 4 O3.3 (R-O3-13): a new organization's roles get O3's keys from-- provisioning; an existing organization's get them here (00713's pattern).-- Owner: all eighteen and the three-way match override (R-O3-7); Finance:-- the eighteen; Employee: the requisition keys and inventory.item.read (to pick items-- and units). Runs as the migration role,-- which bypasses RLS.INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, k FROM role r, unnest(ARRAY[ 'procurement.requisition.read', 'procurement.requisition.create', 'procurement.requisition.transition', 'procurement.order.read', 'procurement.order.create', 'procurement.order.transition', 'procurement.receipt.read', 'procurement.receipt.create', 'procurement.receipt.transition', 'sales.quotation.read', 'sales.quotation.create', 'sales.quotation.transition', 'sales.order.read', 'sales.order.create', 'sales.order.transition', 'sales.delivery.read', 'sales.delivery.create', 'sales.delivery.transition']) AS k WHERE r.system AND r.name IN ('Owner', 'Finance')ON CONFLICT (role_id, permission_key) DO NOTHING;
INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, 'payables.invoice.match_override' FROM role r WHERE r.system AND r.name = 'Owner'ON CONFLICT (role_id, permission_key) DO NOTHING;
INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, k FROM role r, unnest(ARRAY['procurement.requisition.read', 'procurement.requisition.create', 'procurement.requisition.transition', 'inventory.item.read']) AS k WHERE r.system AND r.name = 'Employee'ON CONFLICT (role_id, permission_key) DO NOTHING;
INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, k FROM role r, unnest(ARRAY['procurement.requisition.read', 'procurement.requisition.transition']) AS k WHERE r.system AND r.name = 'Approver'ON CONFLICT (role_id, permission_key) DO NOTHING;
-- member.BumpPermVersion's contract (see 00170).UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner', 'Finance', 'Employee', 'Approver'));
-- +goose DownDELETE FROM role_permission rp USING role r WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.system AND r.name IN ('Owner', 'Finance', 'Employee', 'Approver') AND (rp.permission_key LIKE 'procurement.%' OR rp.permission_key LIKE 'sales.%' OR rp.permission_key = 'payables.invoice.match_override' OR (r.name = 'Employee' AND rp.permission_key = 'inventory.item.read'));UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner', 'Finance', 'Employee', 'Approver'));In constants.yaml, append to permissions after the last inventory. key, one per line, the eighteen keys of permissions.go in its order ( - procurement.requisition.read … - sales.delivery.transition). Run go run ./tools/vocabgen.
- Step 9: Wire the module into the application and the binaries
In internal/app/app.go: import "github.com/mcom/erp/internal/modules/trade", add Trade trade.Deps to Deps after the O1 Inventory field, and after the O1 inventory.Register(...) line:
trade.Register(optional("procurement"), optional("sales"), deps.Trade)In cmd/api/main.go, import the module and add to the app.Deps{...} literal after Inventory::
Trade: trade.Deps{ DB: database, Clock: clock.System(), },In internal/app/routes_test.go, import "github.com/mcom/erp/internal/modules/trade" and add to composedModules:
"trade": trade.Permissions,In cmd/orgadmin/main.go and cmd/migrate/main.go, add _ "github.com/mcom/erp/internal/modules/trade" to the blank-import block (alphabetical, after taxreturns). In cmd/orgadmin/wiring_test.go and cmd/migrate/wiring_test.go, append "github.com/mcom/erp/internal/modules/trade", to wantModules and to wantPermissions:
"procurement.order.create", // modules/trade "sales.delivery.transition", // modules/trade- Step 10: Declare the routes in the contract
In api/openapi.yaml, insert immediately before the line components::
# --- Phase 4 O3: procurement and sales (R-O3-13, R-O3-14). --- /procurement/requisitions: get: operationId: listPurchaseRequisitions tags: [Procurement] summary: List purchase requisitions description: Newest first. open=true keeps approved, unclosed ones. Requires procurement.requisition.read. parameters: [{ $ref: '#/components/parameters/TradeState' }, { $ref: '#/components/parameters/TradeParty' }, { $ref: '#/components/parameters/TradeOpen' }, { $ref: '#/components/parameters/TradeCursor' }, { $ref: '#/components/parameters/TradeLimit' }] responses: { '200': { $ref: '#/components/responses/TradeList' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '422': { $ref: '#/components/responses/TradeInvalid' } } post: operationId: createPurchaseRequisition tags: [Procurement] summary: Raise a purchase requisition description: A draft, approved by the requester's superior (O1-12). Requires procurement.requisition.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } } responses: { '201': { $ref: '#/components/responses/TradeViewCreated' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/requisitions/{id}: get: operationId: getPurchaseRequisition tags: [Procurement] summary: One purchase requisition description: Requires procurement.requisition.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { $ref: '#/components/responses/TradeView' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '422': { $ref: '#/components/responses/TradeInvalid' } } put: operationId: updatePurchaseRequisition tags: [Procurement] summary: Edit your draft purchase requisition description: If-Match is required. Requires procurement.requisition.create. parameters: [{ $ref: '#/components/parameters/TradeID' }, { $ref: '#/components/parameters/TradeIfMatch' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/requisitions/{id}/transitions: post: operationId: transitionPurchaseRequisition tags: [Procurement] summary: Move a purchase requisition along its workflow description: Requires procurement.requisition.transition; authority to decide comes from the workflow's grants. parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } }Then the seven other families, in full:
/procurement/orders: get: operationId: listPurchaseOrders tags: [Procurement] summary: List purchase orders description: Newest first. open=true keeps approved or posted, unclosed ones. Requires procurement.order.read. parameters: [{ $ref: '#/components/parameters/TradeState' }, { $ref: '#/components/parameters/TradeParty' }, { $ref: '#/components/parameters/TradeOpen' }, { $ref: '#/components/parameters/TradeCursor' }, { $ref: '#/components/parameters/TradeLimit' }] responses: { '200': { $ref: '#/components/responses/TradeList' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '422': { $ref: '#/components/responses/TradeInvalid' } } post: operationId: createPurchaseOrder tags: [Procurement] summary: Create a purchase order description: A draft, direct or with lines drawn from an approved requisition (source_line_id), approved by the Owner seat (O1-12). Requires procurement.order.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } } responses: { '201': { $ref: '#/components/responses/TradeViewCreated' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/orders/{id}: get: operationId: getPurchaseOrder tags: [Procurement] summary: One purchase order description: Requires procurement.order.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { $ref: '#/components/responses/TradeView' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '422': { $ref: '#/components/responses/TradeInvalid' } } put: operationId: updatePurchaseOrder tags: [Procurement] summary: Edit your draft purchase order description: If-Match is required. Requires procurement.order.create. parameters: [{ $ref: '#/components/parameters/TradeID' }, { $ref: '#/components/parameters/TradeIfMatch' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/orders/{id}/transitions: post: operationId: transitionPurchaseOrder tags: [Procurement] summary: Move a purchase order along its workflow description: Requires procurement.order.transition; authority to decide comes from the workflow's grants. parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/receipts: get: operationId: listGoodsReceipts tags: [Procurement] summary: List goods receipts description: Newest first. open=true keeps approved or posted, unclosed ones. Requires procurement.receipt.read. parameters: [{ $ref: '#/components/parameters/TradeState' }, { $ref: '#/components/parameters/TradeParty' }, { $ref: '#/components/parameters/TradeOpen' }, { $ref: '#/components/parameters/TradeCursor' }, { $ref: '#/components/parameters/TradeLimit' }] responses: { '200': { $ref: '#/components/responses/TradeList' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '422': { $ref: '#/components/responses/TradeInvalid' } } post: operationId: createGoodsReceipt tags: [Procurement] summary: Create a goods receipt description: Drawn from a committed purchase order's lines only (R-O3-1), in base units at the order's price per base unit (R-O3-2), at a location, with serials or batches. Requires procurement.receipt.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StockDocumentInput' } } } } responses: { '201': { $ref: '#/components/responses/TradeViewCreated' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/receipts/{id}: get: operationId: getGoodsReceipt tags: [Procurement] summary: One goods receipt description: Requires procurement.receipt.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { $ref: '#/components/responses/TradeView' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/receipts/{id}/transitions: post: operationId: transitionGoodsReceipt tags: [Procurement] summary: Move a goods receipt along its workflow description: '403 receiver_approved_order: the member who approved the order cannot post its receipt, save the organization''s one active member with a reason; 409 invoiced_document: a posted invoice is on it. Requires procurement.receipt.transition; authority to decide comes from the workflow''s grants.' parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/returns: get: operationId: listPurchaseReturns tags: [Procurement] summary: List purchase returns description: Newest first. open=true keeps approved or posted, unclosed ones. Requires procurement.receipt.read. parameters: [{ $ref: '#/components/parameters/TradeState' }, { $ref: '#/components/parameters/TradeParty' }, { $ref: '#/components/parameters/TradeOpen' }, { $ref: '#/components/parameters/TradeCursor' }, { $ref: '#/components/parameters/TradeLimit' }] responses: { '200': { $ref: '#/components/responses/TradeList' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '422': { $ref: '#/components/responses/TradeInvalid' } } post: operationId: createPurchaseReturn tags: [Procurement] summary: Create a purchase return description: Drawn from a posted goods receipt's lines, in base units, at a location; stock leaves at that receipt's cost. Requires procurement.receipt.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StockDocumentInput' } } } } responses: { '201': { $ref: '#/components/responses/TradeViewCreated' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/returns/{id}: get: operationId: getPurchaseReturn tags: [Procurement] summary: One purchase return description: Requires procurement.receipt.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { $ref: '#/components/responses/TradeView' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/returns/{id}/transitions: post: operationId: transitionPurchaseReturn tags: [Procurement] summary: Move a purchase return along its workflow description: '409 invoiced_document: a posted credit note is on it. Requires procurement.receipt.transition; authority to decide comes from the workflow''s grants.' parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/quotations: get: operationId: listQuotations tags: [Sales] summary: List quotations description: Newest first. open=true keeps approved or posted, unclosed ones. Requires sales.quotation.read. parameters: [{ $ref: '#/components/parameters/TradeState' }, { $ref: '#/components/parameters/TradeParty' }, { $ref: '#/components/parameters/TradeOpen' }, { $ref: '#/components/parameters/TradeCursor' }, { $ref: '#/components/parameters/TradeLimit' }] responses: { '200': { $ref: '#/components/responses/TradeList' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '422': { $ref: '#/components/responses/TradeInvalid' } } post: operationId: createQuotation tags: [Sales] summary: Create a quotation description: A draft; due_date, the date it is valid until, is required (422 valid_until_required). Expiry is derived (R-O3-10). Requires sales.quotation.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } } responses: { '201': { $ref: '#/components/responses/TradeViewCreated' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/quotations/{id}: get: operationId: getQuotation tags: [Sales] summary: One quotation description: Requires sales.quotation.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { $ref: '#/components/responses/TradeView' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '422': { $ref: '#/components/responses/TradeInvalid' } } put: operationId: updateQuotation tags: [Sales] summary: Edit your draft quotation description: If-Match is required. Requires sales.quotation.create. parameters: [{ $ref: '#/components/parameters/TradeID' }, { $ref: '#/components/parameters/TradeIfMatch' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/quotations/{id}/transitions: post: operationId: transitionQuotation tags: [Sales] summary: Move a quotation along its workflow description: Requires sales.quotation.transition; authority to decide comes from the workflow's grants. parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/orders: get: operationId: listSalesOrders tags: [Sales] summary: List sales orders description: Newest first. open=true keeps approved or posted, unclosed ones. Requires sales.order.read. parameters: [{ $ref: '#/components/parameters/TradeState' }, { $ref: '#/components/parameters/TradeParty' }, { $ref: '#/components/parameters/TradeOpen' }, { $ref: '#/components/parameters/TradeCursor' }, { $ref: '#/components/parameters/TradeLimit' }] responses: { '200': { $ref: '#/components/responses/TradeList' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '422': { $ref: '#/components/responses/TradeInvalid' } } post: operationId: createSalesOrder tags: [Sales] summary: Create a sales order description: A draft, direct or drawn from an issued quotation; 409 quotation_expired when a line draws from an expired one. Confirmed by the Owner seat. Requires sales.order.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } } responses: { '201': { $ref: '#/components/responses/TradeViewCreated' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/orders/{id}: get: operationId: getSalesOrder tags: [Sales] summary: One sales order description: Requires sales.order.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { $ref: '#/components/responses/TradeView' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '422': { $ref: '#/components/responses/TradeInvalid' } } put: operationId: updateSalesOrder tags: [Sales] summary: Edit your draft sales order description: If-Match is required. Requires sales.order.create. parameters: [{ $ref: '#/components/parameters/TradeID' }, { $ref: '#/components/parameters/TradeIfMatch' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/orders/{id}/transitions: post: operationId: transitionSalesOrder tags: [Sales] summary: Move a sales order along its workflow description: Requires sales.order.transition; authority to decide comes from the workflow's grants. parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/deliveries: get: operationId: listDeliveries tags: [Sales] summary: List deliveries description: Newest first. open=true keeps approved or posted, unclosed ones. Requires sales.delivery.read. parameters: [{ $ref: '#/components/parameters/TradeState' }, { $ref: '#/components/parameters/TradeParty' }, { $ref: '#/components/parameters/TradeOpen' }, { $ref: '#/components/parameters/TradeCursor' }, { $ref: '#/components/parameters/TradeLimit' }] responses: { '200': { $ref: '#/components/responses/TradeList' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '422': { $ref: '#/components/responses/TradeInvalid' } } post: operationId: createDelivery tags: [Sales] summary: Create a delivery description: Drawn from a confirmed sales order's lines only, in base units, at a location, with serials or batches. Requires sales.delivery.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StockDocumentInput' } } } } responses: { '201': { $ref: '#/components/responses/TradeViewCreated' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/deliveries/{id}: get: operationId: getDelivery tags: [Sales] summary: One delivery description: Requires sales.delivery.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { $ref: '#/components/responses/TradeView' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/deliveries/{id}/transitions: post: operationId: transitionDelivery tags: [Sales] summary: Move a delivery along its workflow description: '409 invoiced_document: a posted invoice is on it; 409 insufficient_stock. Requires sales.delivery.transition; authority to decide comes from the workflow''s grants.' parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/returns: get: operationId: listSalesReturns tags: [Sales] summary: List sales returns description: Newest first. open=true keeps approved or posted, unclosed ones. Requires sales.delivery.read. parameters: [{ $ref: '#/components/parameters/TradeState' }, { $ref: '#/components/parameters/TradeParty' }, { $ref: '#/components/parameters/TradeOpen' }, { $ref: '#/components/parameters/TradeCursor' }, { $ref: '#/components/parameters/TradeLimit' }] responses: { '200': { $ref: '#/components/responses/TradeList' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '422': { $ref: '#/components/responses/TradeInvalid' } } post: operationId: createSalesReturn tags: [Sales] summary: Create a sales return description: Drawn from a posted delivery's lines, in base units, at a location; stock comes back at the cost it left at. Requires sales.delivery.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StockDocumentInput' } } } } responses: { '201': { $ref: '#/components/responses/TradeViewCreated' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/returns/{id}: get: operationId: getSalesReturn tags: [Sales] summary: One sales return description: Requires sales.delivery.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { $ref: '#/components/responses/TradeView' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/returns/{id}/transitions: post: operationId: transitionSalesReturn tags: [Sales] summary: Move a sales return along its workflow description: '409 invoiced_document: a posted credit note is on it. Requires sales.delivery.transition; authority to decide comes from the workflow''s grants.' parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } }Then add the twelve other operations:
/procurement/orders/draft: get: operationId: draftPurchaseOrder tags: [Procurement] summary: A purchase order's lines copied forward from an approved requisition description: Nothing is saved. Requires procurement.order.create. parameters: [{ name: from, in: query, required: true, schema: { type: string, format: uuid } }] responses: { '200': { description: The draft, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/orders/{id}/close: post: operationId: closePurchaseOrder tags: [Procurement] summary: Close a purchase order by hand, with a reason description: It then takes no new receipts. Audited. Requires procurement.order.transition. parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeClose' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /procurement/orders/{id}/pdf: get: operationId: getPurchaseOrderPdf tags: [Procurement] summary: A purchase order as a PDF description: Printed under the organization's purchase_order layout. Requires procurement.order.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { description: The PDF, content: { application/pdf: { schema: { type: string, format: binary } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' } } /procurement/documents/{id}/chain: get: operationId: getPurchaseChain tags: [Procurement] summary: A procurement document's chain description: Its ancestors and descendants and the purchase invoices on their lines (R-O3-16). Requires procurement.order.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { description: The chain, content: { application/json: { schema: { $ref: '#/components/schemas/TradeChain' } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' } } /procurement/reports/open-orders: get: operationId: getOpenPurchaseOrders tags: [Procurement] summary: Purchase order lines still to receive description: Requires procurement.order.read. responses: { '200': { description: The rows, content: { application/json: { schema: { $ref: '#/components/schemas/OpenOrderReport' } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' } } /procurement/reports/grni-aging: get: operationId: getGrniAging tags: [Procurement] summary: Goods received not invoiced, aged description: Received − returned − invoiced + credited, at the receipt's base value (R-O3-17). Requires procurement.receipt.read. responses: { '200': { description: The rows, content: { application/json: { schema: { $ref: '#/components/schemas/GrniAgingReport' } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' } } /sales/orders/draft: get: operationId: draftSalesOrder tags: [Sales] summary: A sales order's lines copied forward from an issued quotation description: Nothing is saved. Requires sales.order.create. parameters: [{ name: from, in: query, required: true, schema: { type: string, format: uuid } }] responses: { '200': { description: The draft, content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentInput' } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/orders/{id}/close: post: operationId: closeSalesOrder tags: [Sales] summary: Close a sales order by hand, with a reason description: It then takes no new deliveries. Audited. Requires sales.order.transition. parameters: [{ $ref: '#/components/parameters/TradeID' }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TradeClose' } } } } responses: { '200': { $ref: '#/components/responses/TradeView' }, '400': { $ref: '#/components/responses/BadRequestBody' }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' }, '409': { $ref: '#/components/responses/TradeConflict' }, '422': { $ref: '#/components/responses/TradeInvalid' } } /sales/quotations/{id}/pdf: get: operationId: getQuotationPdf tags: [Sales] summary: A quotation as a PDF description: Printed under the organization's quotation layout. Requires sales.quotation.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { description: The PDF, content: { application/pdf: { schema: { type: string, format: binary } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' } } /sales/documents/{id}/chain: get: operationId: getSalesChain tags: [Sales] summary: A sales document's chain description: Its ancestors and descendants and the sales invoices on their lines (R-O3-16). Requires sales.order.read. parameters: [{ $ref: '#/components/parameters/TradeID' }] responses: { '200': { description: The chain, content: { application/json: { schema: { $ref: '#/components/schemas/TradeChain' } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' }, '404': { $ref: '#/components/responses/TradeNotFound' } } /sales/reports/open-orders: get: operationId: getOpenSalesOrders tags: [Sales] summary: Sales order lines still to deliver; late ones are backorders description: Requires sales.order.read. responses: { '200': { description: The rows, content: { application/json: { schema: { $ref: '#/components/schemas/OpenOrderReport' } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' } } /sales/reports/delivered-not-invoiced: get: operationId: getDeliveredNotInvoiced tags: [Sales] summary: Delivery lines not yet billed description: Requires sales.delivery.read. responses: { '200': { description: The rows, content: { application/json: { schema: { $ref: '#/components/schemas/UninvoicedDeliveryReport' } } } }, '401': { $ref: '#/components/responses/Unauthenticated' }, '403': { $ref: '#/components/responses/TradeForbidden' } }Under components.responses:
TradeView: description: The document with its available transitions and decisions. headers: { ETag: { $ref: '#/components/headers/TradeRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentView' } } } TradeViewCreated: description: Created, as a draft. headers: { ETag: { $ref: '#/components/headers/TradeRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentView' } } } TradeList: description: A page of documents. content: { application/json: { schema: { $ref: '#/components/schemas/TradeDocumentList' } } } TradeForbidden: description: 'The caller lacks the permission, or the rule refuses them: receiver_approved_order, not_preparer, or a workflow refusal code.' content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } TradeNotFound: description: No such document of this kind in this organization. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } TradeConflict: description: > code not_draft, source_not_approved, source_closed, over_open_quantity, nothing_open, has_children, not_closable, already_closed, invoiced_document, quotation_expired, revision_mismatch, or one of O2's stock codes (insufficient_stock, serial_held, …); or a workflow or ledger refusal. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } TradeInvalid: description: A field refusal, "field=<name> rule=<code>", or pair_not_allowed, party_mismatch, item_mismatch, not_stock_kind, not_commitment_kind. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }Under components.parameters:
TradeID: { name: id, in: path, required: true, schema: { type: string, format: uuid } } TradeIfMatch: { name: If-Match, in: header, required: true, schema: { type: string }, description: The ETag of the document being edited. } TradeState: { name: state, in: query, required: false, schema: { type: string } } TradeParty: { name: party_id, in: query, required: false, schema: { type: string, format: uuid } } TradeOpen: { name: open, in: query, required: false, schema: { type: boolean } } TradeCursor: { name: cursor, in: query, required: false, schema: { type: string } } TradeLimit: { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 200 } }Under components.headers:
TradeRevision: description: The document's revision, quoted. schema: { type: string }Under components.schemas:
TradeKind: type: string enum: [purchase_requisition, purchase_order, goods_receipt, purchase_return, quotation, sales_order, delivery_order, sales_return, stock_adjustment] TradeQuantity: type: string description: A quantity as a decimal string, up to 6 places. Never a JSON number. example: "12.000000" x-go-type: money.Rate x-go-type-import: path: github.com/mcom/erp/internal/kernel/money TradeShare: type: object additionalProperties: false required: [analytic_account_id, percentage] properties: analytic_account_id: { type: string, format: uuid } percentage: { $ref: '#/components/schemas/Rate' } TradeLine: type: object required: [id, line_no, item_id, description, quantity, uom_id, uom_factor, quantity_base, unit_price, net_amount, tax_code_id, shares, source_line_id, open_quantity] properties: id: { type: string, format: uuid } line_no: { type: integer } item_id: { type: string, format: uuid, nullable: true } description: '{ type: string }' quantity: { $ref: '#/components/schemas/TradeQuantity' } uom_id: { type: string, format: uuid } uom_factor: { $ref: '#/components/schemas/TradeQuantity' } quantity_base: { $ref: '#/components/schemas/TradeQuantity' } unit_price: { $ref: '#/components/schemas/Money' } net_amount: { $ref: '#/components/schemas/Money' } tax_code_id: { type: string, format: uuid, nullable: true } shares: { type: array, items: { $ref: '#/components/schemas/TradeShare' } } source_line_id: { type: string, format: uuid, nullable: true } open_quantity: { $ref: '#/components/schemas/TradeQuantity' } TradeDocument: type: object required: [id, kind, number, party_id, currency, fx_rate, document_date, due_date, location_id, net_total, net_base, closed_at, close_reason, fulfilled, closed, expired, instance_id, prepared_by, revision, created_at, state, semantic, lines] properties: id: { type: string, format: uuid } kind: { $ref: '#/components/schemas/TradeKind' } number: { type: string } party_id: { type: string, format: uuid, nullable: true, description: The supplier or customer; none on a requisition. } currency: { type: string } fx_rate: { $ref: '#/components/schemas/Rate' } document_date: { $ref: '#/components/schemas/Date' } due_date: { allOf: [{ $ref: '#/components/schemas/Date' }], nullable: true, description: 'Needed by, delivery date, or a quotation''s valid-until.' } location_id: { type: string, format: uuid, nullable: true } net_total: { $ref: '#/components/schemas/Money' } net_base: { $ref: '#/components/schemas/Money' } closed_at: { type: string, format: date-time, nullable: true } close_reason: { type: string, nullable: true } fulfilled: { type: boolean } closed: { type: boolean } expired: { type: boolean, description: 'A quotation past its valid-until date (derived, R-O3-10).' } instance_id: { type: string, format: uuid } prepared_by: { type: string, format: uuid } revision: { type: integer } created_at: { type: string, format: date-time } state: { type: string } semantic: { $ref: '#/components/schemas/WorkflowSemantic' } lines: { type: array, items: { $ref: '#/components/schemas/TradeLine' } } TradeDocumentView: type: object required: [document, available_transitions, decisions] properties: document: { $ref: '#/components/schemas/TradeDocument' } available_transitions: { type: array, items: { $ref: '#/components/schemas/WorkflowAvailableTransition' } } decisions: { type: array, items: { $ref: '#/components/schemas/DocumentDecision' } } TradeDocumentList: type: object required: [documents, next_cursor] properties: documents: { type: array, items: { $ref: '#/components/schemas/TradeDocument' } } next_cursor: { type: string, nullable: true } TradeLineInput: type: object additionalProperties: false required: [description, quantity, uom_id, unit_price] properties: item_id: { type: string, format: uuid, nullable: true } description: '{ type: string, minLength: 1, maxLength: 500 }' quantity: { $ref: '#/components/schemas/TradeQuantity' } uom_id: { type: string, format: uuid } unit_price: { $ref: '#/components/schemas/Money' } tax_code_id: { type: string, format: uuid, nullable: true } shares: { type: array, items: { $ref: '#/components/schemas/TradeShare' } } source_line_id: { type: string, format: uuid, nullable: true } TradeDocumentInput: type: object additionalProperties: false required: [currency, lines] properties: party_id: { type: string, format: uuid, nullable: true } currency: { type: string, minLength: 3, maxLength: 3 } fx_rate: { allOf: [{ $ref: '#/components/schemas/Rate' }], nullable: true } due_date: { allOf: [{ $ref: '#/components/schemas/Date' }], nullable: true } lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/TradeLineInput' } } StockDocumentLineInput: type: object additionalProperties: false required: [source_line_id, quantity] properties: source_line_id: { type: string, format: uuid } quantity: { $ref: '#/components/schemas/TradeQuantity', description: In the item's base unit (R-O3-2). } lots: { type: array, items: { $ref: '#/components/schemas/StockLot' } } StockDocumentInput: type: object additionalProperties: false required: [source_document_id, location_id, lines] properties: source_document_id: { type: string, format: uuid, description: 'The order (receipt), receipt (purchase return), sales order (delivery) or delivery (sales return).' } location_id: { type: string, format: uuid } lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/StockDocumentLineInput' } } TradeClose: type: object additionalProperties: false required: [reason] properties: reason: { type: string, minLength: 1, maxLength: 500 } TradeChainNode: type: object required: [id, kind, number, state, semantic, document_date] properties: id: { type: string, format: uuid } kind: { $ref: '#/components/schemas/TradeKind' } number: { type: string } state: { type: string } semantic: { $ref: '#/components/schemas/WorkflowSemantic' } document_date: { $ref: '#/components/schemas/Date' } TradeChainInvoice: type: object required: [id, kind, reference, state] properties: id: { type: string, format: uuid } kind: { type: string, enum: [purchase_invoice, supplier_credit_note, sales_invoice, customer_credit_note] } reference: { type: string } state: { type: string } TradeChain: type: object required: [documents, invoices] properties: documents: { type: array, items: { $ref: '#/components/schemas/TradeChainNode' } } invoices: { type: array, items: { $ref: '#/components/schemas/TradeChainInvoice' } } OpenOrderRow: type: object required: [document_id, number, party_id, party_name, document_date, due_date, late, line_no, item_id, description, ordered, open, currency, open_amount] properties: document_id: { type: string, format: uuid } number: { type: string } party_id: { type: string, format: uuid } party_name: { type: string } document_date: { $ref: '#/components/schemas/Date' } due_date: { allOf: [{ $ref: '#/components/schemas/Date' }], nullable: true } late: { type: boolean, description: 'Past its due date; on the sales side, a backorder.' } line_no: { type: integer } item_id: { type: string, format: uuid, nullable: true } description: '{ type: string }' ordered: { $ref: '#/components/schemas/TradeQuantity' } open: { $ref: '#/components/schemas/TradeQuantity' } currency: { type: string } open_amount: { $ref: '#/components/schemas/Money' } OpenOrderReport: type: object required: [rows] properties: rows: { type: array, items: { $ref: '#/components/schemas/OpenOrderRow' } } GrniAgingRow: type: object required: [receipt_id, number, supplier_id, supplier_name, received_on, age_days, line_no, item_id, description, outstanding, currency, outstanding_base] properties: receipt_id: { type: string, format: uuid } number: { type: string } supplier_id: { type: string, format: uuid } supplier_name: { type: string } received_on: { $ref: '#/components/schemas/Date' } age_days: { type: integer } line_no: { type: integer } item_id: { type: string, format: uuid, nullable: true } description: '{ type: string }' outstanding: { $ref: '#/components/schemas/TradeQuantity' } currency: { type: string } outstanding_base: { $ref: '#/components/schemas/Money' } GrniAgingReport: type: object required: [rows] properties: rows: { type: array, items: { $ref: '#/components/schemas/GrniAgingRow' } } UninvoicedDeliveryRow: type: object required: [delivery_id, number, customer_id, customer_name, delivered_on, line_no, item_id, description, delivered, invoiced, uninvoiced, currency, uninvoiced_amount] properties: delivery_id: { type: string, format: uuid } number: { type: string } customer_id: { type: string, format: uuid } customer_name: { type: string } delivered_on: { $ref: '#/components/schemas/Date' } line_no: { type: integer } item_id: { type: string, format: uuid, nullable: true } description: '{ type: string }' delivered: { $ref: '#/components/schemas/TradeQuantity' } invoiced: { $ref: '#/components/schemas/TradeQuantity' } uninvoiced: { $ref: '#/components/schemas/TradeQuantity' } currency: { type: string } uninvoiced_amount: { $ref: '#/components/schemas/Money' } UninvoicedDeliveryReport: type: object required: [rows] properties: rows: { type: array, items: { $ref: '#/components/schemas/UninvoicedDeliveryRow' } }Run: make generate && git status --short — expected: the generated files change, nothing else.
- Step 11: Write the HTTP flow test
Create internal/modules/trade/env_test.go:
package trade_test
import ( "context" "encoding/json" "io" "log/slog" "net/http/httptest" "strings" "testing"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/authn" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/modules/trade" "github.com/mcom/erp/internal/platform/identity/token" "github.com/mcom/erp/internal/platform/trade/chaintest")
type alwaysLive struct{}
func (alwaysLive) Check(context.Context, authn.Claims) error { return nil }
// world is chaintest's organization with this module mounted on a test router.type world struct { e *chaintest.Env r *gin.Engine signer *token.Signer}
func newWorld(t *testing.T) *world { t.Helper() gin.SetMode(gin.TestMode) e := chaintest.New(t) signer, err := token.NewSigner(map[string][]byte{"k1": []byte("01234567890123456789012345678901")}, "k1", clock.Fixed(e.Clock.Now())) require.NoError(t, err) r, api := httpx.NewRouter(httpx.Deps{DB: e.DB, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Version: "test", TokenVerifier: signer, Liveness: alwaysLive{}}) trade.Register(api, api, trade.Deps{DB: e.DB, Clock: e.Clock}) return &world{e: e, r: r, signer: signer}}
// as mints a token for an existing member after giving them a role with keys.func (w *world) as(t *testing.T, member uuid.UUID, keys ...string) string { t.Helper() ctx, admin := context.Background(), w.e.H.Admin var roleID uuid.UUID require.NoError(t, admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1,$2,true) RETURNING id`, w.e.Org, "R-"+uuid.NewString()).Scan(&roleID)) for _, k := range keys { _, err := admin.Exec(ctx, `INSERT INTO role_permission (org_id, role_id, permission_key) VALUES ($1,$2,$3)`, w.e.Org, roleID, k) require.NoError(t, err) } _, err := admin.Exec(ctx, `INSERT INTO member_role (org_id, member_id, role_id) VALUES ($1,$2,$3)`, w.e.Org, member, roleID) require.NoError(t, err) tok, err := w.signer.Mint(authn.Claims{UserID: uuid.New(), OrgID: w.e.Org, MemberID: member, SessionID: uuid.New()}) require.NoError(t, err) return tok}
func (w *world) do(t *testing.T, method, path, body, tok string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tok) rec := httptest.NewRecorder() w.r.ServeHTTP(rec, req) return rec}
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T { t.Helper() var v T require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &v), rec.Body.String()) return v}
type docView struct { Document struct { ID string `json:"id"` State string `json:"state"` Lines []struct { ID string `json:"id"` } `json:"lines"` } `json:"document"`}Create internal/modules/trade/handlers_test.go:
package trade_test
import ( "bytes" "fmt" "net/http" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/trade")
// One order through the API: prepared, approved, printed, received; the// approver's own receipt refused (403 receiver_approved_order); the chain// and the open-order report read back; a document is found only through its// own family's routes.func TestAnOrderIsApprovedPrintedAndReceivedOverHTTP(t *testing.T) { w := newWorld(t) e := w.e bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) staff := w.as(t, e.Staff, trade.PermissionOrderRead, trade.PermissionOrderCreate, trade.PermissionOrderTransition, trade.PermissionReceiptRead, trade.PermissionReceiptCreate, trade.PermissionReceiptTransition) owner := w.as(t, e.Owner, trade.PermissionOrderRead, trade.PermissionOrderTransition, trade.PermissionReceiptCreate, trade.PermissionReceiptTransition) move := func(tok, path, from, to string) *httptest.ResponseRecorder { return w.do(t, "POST", path+"/transitions", fmt.Sprintf(`{"from":%q,"to":%q}`, from, to), tok) }
res := w.do(t, "POST", "/v1/procurement/orders", fmt.Sprintf(`{"party_id":%q,"currency":"MYR","lines":[ {"item_id":%q,"description":"Bolts","quantity":"10","uom_id":%q,"unit_price":"3.00"}]}`, sup, bolt, e.Unit), staff) require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) order := decode[docView](t, res).Document orderPath := "/v1/procurement/orders/" + order.ID require.Equal(t, http.StatusOK, move(staff, orderPath, "draft", "pending_approval").Code) res = move(owner, orderPath, "pending_approval", "approved") require.Equal(t, http.StatusOK, res.Code, res.Body.String())
pdf := w.do(t, "GET", orderPath+"/pdf", "", owner) require.Equal(t, http.StatusOK, pdf.Code, pdf.Body.String()) require.True(t, bytes.HasPrefix(pdf.Body.Bytes(), []byte("%PDF")))
receive := func(tok, qty string) docView { res := w.do(t, "POST", "/v1/procurement/receipts", fmt.Sprintf(`{"source_document_id":%q,"location_id":%q, "lines":[{"source_line_id":%q,"quantity":%q}]}`, order.ID, e.WH1, order.Lines[0].ID, qty), tok) require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) return decode[docView](t, res) } grn := receive(staff, "4").Document res = move(staff, "/v1/procurement/receipts/"+grn.ID, "draft", "received") require.Equal(t, http.StatusOK, res.Code, "PRESENCE: "+res.Body.String())
own := receive(owner, "1").Document refused := move(owner, "/v1/procurement/receipts/"+own.ID, "draft", "received") require.Equal(t, http.StatusForbidden, refused.Code, refused.Body.String()) require.Contains(t, refused.Body.String(), `"receiver_approved_order"`)
chain := decode[struct { Documents []struct{ Kind string } `json:"documents"` }](t, w.do(t, "GET", "/v1/procurement/documents/"+order.ID+"/chain", "", staff)) require.Len(t, chain.Documents, 3, "the order, the posted receipt and the Owner's draft") report := decode[struct { Rows []struct { Open string `json:"open"` } `json:"rows"` }](t, w.do(t, "GET", "/v1/procurement/reports/open-orders", "", staff)) require.Len(t, report.Rows, 1) require.Zero(t, money.MustParseRate("5").Cmp(money.MustParseRate(report.Rows[0].Open)), "ten less the posted four and the drafted one")
reader := w.as(t, e.Staff, trade.PermissionRequisitionRead) require.Equal(t, http.StatusNotFound, w.do(t, "GET", "/v1/procurement/requisitions/"+order.ID, "", reader).Code, "an order is not a requisition")}Add "net/http/httptest" and "github.com/mcom/erp/internal/kernel/money" to its imports.
- Step 12: Run everything this task touches
Run: make generate && go test ./internal/modules/trade/ ./internal/platform/trade/ ./internal/app/ ./cmd/... ./internal/gen/ ./internal/kernel/vocab/ -count=1
Expected: PASS — including TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, the wiring tests, the grants migration test.
- Step 13: Sabotage
Register /procurement/receipts/:id/transitions under PermissionReceiptRead in routes.go; run go test ./internal/modules/trade/ -run TestEveryRouteRequires -count=1; read the red map diff; restore. Remove the if procurementKinds[d.Kind] != procurement check from chain; nothing breaks — add require.Equal(t, http.StatusNotFound, w.do(t, "GET", "/v1/sales/documents/"+order.ID+"/chain", "", w.as(t, e.Staff, trade.PermissionSalesOrderRead)).Code) to the flow test, watch it fail, restore the check, keep the assertion.
- Step 14: Commit
git add internal/modules/trade internal/platform/trade/migration00742_test.go db/migrations/00742_trade_grants.sql \ constants.yaml internal/kernel/vocab packages/contracts/vocab.ts api/openapi.yaml internal/gen web/src/lib/api/schema.d.ts \ internal/app cmdgit commit -m "Phase 4 O3: procurement and sales routes, permissions and grants
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 10: The AP and AR routes carry the match: line links, the override, the tolerance, the pickers
Section titled “Task 10: The AP and AR routes carry the match: line links, the override, the tolerance, the pickers”Files:
- Modify:
internal/modules/payables/{permissions,routes,invoice_handlers,errors}.go - Modify:
internal/modules/payables/routes_test.go,errors_test.go; Test:internal/modules/payables/match_handlers_test.go - Modify:
internal/modules/receivables/{routes,document_handlers,errors}.go,errors_test.go - Modify:
constants.yaml(payables.invoice.match_override), regenerate - Modify:
api/openapi.yaml, regenerate
Interfaces:
-
Consumes: Tasks 3–6 (
payables.LineInput.TradeLineID/Quantity,TransitionInput.MatchOverrideReason,MatchError,ErrOverrideByPreparer,ErrNothingToClear,GetMatchTolerance,SetMatchTolerance,MatchableLines;receivables.LineInput.TradeLineID,DeliveryMatchError,MatchableLines);member.Can. -
Produces:
payables.PermissionInvoiceMatchOverride = "payables.invoice.match_override"(module); routesGET|PUT /v1/payables/match-tolerance,GET /v1/payables/matchable-lines,GET /v1/receivables/matchable-lines; the invoice transition body’smatch_override_reason; refusal codesthree_way_mismatch(409, withmismatches),match_override_permission(403),override_by_preparer(403),nothing_to_clear(409),invoiced_exceeds_delivered(409); schemasPurchaseInvoiceTransition,MatchTolerance,MatchableLine,MatchableLineList;trade_line_id/quantityon AP lines andtrade_line_idon AR lines. -
Step 1: Write the failing tests
In internal/modules/payables/routes_test.go, add to want (the override key is checked inside the transition route, not by a route of its own):
"GET /v1/payables/match-tolerance": invRead, "PUT /v1/payables/match-tolerance": "org.profile.manage", "GET /v1/payables/matchable-lines": invCreate,Append to internal/modules/payables/errors_test.go:
// O3 R-O3-7: a three-way mismatch is a 409 naming each line; the preparer's// override a 403.func TestTheThreeWayMatchRefusalsMapToStableCodes(t *testing.T) { mm := &p.MatchError{Mismatches: []p.Mismatch{{LineNo: 1, Rule: p.MismatchPrice}}} var problem *apperr.Problem require.ErrorAs(t, mapErr(mm), &problem) require.Equal(t, 409, problem.Status) require.Equal(t, "three_way_mismatch", problem.Extensions["code"]) require.Equal(t, mm.Mismatches, problem.Extensions["mismatches"]) require.ErrorAs(t, mapErr(p.ErrOverrideByPreparer), &problem) require.Equal(t, 403, problem.Status) require.Equal(t, "override_by_preparer", problem.Extensions["code"])}Create internal/modules/payables/match_handlers_test.go:
package payables_test
import ( "net/http" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/payables" p "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
// R-O3-7: approving with an override reason needs the override key (the// BR-11 shape), on top of the transition key.func TestAMatchOverrideNeedsItsOwnKey(t *testing.T) { e := newEnv(t) sup := e.px.Supplier(t, "OVR") line := payablestest.Line(e.px.Account(t, "6100"), "10.00") approve := func(no, reason string) int { inv := e.px.Submitted(t, e.px.Invoice(t, p.KindInvoice, sup, no, line)) body := map[string]string{"from": "pending_review", "to": "approved"} if reason != "" { body["match_override_reason"] = reason } res := e.do(t, "POST", "/v1/payables/invoices/"+inv.ID.String()+"/transitions", e.approverTok, body) if res.Code == http.StatusForbidden { require.Contains(t, res.Body.String(), `"match_override_permission"`) } return res.Code } require.Equal(t, http.StatusOK, approve("OVR-1", ""), "PRESENCE: the approver approves") require.Equal(t, http.StatusForbidden, approve("OVR-2", "Agreed with the supplier")) e.grant(t, e.approver, payables.PermissionInvoiceMatchOverride) require.Equal(t, http.StatusOK, approve("OVR-3", "Agreed with the supplier"), "nothing mismatched: the reason records nothing")}
// R-O3-6 over HTTP: read at revision 0, set against it.func TestTheMatchToleranceRoundTrips(t *testing.T) { e := newEnv(t) res := e.do(t, "GET", "/v1/payables/match-tolerance", e.readerTok, nil) require.Equal(t, http.StatusOK, res.Code, res.Body.String()) require.JSONEq(t, `{"percent":"0.0000000000","amount":"0.0000","revision":0}`, res.Body.String()) e.grant(t, e.approver, "org.profile.manage") res = e.do(t, "PUT", "/v1/payables/match-tolerance", e.approverTok, map[string]any{"percent": "2.5", "amount": "0.10", "revision": 0}) require.Equal(t, http.StatusOK, res.Code, res.Body.String()) require.Contains(t, res.Body.String(), `"revision":1`) require.Equal(t, http.StatusForbidden, e.do(t, "PUT", "/v1/payables/match-tolerance", e.clerkTok, map[string]any{"percent": "9", "amount": "0", "revision": 1}).Code, "setting it needs org.profile.manage")}Append to internal/modules/receivables/errors_test.go:
// O3.2: billing past a delivery is a 409 with a stable code.func TestBillingPastADeliveryMapsToItsCode(t *testing.T) { var problem *apperr.Problem require.ErrorAs(t, mapErr(&p.DeliveryMatchError{LineNo: 1}), &problem) require.Equal(t, 409, problem.Status) require.Equal(t, "invoiced_exceeds_delivered", problem.Extensions["code"])}(add the apperr import to that file if it lacks it).
- Step 2: Run them to verify they fail
Run: go test ./internal/modules/payables/ ./internal/modules/receivables/ -run 'TestEveryRouteRequires|TestTheThreeWay|TestAMatchOverride|TestTheMatchTolerance|TestBillingPast' -count=1
Expected: FAIL to compile — undefined: payables.PermissionInvoiceMatchOverride.
- Step 3: The payables module
In internal/modules/payables/permissions.go, add to the const block PermissionInvoiceMatchOverride = "payables.invoice.match_override" and to init(), after the other registrations:
// O3 R-O3-7 (the BR-11 shape): the Owner's by default — provisioning // seeds the Owner with every registered key; no other role gets it. member.RegisterPermission(PermissionInvoiceMatchOverride, "Approve a purchase invoice past a three-way mismatch, with a reason (never your own)")and change the comment “The fifteen permissions” to “The sixteen permissions”. In constants.yaml, add - payables.invoice.match_override after - payables.invoice.transition; run go run ./tools/vocabgen.
In internal/modules/payables/routes.go, after the POST /payables/invoices/:id/transitions line:
httpx.Protected(g, pl, "GET", "/payables/match-tolerance", PermissionInvoiceRead, h.getMatchTolerance) httpx.Protected(g, pl, "PUT", "/payables/match-tolerance", "org.profile.manage", h.setMatchTolerance) httpx.Protected(g, pl, "GET", "/payables/matchable-lines", PermissionInvoiceCreate, h.matchableLines)In internal/modules/payables/invoice_handlers.go:
(a) Add to lineDTO after ClassificationCode:
TradeLineID *uuid.UUID `json:"trade_line_id"` Quantity *money.Rate `json:"quantity"`and in invoiceView replace the positional literal with
out.Lines = append(out.Lines, lineDTO{LineNo: l.LineNo, Description: l.Description, AccountID: l.AccountID, TaxCodeID: l.TaxCodeID, NetAmount: l.NetAmount, TaxAmount: l.TaxAmount, NetBase: l.NetBase, TaxBase: l.TaxBase, ClassificationCode: l.ClassificationCode, TradeLineID: l.TradeLineID, Quantity: l.Quantity, Shares: shares})(b) Add to lineBody after ClassificationCode:
TradeLineID *uuid.UUID `json:"trade_line_id"` Quantity *money.Rate `json:"quantity"`and in input() set them: li := p.LineInput{…, ClassificationCode: l.ClassificationCode, TradeLineID: l.TradeLineID, Quantity: l.Quantity}.
(c) Replace transitionInvoice with:
// invoiceTransitionBody is an invoice's or credit note's move; an approval// past a three-way mismatch names its reason (OD-3, R-O3-7).type invoiceTransitionBody struct { From string `json:"from"` To string `json:"to"` Reason string `json:"reason"` MatchOverrideReason string `json:"match_override_reason"`}
func (h handlers) transitionInvoice(c *gin.Context) { id, err := parseID(c, "id") var body invoiceTransitionBody if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.invoiceTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (p.Invoice, error) { if strings.TrimSpace(body.MatchOverrideReason) != "" { // the BR-11 shape: its own key, on top of the route's ok, err := member.Can(ctx, tx, who, PermissionInvoiceMatchOverride) if err != nil { return p.Invoice{}, err } if !ok { return p.Invoice{}, apperr.New(apperr.KindForbidden, "approving past a three-way mismatch needs "+ PermissionInvoiceMatchOverride).With("code", "match_override_permission") } } v, _, err := p.TransitionInvoice(ctx, tx, h.deps.Clock, p.TransitionInput{ID: id, From: body.From, To: body.To, Actor: who, Reason: body.Reason, MatchOverrideReason: body.MatchOverrideReason}) return v, err })}
type toleranceDTO struct { Percent money.Rate `json:"percent"` Amount money.Amount `json:"amount"` Revision int `json:"revision"`}
func (h handlers) getMatchTolerance(c *gin.Context) { var out toleranceDTO err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { t, err := p.GetMatchTolerance(ctx, tx) out = toleranceDTO{t.Percent, t.Amount, t.Revision} return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, out)}
func (h handlers) setMatchTolerance(c *gin.Context) { who, err := mustActor(c.Request.Context()) var body toleranceDTO if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } var out toleranceDTO err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { t, err := p.SetMatchTolerance(ctx, tx, h.deps.Clock, who, body.Revision, p.MatchTolerance{Percent: body.Percent, Amount: body.Amount}) out = toleranceDTO{t.Percent, t.Amount, t.Revision} return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, out)}
// matchableLines feeds the invoice screen's receipt-line picker.func (h handlers) matchableLines(c *gin.Context) { supplier, err := uuid.Parse(c.Query("supplier_id")) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=supplier_id rule=uuid: supplier_id must be a UUID")) return } var out []p.MatchableLine err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error out, err = p.MatchableLines(ctx, tx, supplier, c.Query("kind")) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"lines": out})}Add "strings" and "github.com/mcom/erp/internal/platform/identity/member" to its imports. The shared transitionBody stays for payments.
In internal/modules/payables/errors.go, add mismatch *p.MatchError to the var (…) block and, as the first cases of the switch:
case errors.As(err, &mismatch): // OD-3, R-O3-7 return apperr.New(apperr.KindConflict, strings.TrimPrefix(mismatch.Error(), "payables: ")). With("code", "three_way_mismatch").With("mismatches", mismatch.Mismatches) case errors.Is(err, p.ErrOverrideByPreparer): return apperr.New(apperr.KindForbidden, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "override_by_preparer") case errors.Is(err, p.ErrNothingToClear): return apperr.New(apperr.KindConflict, strings.TrimPrefix(err.Error(), "payables: ")).With("code", "nothing_to_clear")- Step 4: The receivables module
In internal/modules/receivables/document_handlers.go: add TradeLineID *uuid.UUID \json:“trade_line_id”`tosalesLineDTO(afterShares) and to salesLineBody(afterShares); set TradeLineID: l.TradeLineIDintoDocumentDTO's salesLineDTO{…}literal and ininput()'s p.LineInput{…}` literal. Append:
// matchableLines feeds the invoice screen's delivery-line picker (O3.2).func (h handlers) matchableLines(c *gin.Context) { customer, err := uuid.Parse(c.Query("customer_id")) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=customer_id rule=uuid: customer_id must be a UUID")) return } var out []p.MatchableLine err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error out, err = p.MatchableLines(ctx, tx, customer, c.Query("kind")) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"lines": out})}(add apperr to its imports if absent). In routes.go, after the POST /receivables/invoices/:id/transitions line:
httpx.Protected(g, pl, "GET", "/receivables/matchable-lines", PermissionInvoiceCreate, h.matchableLines)In errors.go, add delivery *p.DeliveryMatchError to the var (…) block and, after the ErrNotFound case:
case errors.As(err, &delivery): // O3.2: invoiced ≤ delivered − invoiced return apperr.New(apperr.KindConflict, msg()).With("code", "invoiced_exceeds_delivered")- Step 5: The contract
In api/openapi.yaml:
(a) PurchaseInvoiceLineInput: required: [description, net_amount, tax_amount]; account_id becomes { type: string, format: uuid, description: 'Required on an unmatched line; ignored on a matched one (charged to GRNI, R-O3-8).' }; add
trade_line_id: { type: string, format: uuid, nullable: true, description: 'A goods receipt line (an invoice) or purchase return line (a credit note) of this supplier, in the order''s currency (R-O3-8).' } quantity: { allOf: [{ $ref: '#/components/schemas/TradeQuantity' }], nullable: true, description: 'With trade_line_id only: how much of it, in the item''s base unit.' }(b) PurchaseInvoiceLine: add trade_line_id and quantity to required and the same two properties (without the input descriptions).
(c) SalesLineInput and SalesLine: add trade_line_id: { type: string, format: uuid, nullable: true, description: 'A delivery line (an invoice) or sales return line (a credit note) of this customer; quantity is then in base units (O3.2).' }; on SalesLine also in required.
(d) Add the schema
PurchaseInvoiceTransition: type: object additionalProperties: false required: [from, to] properties: from: { type: string, description: The state the caller believes the document is in. } to: { type: string } reason: { type: string } match_override_reason: type: string maxLength: 500 description: > Approve past a three-way mismatch (OD-3): needs payables.invoice.match_override (403 match_override_permission) and an approver who did not prepare the document (403 override_by_preparer). Recorded and audited; ignored when nothing mismatches. MatchTolerance: type: object additionalProperties: false required: [percent, amount, revision] properties: percent: { $ref: '#/components/schemas/Rate', description: 'Of the receipt''s value, 0 to 100.' } amount: { $ref: '#/components/schemas/Money', description: 'Per unit, in the base currency.' } revision: { type: integer, description: What the caller read; 0 when never set. } MatchableLine: type: object required: [trade_line_id, document_id, document_number, line_no, item_id, description, currency, available, unit_price] properties: trade_line_id: { type: string, format: uuid } document_id: { type: string, format: uuid } document_number: { type: string } line_no: { type: integer } item_id: { type: string, format: uuid, nullable: true } description: '{ type: string }' currency: { type: string } available: { $ref: '#/components/schemas/TradeQuantity' } unit_price: { $ref: '#/components/schemas/Money', description: Per base unit. } MatchableLineList: type: object required: [lines] properties: lines: { type: array, items: { $ref: '#/components/schemas/MatchableLine' } }(e) /payables/invoices/{id}/transitions: request schema PurchaseInvoiceTransition; append to its description “409 three_way_mismatch (with mismatches: each line’s rule, quantity, available, amount and expected) when a matched line exceeds what is left of its receipt or strays past the tolerance (OD-3).”
(f) /receivables/invoices/{id}/transitions: append “409 invoiced_exceeds_delivered when a line bills more of a delivery (or credits more of a sales return) than is left.”
(g) New paths, after /payables/invoices/{id}/transitions:
/payables/match-tolerance: get: operationId: getMatchTolerance tags: [Payables] summary: How far a matched invoice's price may stray from its receipt's description: Both 0 until set (an exact match). Requires payables.invoice.read. responses: '200': { description: The tolerance, content: { application/json: { schema: { $ref: '#/components/schemas/MatchTolerance' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } put: operationId: setMatchTolerance tags: [Payables] summary: Set the three-way match tolerance description: Against the revision read (409 revision_mismatch). Audited. Requires org.profile.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/MatchTolerance' } } } } responses: '200': { description: The tolerance, content: { application/json: { schema: { $ref: '#/components/schemas/MatchTolerance' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } '409': { $ref: '#/components/responses/PayablesConflict' } '422': { $ref: '#/components/responses/PayablesInvalid' } /payables/matchable-lines: get: operationId: listMatchablePurchaseLines tags: [Payables] summary: A supplier's receipt (or return) lines with quantity left to invoice (or credit) description: kind purchase_invoice lists goods receipt lines, supplier_credit_note purchase return lines. Requires payables.invoice.create. parameters: - { name: supplier_id, in: query, required: true, schema: { type: string, format: uuid } } - { name: kind, in: query, required: true, schema: { type: string, enum: [purchase_invoice, supplier_credit_note] } } responses: '200': { description: The lines, content: { application/json: { schema: { $ref: '#/components/schemas/MatchableLineList' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayablesForbidden' } '422': { $ref: '#/components/responses/PayablesInvalid' }and after /receivables/invoices/{id}/transitions:
/receivables/matchable-lines: get: operationId: listMatchableSalesLines tags: [Receivables] summary: A customer's delivery (or sales return) lines with quantity left to bill (or credit) description: kind sales_invoice lists delivery lines, customer_credit_note sales return lines. Requires receivables.invoice.create. parameters: - { name: customer_id, in: query, required: true, schema: { type: string, format: uuid } } - { name: kind, in: query, required: true, schema: { type: string, enum: [sales_invoice, customer_credit_note] } } responses: '200': { description: The lines, content: { application/json: { schema: { $ref: '#/components/schemas/MatchableLineList' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/ReceivablesForbidden' } '422': { description: A field refusal, content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }- Step 6: Run the tests to verify they pass
Run: make generate && go test ./internal/modules/payables/ ./internal/modules/receivables/ ./internal/app/ ./cmd/... ./internal/gen/ ./internal/kernel/vocab/ -count=1
Expected: PASS.
- Step 7: Sabotage
In transitionInvoice invert the permission check (if ok); run -run TestAMatchOverrideNeedsItsOwnKey; read the red; restore.
- Step 8: Commit
git add internal/modules/payables internal/modules/receivables constants.yaml internal/kernel/vocab packages/contracts/vocab.ts \ api/openapi.yaml internal/gen web/src/lib/api/schema.d.tsgit commit -m "Phase 4 O3: invoice routes carry the match, the override key, tolerance and line pickers
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 11: The operations screens
Section titled “Task 11: The operations screens”Files:
- Create:
web/src/features/trade/kinds.ts,api.ts,DocumentsPage.tsx,DocumentDialog.tsx,StockDialog.tsx,DocumentDrawer.tsx,ChainView.tsx,ReportsPage.tsx,routes.tsx - Create:
web/src/lib/i18n/locales/{en,ms,zh}/trade.json - Modify:
web/src/app/routes.ts,web/src/app/shell/nav.ts,web/src/lib/i18n/locales/{en,ms,zh}/shell.json - Test:
web/src/features/trade/__tests__/trade.test.tsx
Interfaces:
-
Consumes: Task 9’s routes and generated
Schemas['TradeDocument' | 'TradeDocumentView' | 'TradeDocumentInput' | 'TradeLineInput' | 'StockDocumentInput' | 'TradeDocumentList' | 'TradeChain' | 'OpenOrderRow' | 'GrniAgingRow' | 'UninvoicedDeliveryRow']; O1’suseItems,useUoms,useLocations,useTaxCodes(@/features/inventory/api);TransitionBar,StatusChip,DecisionTimeline,PdfButton,DataTable,useCan,hasPermission,useMe,RequirePermission. -
Produces: routes
/procurement/{requisitions,orders,receipts,returns}and/sales/{quotations,orders,deliveries,returns}(each withnew,$id,$id/edit;new?from=<source id>starts from a source) and/operations/reports; nav groupoperations;KINDS,trimQty(./kinds). -
Step 1: Write the failing screen tests
Create web/src/features/trade/__tests__/trade.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, waitFor, within } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const line = { id: 'l1', line_no: 1, item_id: 'it1', description: 'Bolts', quantity: '10.000000', uom_id: 'u1', uom_factor: '1.000000', quantity_base: '10.000000', unit_price: '3.0000', net_amount: '30.0000', tax_code_id: null, shares: [], source_line_id: null, open_quantity: '6.000000',};const order = { id: 'o1', kind: 'purchase_order', number: 'PO-2026-0001', party_id: 's1', currency: 'MYR', fx_rate: '1.0000000000', document_date: '2026-09-21', due_date: null, location_id: null, net_total: '30.0000', net_base: '30.0000', closed_at: null, close_reason: null, fulfilled: false, closed: false, expired: false, instance_id: 'n1', prepared_by: 'm2', revision: 2, created_at: '2026-09-21T01:00:00Z', state: 'approved', semantic: 'committed', lines: [line],};const receipt = { ...order, id: 'g2', kind: 'goods_receipt', number: 'GRN-2026-0002', state: 'draft', semantic: 'draft' };const definition = (kind: string) => ({ kind, version: 1, graph: { kind, posting_order: 'accrual_first', edges: [], grants: [], states: [ { key: 'draft', semantic: 'draft', display_order: 1, is_terminal: false, labels: { en: 'Draft' } }, { key: 'approved', semantic: 'committed', display_order: 3, is_terminal: false, labels: { en: 'Approved' } }, ], },});
function mockApi(posted: unknown[] = []) { server.use( http.get('*/v1/workflow/definitions/:kind', ({ params }) => HttpResponse.json(definition(params.kind as string))), http.get('*/v1/payables/suppliers', () => HttpResponse.json({ suppliers: [{ id: 's1', code: 'BEKAL', legal_name: 'Bekal Sdn Bhd', currency: 'MYR', is_active: true }], }), ), http.get('*/v1/procurement/orders', () => HttpResponse.json({ documents: [order], next_cursor: null })), http.get('*/v1/procurement/orders/:id', () => HttpResponse.json({ document: order, available_transitions: [], decisions: [] }), ), http.get('*/v1/procurement/documents/:id/chain', () => HttpResponse.json({ documents: [ { id: 'o1', kind: 'purchase_order', number: 'PO-2026-0001', state: 'approved', semantic: 'committed', document_date: '2026-09-21' }, { id: 'g1', kind: 'goods_receipt', number: 'GRN-2026-0001', state: 'received', semantic: 'accrual', document_date: '2026-09-21' }, ], invoices: [], }), ), http.get('*/v1/inventory/uoms', () => HttpResponse.json({ uoms: [{ id: 'u1', key: 'C62', names: { en: 'Unit' }, is_active: true, global: true }] }), ), http.get('*/v1/inventory/items', () => HttpResponse.json({ items: [ { id: 'it1', code: 'BOLT', names: { en: 'Bolt' }, descriptions: {}, type: 'stock', base_uom_id: 'u1', category_id: null, tax_code_id: null, inventory_account_id: 'a1', cogs_account_id: 'a2', sales_account_id: null, purchase_account_id: null, tracking: 'none', is_active: true, uoms: [], custom_fields: {}, revision: 1, }, ], }), ), http.get('*/v1/inventory/locations', () => HttpResponse.json({ locations: [{ id: 'w1', code: 'WH1', names: { en: 'Main warehouse' }, is_active: true, revision: 1 }] }), ), http.get('*/v1/procurement/receipts', () => HttpResponse.json({ documents: [], next_cursor: null })), http.post('*/v1/procurement/receipts', async ({ request }) => { posted.push(await request.json()); return HttpResponse.json({ document: receipt, available_transitions: [], decisions: [] }, { status: 201 }); }), http.get('*/v1/procurement/receipts/:id', () => HttpResponse.json({ document: receipt, available_transitions: [], decisions: [] }), ), );}
describe('purchase orders', () => { it('lists orders with their supplier and offers a new one to a preparer (presence first)', async () => { mockApi(); await renderWithApp({ path: '/procurement/orders', permissions: ['procurement.order.read', 'procurement.order.create', 'payables.supplier.read'], }); const row = (await screen.findByText('PO-2026-0001')).closest('tr')!; expect(await within(row).findByText('BEKAL · Bekal Sdn Bhd')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'New purchase order' })).toBeInTheDocument(); });
it('is read-only without procurement.order.create', async () => { mockApi(); await renderWithApp({ path: '/procurement/orders', permissions: ['procurement.order.read'] }); expect(await screen.findByText('PO-2026-0001')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'New purchase order' })).not.toBeInTheDocument(); });
it("shows an order's chain, prints it, and offers its receipt", async () => { mockApi(); await renderWithApp({ path: '/procurement/orders/o1', permissions: ['procurement.order.read', 'procurement.receipt.create'], }); expect(await screen.findByText(/GRN-2026-0001/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /PDF/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'New goods receipt' })).toBeInTheDocument(); });});
describe('goods receipts', () => { it('receives against its order, in base units, at a location (R-O3-1, R-O3-2)', async () => { const posted: unknown[] = []; mockApi(posted); const { user } = await renderWithApp({ path: '/procurement/receipts/new?from=o1', permissions: ['procurement.receipt.read', 'procurement.receipt.create', 'procurement.receipt.transition'], }); await user.click(await screen.findByRole('combobox', { name: 'Location' })); await user.click(await screen.findByRole('option', { name: /WH1/ })); await user.type(await screen.findByRole('textbox', { name: 'Quantity 1' }), '4'); await user.click(screen.getByRole('button', { name: 'Save draft' })); await waitFor(() => expect(posted).toHaveLength(1)); expect(posted[0]).toEqual({ source_document_id: 'o1', location_id: 'w1', lines: [{ source_line_id: 'l1', quantity: '4' }] }); });});- Step 2: Run them to verify they fail
Run: pnpm -C web test -- features/trade
Expected: FAIL — /procurement/orders renders the not-found page.
- Step 3: The kinds and the API hooks
Create web/src/features/trade/kinds.ts:
// Procurement and sales (Phase 4 O3): one table of the eight document kinds the// generic screens are driven by. Each kind's API collection and screen route// are the same path (R-O3-14).import type { Permission } from '@/lib/api/types';
export type TradeKind = | 'purchase_requisition' | 'purchase_order' | 'goods_receipt' | 'purchase_return' | 'quotation' | 'sales_order' | 'delivery_order' | 'sales_return';
export interface KindConfig { kind: TradeKind; path: string; module: 'procurement' | 'sales'; read: Permission; create: Permission; transition: Permission; party: 'supplier' | 'customer' | null; /** Created from its source, in base units, at a location, never edited (R-O3-1, R-O3-2). */ stock: boolean; /** A stock kind's source; a commitment kind's optional "start from". */ source?: TradeKind; /** The kind the drawer offers to create from this one. */ next?: TradeKind; pdf?: boolean; closable?: boolean;}
const procurement = (kind: TradeKind, path: string, keys: 'requisition' | 'order' | 'receipt') => ({ kind, path, module: 'procurement' as const, read: `procurement.${keys}.read` as Permission, create: `procurement.${keys}.create` as Permission, transition: `procurement.${keys}.transition` as Permission,});const sales = (kind: TradeKind, path: string, keys: 'quotation' | 'order' | 'delivery') => ({ kind, path, module: 'sales' as const, read: `sales.${keys}.read` as Permission, create: `sales.${keys}.create` as Permission, transition: `sales.${keys}.transition` as Permission,});
export const KINDS: Record<TradeKind, KindConfig> = { purchase_requisition: { ...procurement('purchase_requisition', '/procurement/requisitions', 'requisition'), party: null, stock: false, next: 'purchase_order', }, purchase_order: { ...procurement('purchase_order', '/procurement/orders', 'order'), party: 'supplier', stock: false, source: 'purchase_requisition', next: 'goods_receipt', pdf: true, closable: true, }, goods_receipt: { ...procurement('goods_receipt', '/procurement/receipts', 'receipt'), party: 'supplier', stock: true, source: 'purchase_order', next: 'purchase_return', }, purchase_return: { ...procurement('purchase_return', '/procurement/returns', 'receipt'), party: 'supplier', stock: true, source: 'goods_receipt', }, quotation: { ...sales('quotation', '/sales/quotations', 'quotation'), party: 'customer', stock: false, next: 'sales_order', pdf: true, }, sales_order: { ...sales('sales_order', '/sales/orders', 'order'), party: 'customer', stock: false, source: 'quotation', next: 'delivery_order', closable: true, }, delivery_order: { ...sales('delivery_order', '/sales/deliveries', 'delivery'), party: 'customer', stock: true, source: 'sales_order', next: 'sales_return', }, sales_return: { ...sales('sales_return', '/sales/returns', 'delivery'), party: 'customer', stock: true, source: 'delivery_order', },};
export const KIND_LIST = Object.values(KINDS);
/** A wire quantity without trailing zeros: "6.000000" → "6", "1.500000" → "1.5". */export function trimQty(q: string): string { return q.includes('.') ? q.replace(/0+$/, '').replace(/\.$/, '') : q;}Create web/src/features/trade/api.ts:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';import { api } from '@/lib/api/client';import { unwrap } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';import type { KindConfig } from './kinds';
export type TradeDocument = Schemas['TradeDocument'];export type TradeView = Schemas['TradeDocumentView'];export type TradeInput = Schemas['TradeDocumentInput'];export type TradeLineInput = Schemas['TradeLineInput'];export type StockInput = Schemas['StockDocumentInput'];export type TradeChain = Schemas['TradeChain'];
type Raw = { data?: unknown; error?: unknown; response: Response };// The eight kinds share one set of schemas, each under its own collection (R-O3-14): the// screens address the client by the path in their KindConfig and type the answers by those// schemas.const raw = api as unknown as { GET(path: string, init?: object): Promise<Raw>; POST(path: string, init?: object): Promise<Raw>; PUT(path: string, init?: object): Promise<Raw>;};const etag = (rev: number) => `"${rev}"`;
export const tradeKeys = { all: ['trade'] as const, list: (kind: string, open: boolean) => ['trade', kind, 'list', open] as const, doc: (id: string) => ['trade', 'doc', id] as const, chain: (id: string) => ['trade', 'chain', id] as const,};
export function useDocuments(k: KindConfig, open = false, enabled = true) { return useQuery({ queryKey: tradeKeys.list(k.kind, open), enabled, queryFn: async ({ signal }) => ( unwrap( await raw.GET(k.path, { params: { query: { limit: 200, ...(open ? { open: true } : {}) } }, signal }), ) as Schemas['TradeDocumentList'] ).documents, });}
export function useDocument(k: KindConfig, id: string | undefined) { return useQuery({ queryKey: tradeKeys.doc(id ?? ''), enabled: !!id, queryFn: async ({ signal }) => unwrap(await raw.GET(`${k.path}/{id}`, { params: { path: { id } }, signal })) as TradeView, });}
function useAfter() { const qc = useQueryClient(); return (v: TradeView) => { qc.setQueryData(tradeKeys.doc(v.document.id), v); void qc.invalidateQueries({ queryKey: tradeKeys.all }); };}
export function useSaveDocument(k: KindConfig) { const after = useAfter(); return useMutation({ mutationFn: async (v: { id?: string; revision?: number; body: TradeInput }) => (v.id ? unwrap( await raw.PUT(`${k.path}/{id}`, { params: { path: { id: v.id }, header: { 'If-Match': etag(v.revision ?? 0) } }, body: v.body, }), ) : unwrap(await raw.POST(k.path, { body: v.body }))) as TradeView, onSuccess: after, });}
export function useCreateStock(k: KindConfig) { const after = useAfter(); return useMutation({ mutationFn: async (body: StockInput) => unwrap(await raw.POST(k.path, { body })) as TradeView, onSuccess: after, });}
export function useTransition(k: KindConfig) { const after = useAfter(); return useMutation({ mutationFn: async (v: { id: string; from: string; to: string; reason?: string }) => unwrap( await raw.POST(`${k.path}/{id}/transitions`, { params: { path: { id: v.id } }, body: { from: v.from, to: v.to, ...(v.reason ? { reason: v.reason } : {}) }, }), ) as TradeView, onSuccess: after, });}
export function useCloseDocument(k: KindConfig) { const after = useAfter(); return useMutation({ mutationFn: async (v: { id: string; reason: string }) => unwrap( await raw.POST(`${k.path}/{id}/close`, { params: { path: { id: v.id } }, body: { reason: v.reason } }), ) as TradeView, onSuccess: after, });}
/** O1's copy-forward: k's draft from an approved source. Nothing is saved. */export async function fetchDraft(k: KindConfig, from: string): Promise<TradeInput> { return unwrap(await raw.GET(`${k.path}/draft`, { params: { query: { from } } })) as TradeInput;}
export function useChain(k: KindConfig, id: string, enabled: boolean) { return useQuery({ queryKey: tradeKeys.chain(id), enabled, queryFn: async ({ signal }) => unwrap(await raw.GET(`/${k.module}/documents/{id}/chain`, { params: { path: { id } }, signal })) as TradeChain, });}
export function useReport<T>(path: string, enabled: boolean) { return useQuery({ queryKey: ['trade', 'report', path], enabled, queryFn: async ({ signal }) => (unwrap(await raw.GET(path, { signal })) as { rows: T[] }).rows, });}
export interface PartyOption { id: string; label: string; currency: string; active: boolean;}
/** The suppliers or customers a kind's documents name. */export function useParties(side: 'supplier' | 'customer' | null) { return useQuery({ queryKey: ['trade', 'parties', side], enabled: side !== null, queryFn: async ({ signal }): Promise<PartyOption[]> => side === 'supplier' ? unwrap(await api.GET('/payables/suppliers', { signal })).suppliers.map((s) => ({ id: s.id, label: `${s.code} · ${s.legal_name}`, currency: s.currency, active: s.is_active, })) : unwrap(await api.GET('/receivables/customers', { signal })).customers.map((c) => ({ id: c.id, label: `${c.code} · ${c.legal_name}`, currency: c.currency, active: c.is_active, })), });}
export const pdfPath = (k: KindConfig, id: string) => `/v1${k.path}/${id}/pdf`;- Step 4: The list, the dialogs, the drawer, the chain
Create web/src/features/trade/DocumentsPage.tsx:
import { useMemo, type ReactNode } from 'react';import { useTranslation } from 'react-i18next';import { Plus } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { PageHeader } from '@/components/ui/PageHeader';import { MoneyText } from '@/components/money/MoneyText';import { StatusChip } from '@/components/workflow/StatusChip';import { useCan } from '@/features/auth/session';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { useDocuments, useParties, type TradeDocument } from './api';import type { KindConfig } from './kinds';
export function DocumentsPage(p: { k: KindConfig; onOpen(id: string): void; onNew(): void; children?: ReactNode }) { const { t } = useTranslation('trade'); const [locale] = useLocale(); const canCreate = useCan(p.k.create); const docs = useDocuments(p.k); const parties = useParties(p.k.party); const partyName = useMemo( () => new Map((parties.data ?? []).map((x) => [x.id, x.label])), [parties.data], ); const columns = useMemo<ColumnDef<TradeDocument>[]>(() => { const cols: ColumnDef<TradeDocument>[] = [ { id: 'number', meta: { required: true }, header: t('columns.number'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.number}</span>, }, ]; if (p.k.party) cols.push({ id: 'party', header: t(`columns.${p.k.party}`), cell: ({ row }) => (row.original.party_id && partyName.get(row.original.party_id)) || '–', }); cols.push( { id: 'date', header: t('columns.date'), cell: ({ row }) => formatDate(row.original.document_date, locale) }, { id: 'due', header: t(`due.${p.k.kind}`), cell: ({ row }) => (row.original.due_date ? formatDate(row.original.due_date, locale) : '–'), }, { id: 'total', header: t('columns.total'), cell: ({ row }) => <MoneyText amount={row.original.net_total} currency={row.original.currency} />, }, { id: 'status', meta: { required: true }, header: t('columns.status'), cell: ({ row }) => ( <StatusChip kind={row.original.kind} state={row.original.state} semantic={row.original.semantic} /> ), }, ); return cols; }, [t, locale, p.k, partyName]); return ( <div className="flex flex-col gap-4"> <PageHeader title={t(`kinds.${p.k.kind}.title`)} subtitle={t(`kinds.${p.k.kind}.subtitle`)} actions={ canCreate && ( <Button onClick={p.onNew}> <Plus className="size-4" aria-hidden /> {t(`kinds.${p.k.kind}.new`)} </Button> ) } /> <DataTable tableId={`trade.${p.k.kind}`} columns={columns} data={docs.data ?? []} loading={docs.isLoading} getRowId={(d: TradeDocument) => d.id} onRowClick={(d: TradeDocument) => p.onOpen(d.id)} empty={<EmptyState title={t(`kinds.${p.k.kind}.empty`)} description={t(`kinds.${p.k.kind}.subtitle`)} />} /> {p.children} </div> );}Create web/src/features/trade/DocumentDialog.tsx:
import { useEffect, useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { X } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { IconButton } from '@/components/ui/IconButton';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { MoneyInput } from '@/components/money/MoneyInput';import { useMe } from '@/features/auth/session';import { useItems, useTaxCodes, useUoms } from '@/features/inventory/api';import { problemMessage } from '@/lib/api/problem';import { pickLabel, useLocale } from '@/lib/i18n';import { fetchDraft, useDocuments, useParties, useSaveDocument, useTransition, type TradeInput, type TradeLineInput, type TradeView,} from './api';import { KINDS, type KindConfig } from './kinds';
const NONE = '__none';const blank = (): TradeLineInput => ({ item_id: null, description: '', quantity: '1', uom_id: '', unit_price: '0', tax_code_id: null, source_line_id: null,});
/** A requisition, order, quotation or sales order: new, edited as a draft, or started from its source. */export function DocumentDialog(p: { k: KindConfig; view?: TradeView; sourceId?: string; onClose(): void; onDone(id: string): void;}) { const { t } = useTranslation('trade'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const doc = p.view?.document; const items = useItems(); const uoms = useUoms(); const taxCodes = useTaxCodes(); const parties = useParties(p.k.party); const sourceKind = p.k.source ? KINDS[p.k.source] : undefined; const sources = useDocuments(sourceKind ?? p.k, true, !!sourceKind && !doc); const save = useSaveDocument(p.k); const move = useTransition(p.k); const [h, setH] = useState<Omit<TradeInput, 'lines'>>({ party_id: doc?.party_id ?? null, currency: doc?.currency ?? base, fx_rate: doc && doc.currency !== base ? doc.fx_rate : null, due_date: doc?.due_date ?? null, }); const [lines, setLines] = useState<TradeLineInput[]>( doc?.lines.map((l) => ({ item_id: l.item_id, description: l.description, quantity: l.quantity, uom_id: l.uom_id, unit_price: l.unit_price, tax_code_id: l.tax_code_id, source_line_id: l.source_line_id, shares: l.shares, })) ?? [blank()], ); const [saved, setSaved] = useState(p.view); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const setLine = (i: number, patch: Partial<TradeLineInput>) => setLines((ls) => ls.map((l, j) => (j === i ? { ...l, ...patch } : l)));
async function startFrom(id: string) { try { const d = await fetchDraft(p.k, id); setH((s) => ({ ...s, party_id: d.party_id ?? s.party_id, currency: d.currency, fx_rate: d.fx_rate ?? null })); setLines(d.lines); } catch (e) { setError(problemMessage(e, tc)); } } useEffect(() => { if (p.sourceId) void startFrom(p.sourceId); // Once, for the source the route named. // eslint-disable-next-line react-hooks/exhaustive-deps }, []);
function pickItem(i: number, id: string) { const it = items.data?.find((x) => x.id === id); setLine(i, { item_id: id, uom_id: it?.base_uom_id ?? '', description: 'lines[i].description || (it ? pickLabel(it.names, locale) : ''''),' tax_code_id: lines[i].tax_code_id ?? it?.tax_code_id ?? null, }); } const unitOptions = (l: TradeLineInput) => { const it = items.data?.find((x) => x.id === l.item_id); const ids = it ? [it.base_uom_id, ...it.uoms.filter((u) => u.is_active).map((u) => u.uom_id)] : (uoms.data ?? []).map((u) => u.id); return ids.map((id) => ({ value: id, label: uoms.data?.find((u) => u.id === id)?.key ?? id })); };
async function run(submit: boolean) { setBusy(true); setError(''); try { const cur = await save.mutateAsync({ id: saved?.document.id, revision: saved?.document.revision, body: { ...h, lines }, }); setSaved(cur); if (submit) { const edge = cur.available_transitions.find( (tr) => tr.allowed && tr.gate === 'reporter' && !tr.requires_reason, ); if (edge) await move.mutateAsync({ id: cur.document.id, from: cur.document.state, to: edge.to }); } p.onDone(cur.document.id); } catch (e) { setError(problemMessage(e, tc)); } finally { setBusy(false); } }
const title = doc ? t(`kinds.${p.k.kind}.edit`) : t(`kinds.${p.k.kind}.new`); return ( <Dialog open onOpenChange={(o: boolean) => !o && p.onClose()} title={title} footer={ <> <Button variant="outline" onClick={() => void run(false)} disabled={busy}> {t('saveDraft')} </Button> <Button onClick={() => void run(true)} loading={busy}> {t('submit')} </Button> </> } > <div className="grid gap-4 md:grid-cols-2"> {sourceKind && !doc && ( <Field label={t('startFrom', { kind: t(`kinds.${sourceKind.kind}.one`) })} id="td-src"> <Select id="td-src" aria-label={t('startFrom', { kind: t(`kinds.${sourceKind.kind}.one`) })} value={undefined} onValueChange={(x: string) => void startFrom(x)} options={(sources.data ?? []).map((s) => ({ value: s.id, label: s.number }))} /> </Field> )} {p.k.party && ( <Field label={t(`columns.${p.k.party}`)} id="td-party"> <Select id="td-party" aria-label={t(`columns.${p.k.party}`)} value={h.party_id ?? undefined} onValueChange={(x: string) => setH((s) => ({ ...s, party_id: x, currency: parties.data?.find((y) => y.id === x)?.currency ?? s.currency, })) } options={(parties.data ?? []).filter((x) => x.active).map((x) => ({ value: x.id, label: x.label }))} /> </Field> )} <Field label={t('fields.currency')} id="td-cur"> <Input id="td-cur" maxLength={3} className="uppercase" value={h.currency} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, currency: e.target.value.toUpperCase() })) } /> </Field> {h.currency !== base && ( <Field label={t('fields.rate', { base })} id="td-rate"> <Input id="td-rate" inputMode="decimal" value={h.fx_rate ?? ''} onChange={(e: ChangeEvent<HTMLInputElement>) => setH((s) => ({ ...s, fx_rate: e.target.value || null })) } /> </Field> )} <Field label={t(`due.${p.k.kind}`)} id="td-due"> <DatePicker id="td-due" value={h.due_date ?? undefined} onChange={(x?: string) => setH((s) => ({ ...s, due_date: x ?? null }))} /> </Field> </div> <section aria-label={t('lines.title')} className="mt-4 flex flex-col gap-3"> <h3 className="font-bold text-fg">{t('lines.title')}</h3> {lines.map((l, i) => ( <div key={i} className="grid gap-2 rounded-card border border-border-subtle p-3 md:grid-cols-6"> <Field label={t('lines.item')} id={`tl-i-${i}`}> <Select id={`tl-i-${i}`} aria-label={t('lines.item')} value={l.item_id ?? NONE} onValueChange={(x: string) => (x === NONE ? setLine(i, { item_id: null }) : pickItem(i, x))} options={[ { value: NONE, label: t('lines.textLine') }, ...(items.data ?? []) .filter((it) => it.is_active) .map((it) => ({ value: it.id, label: `${it.code} · ${pickLabel(it.names, locale)}` })), ]} className="w-full min-w-0" /> </Field> <div className="md:col-span-2"> <Field label={t('lines.description')} id={`tl-d-${i}`}> <Input id={`tl-d-${i}`} value={l.description} onChange={(e: ChangeEvent<HTMLInputElement>) => setLine(i, { description: e.target.value })} /> </Field> </div> <Field label={t('lines.quantity')} id={`tl-q-${i}`}> <Input id={`tl-q-${i}`} inputMode="decimal" value={l.quantity} onChange={(e: ChangeEvent<HTMLInputElement>) => setLine(i, { quantity: e.target.value })} /> </Field> <Field label={t('lines.unit')} id={`tl-u-${i}`}> <Select id={`tl-u-${i}`} aria-label={t('lines.unit')} value={l.uom_id || undefined} onValueChange={(x: string) => setLine(i, { uom_id: x })} options={unitOptions(l)} className="w-full min-w-0" /> </Field> <Field label={t('lines.price')} id={`tl-p-${i}`}> <MoneyInput id={`tl-p-${i}`} aria-label={t('lines.price')} value={l.unit_price} currency={h.currency} onChange={(x) => setLine(i, { unit_price: x })} /> </Field> <Field label={t('lines.taxCode')} id={`tl-t-${i}`}> <Select id={`tl-t-${i}`} aria-label={t('lines.taxCode')} value={l.tax_code_id ?? NONE} onValueChange={(x: string) => setLine(i, { tax_code_id: x === NONE ? null : x })} options={[ { value: NONE, label: t('lines.noTax') }, ...(taxCodes.data ?? []).map((c) => ({ value: c.id, label: `${c.code} · ${c.label}` })), ]} className="w-full min-w-0" /> </Field> {lines.length > 1 && ( <IconButton icon={X} label={t('lines.remove')} onClick={() => setLines((ls) => ls.filter((_, j) => j !== i))} /> )} </div> ))} <Button variant="outline" onClick={() => setLines((ls) => [...ls, blank()])}> {t('lines.add')} </Button> </section> {error && ( <p role="alert" className="mt-3 text-caption text-tone-red-fg"> {error} </p> )} </Dialog> );}Create web/src/features/trade/StockDialog.tsx:
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { useItems, useLocations } from '@/features/inventory/api';import { problemMessage } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';import { pickLabel, useLocale } from '@/lib/i18n';import { useCreateStock, useDocument, useDocuments, useTransition } from './api';import { KINDS, trimQty, type KindConfig } from './kinds';
type Take = { qty: string; lots: string; expiry: string };
/** Serials as a comma list (one unit each); a batch as one number with an optional expiry (R-O3-18). */function lotsOf(tracking: string, qty: string, t: Take): Schemas['StockLot'][] | undefined { if (tracking === 'serial') return t.lots .split(',') .map((s) => s.trim()) .filter(Boolean) .map((s) => ({ serial_no: s, quantity: '1' })); if (tracking === 'batch' && t.lots.trim()) return [{ batch_no: t.lots.trim(), expires_on: t.expiry || null, quantity: qty }]; return undefined;}
/** A receipt, return or delivery: drawn from its source's open lines, in base units, at a location. */export function StockDialog(p: { k: KindConfig; sourceId?: string; onClose(): void; onDone(id: string): void }) { const { t } = useTranslation('trade'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const src = KINDS[p.k.source!]; const sources = useDocuments(src, true); const [sourceId, setSourceId] = useState(p.sourceId ?? ''); const source = useDocument(src, sourceId || undefined); const locations = useLocations(); const items = useItems(); const create = useCreateStock(p.k); const move = useTransition(p.k); const [locationId, setLocationId] = useState(''); const [take, setTake] = useState<Record<string, Take>>({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const open = (source.data?.document.lines ?? []).filter((l) => l.item_id && Number(l.open_quantity) > 0); const trackingOf = (id: string | null) => items.data?.find((x) => x.id === id)?.tracking ?? 'none';
async function run(post: boolean) { setBusy(true); setError(''); try { const lines = open .filter((l) => take[l.id]?.qty) .map((l) => { const x = take[l.id]; const lots = lotsOf(trackingOf(l.item_id), x.qty, x); return { source_line_id: l.id, quantity: x.qty, ...(lots ? { lots } : {}) }; }); const v = await create.mutateAsync({ source_document_id: sourceId, location_id: locationId, lines }); if (post) { const edge = v.available_transitions.find((tr) => tr.allowed && tr.gate === 'reporter' && !tr.requires_reason); if (edge) await move.mutateAsync({ id: v.document.id, from: v.document.state, to: edge.to }); } p.onDone(v.document.id); } catch (e) { setError(problemMessage(e, tc)); } finally { setBusy(false); } }
return ( <Dialog open onOpenChange={(o: boolean) => !o && p.onClose()} title={t(`kinds.${p.k.kind}.new`)} footer={ <> <Button variant="outline" onClick={() => void run(false)} disabled={busy}> {t('saveDraft')} </Button> <Button onClick={() => void run(true)} loading={busy}> {t(`post.${p.k.kind}`)} </Button> </> } > <div className="grid gap-4 md:grid-cols-2"> <Field label={t(`kinds.${src.kind}.one`)} id="sd-src"> <Select id="sd-src" aria-label={t(`kinds.${src.kind}.one`)} value={sourceId || undefined} onValueChange={(x: string) => { setSourceId(x); setTake({}); }} options={(sources.data ?? []).map((d) => ({ value: d.id, label: d.number }))} /> </Field> <Field label={t('fields.location')} id="sd-loc"> <Select id="sd-loc" aria-label={t('fields.location')} value={locationId || undefined} onValueChange={setLocationId} options={(locations.data ?? []) .filter((l) => l.is_active) .map((l) => ({ value: l.id, label: `${l.code} · ${pickLabel(l.names, locale)}` }))} /> </Field> </div> <p className="mt-3 text-caption text-fg-muted">{t('baseUnits')}</p> <section aria-label={t('lines.title')} className="mt-2 flex flex-col gap-3"> {open.map((l) => { const x = take[l.id] ?? { qty: '', lots: '', expiry: '' }; const set = (patch: Partial<Take>) => setTake((s) => ({ ...s, [l.id]: { ...x, ...patch } })); const tracking = trackingOf(l.item_id); return ( <div key={l.id} className="grid gap-2 rounded-card border border-border-subtle p-3 md:grid-cols-4"> <div className="md:col-span-2"> <p className="font-medium">{l.description}</p> <p className="text-caption text-fg-muted">{t('lines.open', { qty: trimQty(l.open_quantity) })}</p> </div> <Field label={t('lines.quantity')} id={`sq-${l.id}`}> <Input id={`sq-${l.id}`} aria-label={`${t('lines.quantity')} ${l.line_no}`} inputMode="decimal" value={x.qty} onChange={(e: ChangeEvent<HTMLInputElement>) => set({ qty: e.target.value })} /> </Field> {tracking !== 'none' && ( <Field label={t(`lots.${tracking}`)} id={`sl-${l.id}`}> <Input id={`sl-${l.id}`} value={x.lots} onChange={(e: ChangeEvent<HTMLInputElement>) => set({ lots: e.target.value })} /> </Field> )} {tracking === 'batch' && ( <Field label={t('lots.expiry')} id={`se-${l.id}`}> <DatePicker id={`se-${l.id}`} value={x.expiry || undefined} onChange={(v?: string) => set({ expiry: v ?? '' })} /> </Field> )} </div> ); })} </section> {error && ( <p role="alert" className="mt-3 text-caption text-tone-red-fg"> {error} </p> )} </Dialog> );}Create web/src/features/trade/ChainView.tsx:
import { useTranslation } from 'react-i18next';import { StatusChip } from '@/components/workflow/StatusChip';import { useCan } from '@/features/auth/session';import { useChain } from './api';import type { KindConfig } from './kinds';
/** The document's ancestors and descendants, and the invoices on them (R-O3-16). */export function ChainView(p: { k: KindConfig; id: string }) { const { t } = useTranslation('trade'); const canRead = useCan(p.k.module === 'procurement' ? 'procurement.order.read' : 'sales.order.read'); const chain = useChain(p.k, p.id, canRead); const v = chain.data; if (!v || (v.documents.length < 2 && v.invoices.length === 0)) return null; return ( <section aria-labelledby="td-chain" className="rounded-card border border-border-subtle bg-surface p-4"> <h3 id="td-chain" className="mb-2 font-bold text-fg"> {t('chain.title')} </h3> <ol className="flex flex-col gap-2 text-body"> {v.documents.map((n) => ( <li key={n.id} className="flex items-center justify-between gap-2"> <span className={n.id === p.id ? 'font-bold' : undefined}> {t(`kinds.${n.kind}.one`)} · {n.number} </span> <StatusChip kind={n.kind} state={n.state} semantic={n.semantic} /> </li> ))} {v.invoices.map((i) => ( <li key={i.id} className="flex items-center justify-between gap-2"> <span> {t(`invoiceKinds.${i.kind}`)} · {i.reference || t('chain.draft')} </span> <StatusChip kind={i.kind} state={i.state} /> </li> ))} </ol> </section> );}Create web/src/features/trade/DocumentDrawer.tsx:
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Drawer } from '@/components/ui/Drawer';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Skeleton } from '@/components/ui/Skeleton';import { toast } from '@/components/ui/Toaster';import { MoneyText } from '@/components/money/MoneyText';import { DecisionTimeline } from '@/components/workflow/DecisionTimeline';import { StatusChip } from '@/components/workflow/StatusChip';import { TransitionBar } from '@/components/workflow/TransitionBar';import { useCan, useMe } from '@/features/auth/session';import { useUoms } from '@/features/inventory/api';import { PdfButton } from '@/features/printing/PdfButton';import { problemMessage } from '@/lib/api/problem';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { pdfPath, useCloseDocument, useDocument, useParties, useTransition } from './api';import { ChainView } from './ChainView';import { KINDS, trimQty, type KindConfig } from './kinds';
const NOT_LIVE = ['draft', 'pending', 'rejected', 'cancelled'];
export function DocumentDrawer(p: { k: KindConfig; id: string; onClose(): void; onEdit(): void; onNext(next: KindConfig, sourceId: string): void;}) { const { t } = useTranslation('trade'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const me = useMe().data; const q = useDocument(p.k, p.id); const move = useTransition(p.k); const close = useCloseDocument(p.k); const parties = useParties(p.k.party); const uoms = useUoms(); const next = p.k.next ? KINDS[p.k.next] : undefined; const canCreate = useCan(p.k.create); const canTransition = useCan(p.k.transition); const canNext = useCan(next?.create ?? p.k.create); const [closing, setClosing] = useState(false); const [reason, setReason] = useState(''); const v = q.data; const d = v?.document; const live = !!d && !NOT_LIVE.includes(d.semantic) && !d.closed && !d.expired; const party = d?.party_id ? parties.data?.find((x) => x.id === d.party_id)?.label : undefined; async function doClose() { if (!d) return; try { await close.mutateAsync({ id: d.id, reason }); toast.success(t('close.done')); setClosing(false); } catch (e) { toast.error(problemMessage(e, tc)); } } return ( <Drawer open onOpenChange={(o: boolean) => !o && p.onClose()} title={t(`kinds.${p.k.kind}.one`)} footer={ d?.semantic === 'draft' && !p.k.stock && canCreate && d.prepared_by === me?.member_id ? ( <Button variant="outline" className="w-full" onClick={p.onEdit}> {t('edit')} </Button> ) : undefined } > {!v || !d ? ( <Skeleton className="h-96 w-full" /> ) : ( <div className="flex flex-col gap-4"> <div className="flex items-start justify-between gap-2"> <h3 className="text-subtitle font-bold text-fg">{d.number}</h3> <StatusChip kind={d.kind} state={d.state} semantic={d.semantic} /> </div> <p className="text-caption text-fg-muted"> {party ? `${party} · ` : ''} {formatDate(d.document_date, locale)} {d.due_date ? ` · ${t(`due.${d.kind}`)} ${formatDate(d.due_date, locale)}` : ''} </p> {d.expired && <p className="text-caption text-tone-red-fg">{t('expired')}</p>} {d.closed && ( <p className="text-caption text-fg-muted"> {d.close_reason ? t('closedBecause', { reason: d.close_reason }) : t('fulfilled')} </p> )} <div className="rounded-card bg-bg-subtle p-4"> <MoneyText className="text-subtitle font-bold" amount={d.net_total} currency={d.currency} /> </div> <table className="w-full text-body"> <thead> <tr className="text-left text-caption text-fg-muted"> <th>{t('lines.description')}</th> <th>{t('lines.quantity')}</th> {next && <th>{t('lines.openShort')}</th>} <th className="text-right">{t('lines.amount')}</th> </tr> </thead> <tbody> {d.lines.map((l) => ( <tr key={l.id}> <td>{l.description}</td> <td> {trimQty(l.quantity)} {uoms.data?.find((u) => u.id === l.uom_id)?.key} </td> {next && <td>{trimQty(l.open_quantity)}</td>} <td className="text-right"> <MoneyText amount={l.net_amount} currency={d.currency} /> </td> </tr> ))} </tbody> </table> <TransitionBar kind={d.kind} transitions={v.available_transitions} onTake={(tr, r) => move.mutateAsync({ id: d.id, from: d.state, to: tr.to, ...(r ? { reason: r } : {}) })} /> <div className="flex flex-wrap gap-2"> {p.k.pdf && d.semantic !== 'draft' && <PdfButton path={pdfPath(p.k, d.id)} fileName={`${d.number}.pdf`} />} {next && live && canNext && ( <Button variant="outline" onClick={() => p.onNext(next, d.id)}> {t(`kinds.${next.kind}.new`)} </Button> )} {p.k.closable && live && canTransition && ( <Button variant="outline" onClick={() => setClosing(true)}> {t('close.action')} </Button> )} </div> {closing && ( <div className="flex flex-col gap-2 rounded-card border border-border-subtle p-3"> <Field label={t('close.reason')} id="td-close"> <Input id="td-close" value={reason} onChange={(e: ChangeEvent<HTMLInputElement>) => setReason(e.target.value)} /> </Field> <Button disabled={!reason.trim()} onClick={() => void doClose()}> {t('close.confirm')} </Button> </div> )} <ChainView k={p.k} id={d.id} /> <DecisionTimeline kind={d.kind} currentState={d.state} decisions={v.decisions} /> </div> )} </Drawer> );}Create web/src/features/trade/ReportsPage.tsx:
import { useMemo, useState } from 'react';import { useTranslation } from 'react-i18next';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { PageHeader } from '@/components/ui/PageHeader';import { Tabs } from '@/components/ui/Tabs';import { MoneyText } from '@/components/money/MoneyText';import { hasPermission, useMe } from '@/features/auth/session';import type { Permission, Schemas } from '@/lib/api/types';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { useReport } from './api';import { trimQty } from './kinds';
type ReportKey = 'open_purchase_orders' | 'grni_aging' | 'open_sales_orders' | 'delivered_not_invoiced';const REPORTS: { key: ReportKey; path: string; permission: Permission }[] = [ { key: 'open_purchase_orders', path: '/procurement/reports/open-orders', permission: 'procurement.order.read' }, { key: 'grni_aging', path: '/procurement/reports/grni-aging', permission: 'procurement.receipt.read' }, { key: 'open_sales_orders', path: '/sales/reports/open-orders', permission: 'sales.order.read' }, { key: 'delivered_not_invoiced', path: '/sales/reports/delivered-not-invoiced', permission: 'sales.delivery.read' },];type OpenRow = Schemas['OpenOrderRow'];type GrniRow = Schemas['GrniAgingRow'];type UninvoicedRow = Schemas['UninvoicedDeliveryRow'];
/** Operations → Reports (spec O3.3): what is still to receive, deliver and invoice. */export function ReportsPage() { const { t } = useTranslation('trade'); const [locale] = useLocale(); const me = useMe().data; const base = me?.organization.base_currency ?? 'MYR'; const allowed = REPORTS.filter((r) => hasPermission(me?.permissions ?? [], r.permission)); const [key, setKey] = useState<ReportKey | undefined>(allowed[0]?.key); const active = allowed.find((r) => r.key === (key ?? allowed[0]?.key)); const orders = useReport<OpenRow>(active?.path ?? '', active?.key === 'open_purchase_orders' || active?.key === 'open_sales_orders'); const grni = useReport<GrniRow>(active?.path ?? '', active?.key === 'grni_aging'); const uninvoiced = useReport<UninvoicedRow>(active?.path ?? '', active?.key === 'delivered_not_invoiced'); const c = (k: string) => t(`reports.columns.${k}`); const orderCols = useMemo<ColumnDef<OpenRow>[]>( () => [ { id: 'number', header: c('number'), cell: ({ row }) => row.original.number }, { id: 'party', header: c('party'), cell: ({ row }) => row.original.party_name }, { id: 'due', header: c('due'), cell: ({ row }) => row.original.due_date ? `${formatDate(row.original.due_date, locale)}${row.original.late ? ` · ${t('reports.late')}` : ''}` : '–', }, { id: 'description', header: c('description'), cell: ({ row }) => row.original.description }, { id: 'open', header: c('open'), cell: ({ row }) => trimQty(row.original.open) }, { id: 'amount', header: c('amount'), cell: ({ row }) => <MoneyText amount={row.original.open_amount} currency={row.original.currency} />, }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [t, locale], ); const grniCols = useMemo<ColumnDef<GrniRow>[]>( () => [ { id: 'number', header: c('number'), cell: ({ row }) => row.original.number }, { id: 'party', header: c('party'), cell: ({ row }) => row.original.supplier_name }, { id: 'received', header: c('received'), cell: ({ row }) => formatDate(row.original.received_on, locale) }, { id: 'age', header: c('age'), cell: ({ row }) => row.original.age_days }, { id: 'description', header: c('description'), cell: ({ row }) => row.original.description }, { id: 'outstanding', header: c('outstanding'), cell: ({ row }) => trimQty(row.original.outstanding) }, { id: 'amount', header: c('amount'), cell: ({ row }) => <MoneyText amount={row.original.outstanding_base} currency={base} />, }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [t, locale, base], ); const uninvoicedCols = useMemo<ColumnDef<UninvoicedRow>[]>( () => [ { id: 'number', header: c('number'), cell: ({ row }) => row.original.number }, { id: 'party', header: c('party'), cell: ({ row }) => row.original.customer_name }, { id: 'delivered', header: c('delivered'), cell: ({ row }) => formatDate(row.original.delivered_on, locale) }, { id: 'description', header: c('description'), cell: ({ row }) => row.original.description }, { id: 'uninvoiced', header: c('uninvoiced'), cell: ({ row }) => trimQty(row.original.uninvoiced) }, { id: 'amount', header: c('amount'), cell: ({ row }) => <MoneyText amount={row.original.uninvoiced_amount} currency={row.original.currency} />, }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [t, locale], ); const empty = <EmptyState title={t('reports.empty')} description={t('reports.subtitle')} />; return ( <div className="flex flex-col gap-4"> <PageHeader title={t('reports.title')} subtitle={t('reports.subtitle')} /> <Tabs value={active?.key ?? ''} onValueChange={(x) => setKey(x as ReportKey)} items={allowed.map((r) => ({ value: r.key, label: t(`reports.${r.key}`) }))} /> {(active?.key === 'open_purchase_orders' || active?.key === 'open_sales_orders') && ( <DataTable tableId={`trade.report.${active.key}`} columns={orderCols} data={orders.data ?? []} loading={orders.isLoading} getRowId={(r: OpenRow) => `${r.document_id}:${r.line_no}`} empty={empty} /> )} {active?.key === 'grni_aging' && ( <DataTable tableId="trade.report.grni_aging" columns={grniCols} data={grni.data ?? []} loading={grni.isLoading} getRowId={(r: GrniRow) => `${r.receipt_id}:${r.line_no}`} empty={empty} /> )} {active?.key === 'delivered_not_invoiced' && ( <DataTable tableId="trade.report.delivered_not_invoiced" columns={uninvoicedCols} data={uninvoiced.data ?? []} loading={uninvoiced.isLoading} getRowId={(r: UninvoicedRow) => `${r.delivery_id}:${r.line_no}`} empty={empty} /> )} </div> );}Create web/src/features/trade/routes.tsx:
import { createRoute, Outlet, type AnyRoute } from '@tanstack/react-router';import { appRoute } from '@/app/layouts';import { RequirePermission } from '@/features/auth/session';import { useDocument } from './api';import { DocumentDialog } from './DocumentDialog';import { DocumentDrawer } from './DocumentDrawer';import { DocumentsPage } from './DocumentsPage';import { KIND_LIST, type KindConfig } from './kinds';import { ReportsPage } from './ReportsPage';import { StockDialog } from './StockDialog';
// One route family per kind, built from its KindConfig; navigation is by href because the// paths are data, not literals.function family(k: KindConfig): AnyRoute { const list = createRoute({ getParentRoute: () => appRoute, path: k.path.slice(1), component: function R() { const navigate = list.useNavigate(); return ( <RequirePermission permission={k.read}> <DocumentsPage k={k} onOpen={(id) => void navigate({ href: `${k.path}/${id}` })} onNew={() => void navigate({ href: `${k.path}/new` })} > <Outlet /> </DocumentsPage> </RequirePermission> ); }, }); const create = createRoute({ getParentRoute: () => list, path: 'new', validateSearch: (s: Record<string, unknown>) => ({ from: typeof s.from === 'string' ? s.from : undefined }), component: function R() { const navigate = create.useNavigate(); const { from } = create.useSearch() as { from?: string }; const back = () => void navigate({ href: k.path }); const done = (id: string) => void navigate({ href: `${k.path}/${id}` }); return k.stock ? ( <StockDialog k={k} sourceId={from} onClose={back} onDone={done} /> ) : ( <DocumentDialog k={k} sourceId={from} onClose={back} onDone={done} /> ); }, }); const one = createRoute({ getParentRoute: () => list, path: '$id', component: function R() { const { id } = one.useParams() as { id: string }; const navigate = one.useNavigate(); return ( <DocumentDrawer k={k} id={id} onClose={() => void navigate({ href: k.path })} onEdit={() => void navigate({ href: `${k.path}/${id}/edit` })} onNext={(next, sourceId) => void navigate({ href: `${next.path}/new?from=${sourceId}` })} /> ); }, }); const edit = createRoute({ getParentRoute: () => list, path: '$id/edit', component: function R() { const { id } = edit.useParams() as { id: string }; const navigate = edit.useNavigate(); const v = useDocument(k, id); const back = () => void navigate({ href: `${k.path}/${id}` }); return v.data ? <DocumentDialog k={k} view={v.data} onClose={back} onDone={back} /> : null; }, }); return list.addChildren([create, edit, one]);}
const reportsRoute = createRoute({ getParentRoute: () => appRoute, path: 'operations/reports', component: function R() { return ( <RequirePermission permission={['procurement.order.read', 'procurement.receipt.read', 'sales.order.read', 'sales.delivery.read']} > <ReportsPage /> </RequirePermission> ); },});
export const routes: AnyRoute[] = [...KIND_LIST.map(family), reportsRoute];In web/src/app/routes.ts, add import { routes as tradeRoutes } from '@/features/trade/routes'; with the other imports and ...tradeRoutes, as the last entry of appRoutes.
- Step 5: The operations sidebar group
In web/src/app/shell/nav.ts:
(a) export type NavGroup = 'workspace' | 'requests' | 'approvals' | 'accounting' | 'operations' | 'manage'; and insert 'operations', after 'accounting', in NAV_GROUPS.
(b) Add ClipboardList, FileText, Handshake, PackageCheck, RotateCcw, ShoppingCart, Undo2, to the lucide-react import (alphabetical where it is).
(c) Change O1’s two inventory entries (to: '/inventory/items' and to: '/inventory/locations') from group: 'accounting' to group: 'operations' (O1-17: “until O3 adds an operations group”).
(d) Append, before the closing ]; of navItems:
{ group: 'operations', to: '/procurement/requisitions', labelKey: 'trade:nav.purchase_requisition', icon: ClipboardList, permission: 'procurement.requisition.read', }, { group: 'operations', to: '/procurement/orders', labelKey: 'trade:nav.purchase_order', icon: ShoppingCart, permission: 'procurement.order.read', }, { group: 'operations', to: '/procurement/receipts', labelKey: 'trade:nav.goods_receipt', icon: PackageCheck, permission: 'procurement.receipt.read', }, { group: 'operations', to: '/procurement/returns', labelKey: 'trade:nav.purchase_return', icon: Undo2, permission: 'procurement.receipt.read', }, { group: 'operations', to: '/sales/quotations', labelKey: 'trade:nav.quotation', icon: FileText, permission: 'sales.quotation.read', }, { group: 'operations', to: '/sales/orders', labelKey: 'trade:nav.sales_order', icon: Handshake, permission: 'sales.order.read', }, { group: 'operations', to: '/sales/deliveries', labelKey: 'trade:nav.delivery_order', icon: Truck, permission: 'sales.delivery.read', }, { group: 'operations', to: '/sales/returns', labelKey: 'trade:nav.sales_return', icon: RotateCcw, permission: 'sales.delivery.read', }, { group: 'operations', to: '/operations/reports', labelKey: 'trade:nav.reports', icon: ChartColumnBig, permission: ['procurement.order.read', 'procurement.receipt.read', 'sales.order.read', 'sales.delivery.read'], },In each shell.json, add to "groups" after "accounting": en "operations": "Operations", ms "operations": "Operasi", zh "operations": "运营".
- Step 6: The copy, in three languages
Create web/src/lib/i18n/locales/en/trade.json:
{ "nav": { "purchase_requisition": "Requisitions", "purchase_order": "Purchase orders", "goods_receipt": "Goods receipts", "purchase_return": "Purchase returns", "quotation": "Quotations", "sales_order": "Sales orders", "delivery_order": "Deliveries", "sales_return": "Sales returns", "reports": "Operations reports" }, "kinds": { "purchase_requisition": { "one": "Purchase requisition", "title": "Purchase requisitions", "subtitle": "Ask for goods or services. Your superior approves; Finance orders.", "new": "New requisition", "edit": "Edit requisition", "empty": "No requisitions yet" }, "purchase_order": { "one": "Purchase order", "title": "Purchase orders", "subtitle": "What you have ordered from suppliers, approved by the Owner.", "new": "New purchase order", "edit": "Edit purchase order", "empty": "No purchase orders yet" }, "goods_receipt": { "one": "Goods receipt", "title": "Goods receipts", "subtitle": "What arrived against a purchase order. Whoever approved the order cannot receive it.", "new": "New goods receipt", "edit": "Edit goods receipt", "empty": "No goods receipts yet" }, "purchase_return": { "one": "Purchase return", "title": "Purchase returns", "subtitle": "Goods sent back to a supplier from a receipt.", "new": "New purchase return", "edit": "Edit purchase return", "empty": "No purchase returns yet" }, "quotation": { "one": "Quotation", "title": "Quotations", "subtitle": "Offers to customers, valid until a date.", "new": "New quotation", "edit": "Edit quotation", "empty": "No quotations yet" }, "sales_order": { "one": "Sales order", "title": "Sales orders", "subtitle": "What customers have ordered, confirmed by the Owner.", "new": "New sales order", "edit": "Edit sales order", "empty": "No sales orders yet" }, "delivery_order": { "one": "Delivery", "title": "Deliveries", "subtitle": "Goods sent to customers against a sales order.", "new": "New delivery", "edit": "Edit delivery", "empty": "No deliveries yet" }, "sales_return": { "one": "Sales return", "title": "Sales returns", "subtitle": "Goods a customer sent back from a delivery.", "new": "New sales return", "edit": "Edit sales return", "empty": "No sales returns yet" } }, "columns": { "number": "Number", "supplier": "Supplier", "customer": "Customer", "date": "Date", "total": "Total", "status": "Status" }, "due": { "purchase_requisition": "Needed by", "purchase_order": "Delivery date", "goods_receipt": "Due", "purchase_return": "Due", "quotation": "Valid until", "sales_order": "Delivery date", "delivery_order": "Due", "sales_return": "Due" }, "post": { "goods_receipt": "Receive", "purchase_return": "Return", "delivery_order": "Deliver", "sales_return": "Receive back" }, "fields": { "currency": "Currency", "rate": "Exchange rate to {{base}}", "location": "Location" }, "lines": { "title": "Lines", "item": "Item", "textLine": "No item (text line)", "description": "Description", "quantity": "Quantity", "unit": "Unit", "price": "Unit price", "taxCode": "Tax code", "noTax": "No tax", "amount": "Amount", "open": "{{qty}} open", "openShort": "Open", "add": "Add line", "remove": "Remove line" }, "lots": { "serial": "Serial numbers, separated by commas", "batch": "Batch number", "expiry": "Batch expiry" }, "baseUnits": "Quantities are in each item's base unit.", "startFrom": "Start from a {{kind}}", "saveDraft": "Save draft", "submit": "Submit", "edit": "Edit draft", "expired": "This quotation's validity has passed. Issue a new one.", "fulfilled": "Fulfilled: nothing is left open.", "closedBecause": "Closed: {{reason}}", "close": { "action": "Close", "reason": "Why close it?", "confirm": "Close the document", "done": "Closed" }, "chain": { "title": "Document chain", "draft": "draft" }, "invoiceKinds": { "purchase_invoice": "Purchase invoice", "supplier_credit_note": "Supplier credit note", "sales_invoice": "Sales invoice", "customer_credit_note": "Customer credit note" }, "reports": { "title": "Operations reports", "subtitle": "What is still to receive, deliver and invoice.", "open_purchase_orders": "Open purchase orders", "grni_aging": "Goods received not invoiced", "open_sales_orders": "Open sales orders", "delivered_not_invoiced": "Delivered not invoiced", "late": "Backorder", "empty": "Nothing outstanding.", "columns": { "number": "Number", "party": "Party", "due": "Due", "description": "Description", "open": "Open", "amount": "Amount", "received": "Received", "age": "Age (days)", "outstanding": "Outstanding", "delivered": "Delivered", "uninvoiced": "Not invoiced" } }}Create web/src/lib/i18n/locales/ms/trade.json:
{ "nav": { "purchase_requisition": "Permintaan pembelian", "purchase_order": "Pesanan belian", "goods_receipt": "Penerimaan barang", "purchase_return": "Pemulangan belian", "quotation": "Sebut harga", "sales_order": "Pesanan jualan", "delivery_order": "Penghantaran", "sales_return": "Pemulangan jualan", "reports": "Laporan operasi" }, "kinds": { "purchase_requisition": { "one": "Permintaan pembelian", "title": "Permintaan pembelian", "subtitle": "Minta barang atau perkhidmatan. Penyelia anda meluluskan; Kewangan membuat pesanan.", "new": "Permintaan baharu", "edit": "Sunting permintaan", "empty": "Belum ada permintaan" }, "purchase_order": { "one": "Pesanan belian", "title": "Pesanan belian", "subtitle": "Apa yang dipesan daripada pembekal, diluluskan oleh Pemilik.", "new": "Pesanan belian baharu", "edit": "Sunting pesanan belian", "empty": "Belum ada pesanan belian" }, "goods_receipt": { "one": "Penerimaan barang", "title": "Penerimaan barang", "subtitle": "Barang yang tiba bagi pesanan belian. Sesiapa yang meluluskan pesanan tidak boleh menerimanya.", "new": "Penerimaan barang baharu", "edit": "Sunting penerimaan barang", "empty": "Belum ada penerimaan barang" }, "purchase_return": { "one": "Pemulangan belian", "title": "Pemulangan belian", "subtitle": "Barang yang dipulangkan kepada pembekal daripada penerimaan.", "new": "Pemulangan belian baharu", "edit": "Sunting pemulangan belian", "empty": "Belum ada pemulangan belian" }, "quotation": { "one": "Sebut harga", "title": "Sebut harga", "subtitle": "Tawaran kepada pelanggan, sah sehingga sesuatu tarikh.", "new": "Sebut harga baharu", "edit": "Sunting sebut harga", "empty": "Belum ada sebut harga" }, "sales_order": { "one": "Pesanan jualan", "title": "Pesanan jualan", "subtitle": "Apa yang dipesan oleh pelanggan, disahkan oleh Pemilik.", "new": "Pesanan jualan baharu", "edit": "Sunting pesanan jualan", "empty": "Belum ada pesanan jualan" }, "delivery_order": { "one": "Penghantaran", "title": "Penghantaran", "subtitle": "Barang yang dihantar kepada pelanggan bagi pesanan jualan.", "new": "Penghantaran baharu", "edit": "Sunting penghantaran", "empty": "Belum ada penghantaran" }, "sales_return": { "one": "Pemulangan jualan", "title": "Pemulangan jualan", "subtitle": "Barang yang dipulangkan oleh pelanggan daripada penghantaran.", "new": "Pemulangan jualan baharu", "edit": "Sunting pemulangan jualan", "empty": "Belum ada pemulangan jualan" } }, "columns": { "number": "Nombor", "supplier": "Pembekal", "customer": "Pelanggan", "date": "Tarikh", "total": "Jumlah", "status": "Status" }, "due": { "purchase_requisition": "Diperlukan pada", "purchase_order": "Tarikh penghantaran", "goods_receipt": "Tarikh akhir", "purchase_return": "Tarikh akhir", "quotation": "Sah sehingga", "sales_order": "Tarikh penghantaran", "delivery_order": "Tarikh akhir", "sales_return": "Tarikh akhir" }, "post": { "goods_receipt": "Terima", "purchase_return": "Pulangkan", "delivery_order": "Hantar", "sales_return": "Terima semula" }, "fields": { "currency": "Mata wang", "rate": "Kadar pertukaran kepada {{base}}", "location": "Lokasi" }, "lines": { "title": "Baris", "item": "Item", "textLine": "Tiada item (baris teks)", "description": "Keterangan", "quantity": "Kuantiti", "unit": "Unit", "price": "Harga seunit", "taxCode": "Kod cukai", "noTax": "Tiada cukai", "amount": "Amaun", "open": "{{qty}} terbuka", "openShort": "Terbuka", "add": "Tambah baris", "remove": "Buang baris" }, "lots": { "serial": "Nombor siri, dipisahkan dengan koma", "batch": "Nombor kelompok", "expiry": "Tarikh luput kelompok" }, "baseUnits": "Kuantiti adalah dalam unit asas setiap item.", "startFrom": "Mula daripada {{kind}}", "saveDraft": "Simpan draf", "submit": "Hantar", "edit": "Sunting draf", "expired": "Tempoh sah sebut harga ini telah tamat. Keluarkan yang baharu.", "fulfilled": "Selesai: tiada apa yang masih terbuka.", "closedBecause": "Ditutup: {{reason}}", "close": { "action": "Tutup", "reason": "Mengapa ditutup?", "confirm": "Tutup dokumen", "done": "Ditutup" }, "chain": { "title": "Rantaian dokumen", "draft": "draf" }, "invoiceKinds": { "purchase_invoice": "Invois belian", "supplier_credit_note": "Nota kredit pembekal", "sales_invoice": "Invois jualan", "customer_credit_note": "Nota kredit pelanggan" }, "reports": { "title": "Laporan operasi", "subtitle": "Apa yang masih perlu diterima, dihantar dan diinvois.", "open_purchase_orders": "Pesanan belian terbuka", "grni_aging": "Barang diterima belum diinvois", "open_sales_orders": "Pesanan jualan terbuka", "delivered_not_invoiced": "Dihantar belum diinvois", "late": "Tertunggak", "empty": "Tiada yang tertunggak.", "columns": { "number": "Nombor", "party": "Pihak", "due": "Tarikh akhir", "description": "Keterangan", "open": "Terbuka", "amount": "Amaun", "received": "Diterima", "age": "Umur (hari)", "outstanding": "Belum selesai", "delivered": "Dihantar", "uninvoiced": "Belum diinvois" } }}Create web/src/lib/i18n/locales/zh/trade.json:
{ "nav": { "purchase_requisition": "请购单", "purchase_order": "采购订单", "goods_receipt": "收货单", "purchase_return": "采购退货", "quotation": "报价单", "sales_order": "销售订单", "delivery_order": "送货单", "sales_return": "销售退货", "reports": "运营报表" }, "kinds": { "purchase_requisition": { "one": "请购单", "title": "请购单", "subtitle": "申请物品或服务,由您的上级批准,财务负责下单。", "new": "新建请购单", "edit": "编辑请购单", "empty": "暂无请购单" }, "purchase_order": { "one": "采购订单", "title": "采购订单", "subtitle": "向供应商下的订单,由负责人批准。", "new": "新建采购订单", "edit": "编辑采购订单", "empty": "暂无采购订单" }, "goods_receipt": { "one": "收货单", "title": "收货单", "subtitle": "按采购订单到货的物品。批准订单的人不能收货。", "new": "新建收货单", "edit": "编辑收货单", "empty": "暂无收货单" }, "purchase_return": { "one": "采购退货", "title": "采购退货", "subtitle": "从收货单退回给供应商的物品。", "new": "新建采购退货", "edit": "编辑采购退货", "empty": "暂无采购退货" }, "quotation": { "one": "报价单", "title": "报价单", "subtitle": "给客户的报价,有效期至某日。", "new": "新建报价单", "edit": "编辑报价单", "empty": "暂无报价单" }, "sales_order": { "one": "销售订单", "title": "销售订单", "subtitle": "客户下的订单,由负责人确认。", "new": "新建销售订单", "edit": "编辑销售订单", "empty": "暂无销售订单" }, "delivery_order": { "one": "送货单", "title": "送货单", "subtitle": "按销售订单发给客户的物品。", "new": "新建送货单", "edit": "编辑送货单", "empty": "暂无送货单" }, "sales_return": { "one": "销售退货", "title": "销售退货", "subtitle": "客户从送货单退回的物品。", "new": "新建销售退货", "edit": "编辑销售退货", "empty": "暂无销售退货" } }, "columns": { "number": "编号", "supplier": "供应商", "customer": "客户", "date": "日期", "total": "合计", "status": "状态" }, "due": { "purchase_requisition": "需要日期", "purchase_order": "交货日期", "goods_receipt": "到期", "purchase_return": "到期", "quotation": "有效期至", "sales_order": "交货日期", "delivery_order": "到期", "sales_return": "到期" }, "post": { "goods_receipt": "收货", "purchase_return": "退货", "delivery_order": "发货", "sales_return": "收回" }, "fields": { "currency": "货币", "rate": "兑 {{base}} 汇率", "location": "仓库" }, "lines": { "title": "明细", "item": "物品", "textLine": "无物品(文字行)", "description": "说明", "quantity": "数量", "unit": "单位", "price": "单价", "taxCode": "税码", "noTax": "无税", "amount": "金额", "open": "未完成 {{qty}}", "openShort": "未完成", "add": "添加明细", "remove": "删除明细" }, "lots": { "serial": "序列号,以逗号分隔", "batch": "批号", "expiry": "批次到期日" }, "baseUnits": "数量以各物品的基本单位计。", "startFrom": "从{{kind}}开始", "saveDraft": "保存草稿", "submit": "提交", "edit": "编辑草稿", "expired": "此报价单已过有效期,请重新报价。", "fulfilled": "已完成:没有未完成的数量。", "closedBecause": "已关闭:{{reason}}", "close": { "action": "关闭", "reason": "关闭原因", "confirm": "关闭单据", "done": "已关闭" }, "chain": { "title": "单据链", "draft": "草稿" }, "invoiceKinds": { "purchase_invoice": "采购发票", "supplier_credit_note": "供应商贷项通知", "sales_invoice": "销售发票", "customer_credit_note": "客户贷项通知" }, "reports": { "title": "运营报表", "subtitle": "尚待收货、发货和开票的项目。", "open_purchase_orders": "未完成采购订单", "grni_aging": "已收货未开票", "open_sales_orders": "未完成销售订单", "delivered_not_invoiced": "已发货未开票", "late": "延迟交货", "empty": "没有未完成项目。", "columns": { "number": "编号", "party": "往来方", "due": "到期", "description": "说明", "open": "未完成", "amount": "金额", "received": "收货日期", "age": "账龄(天)", "outstanding": "未结", "delivered": "发货日期", "uninvoiced": "未开票" } }}- Step 7: Run the screen tests and the checks
Run: pnpm -C web test -- features/trade app/shell lib/i18n && pnpm -C web typecheck && pnpm -C web lint
Expected: PASS (the i18n parity test sees trade.json in all three locales).
- Step 8: Commit
git add web/src/features/trade web/src/app web/src/lib/i18n/localesgit commit -m "Phase 4 O3: operations screens — documents, stock drawn from sources, chain view, reports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 12: Invoices matched on screen: the receipt and delivery pickers, the override, the tolerance, the layouts, the copy
Section titled “Task 12: Invoices matched on screen: the receipt and delivery pickers, the override, the tolerance, the layouts, the copy”Files:
- Create:
web/src/features/payables/MatchToleranceCard.tsx - Modify:
web/src/features/payables/{api.ts,InvoiceDialog.tsx,InvoiceDrawer.tsx,InvoicesPage.tsx} - Modify:
web/src/features/receivables/{api.ts,InvoiceDialog.tsx} - Modify:
web/src/features/printing/LayoutsPage.tsx - Modify: locale files
payables,receivables,printing,common,team(en, ms, zh) - Test:
web/src/features/payables/__tests__/InvoicesPage.test.tsx,web/src/features/receivables/__tests__/InvoiceDialog.test.tsx(append)
Interfaces:
-
Consumes: Task 10’s routes and generated
Schemas['PurchaseInvoiceTransition' | 'MatchTolerance' | 'MatchableLine'];trimQty(Task 11);lineNet(@/lib/money);ProblemError(@/lib/api/problem). -
Produces:
useMatchableLines(supplierId, kind),useMatchTolerance(),useSetMatchTolerance()(payables api);useMatchableSalesLines(customerId, kind)(receivables api); the refusal copy for every O3 code. -
Step 1: Write the failing screen tests
Append to web/src/features/payables/__tests__/InvoicesPage.test.tsx:
describe('three-way match', () => { const view = () => ({ invoice: invoice(), lines: [], allocations: [], outstanding: '100.0000', outstanding_base: '440.0000', decisions: [], available_transitions: [], }); const refuseOnce = (sent: unknown[]) => { let refuse = true; server.use( http.post('*/v1/payables/invoices/:id/transitions', async ({ request }) => { sent.push(await request.json()); if (refuse) { refuse = false; return HttpResponse.json( { type: 'https://erp/problems/conflict', title: 'Conflict', status: 409, detail: 'three-way match: line 1', code: 'three_way_mismatch' }, { status: 409 }, ); } return HttpResponse.json(view()); }), ); };
it('lets an approver holding the override key approve past a mismatch, with a reason (OD-3)', async () => { const sent: unknown[] = []; mockApi(); refuseOnce(sent); const { user } = await renderWithApp({ path: '/payables/invoices/i1', permissions: ['payables.invoice.read', 'payables.invoice.transition', 'payables.invoice.match_override'], }); await user.click(await screen.findByRole('button', { name: 'Approve and post' })); await user.type(await screen.findByLabelText('Why approve past the mismatch?'), 'Price rise agreed'); await user.click(screen.getByRole('button', { name: 'Override and post' })); await waitFor(() => expect(sent).toHaveLength(2)); expect(sent[1]).toMatchObject({ to: 'approved', match_override_reason: 'Price rise agreed' }); });
it('offers no override without the key', async () => { const sent: unknown[] = []; mockApi(); refuseOnce(sent); const { user } = await renderWithApp({ path: '/payables/invoices/i1', permissions: ['payables.invoice.read', 'payables.invoice.transition'], }); await user.click(await screen.findByRole('button', { name: 'Approve and post' })); await waitFor(() => expect(sent).toHaveLength(1)); expect(screen.queryByLabelText('Why approve past the mismatch?')).not.toBeInTheDocument(); });
it('matches a line to a receipt line: quantity and net filled, no account sent', async () => { const posted: { lines: Record<string, unknown>[] }[] = []; mockApi(); server.use( http.get('*/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })), http.get('*/v1/accounting/tax-codes', () => HttpResponse.json({ tax_codes: [] })), http.get('*/v1/payables/matchable-lines', () => HttpResponse.json({ lines: [ { trade_line_id: 'tl1', document_id: 'g1', document_number: 'GRN-2026-0001', line_no: 1, item_id: 'it1', description: 'Bolts', currency: 'USD', available: '4.000000', unit_price: '3.0000' }, ], }), ), http.post('*/v1/payables/invoices', async ({ request }) => { posted.push((await request.json()) as { lines: Record<string, unknown>[] }); return HttpResponse.json({ ...view(), invoice: invoice({ state: 'draft', semantic: 'draft' }) }, { status: 201 }); }), ); const { user } = await renderWithApp({ path: '/payables/invoices/new', permissions: ['payables.invoice.read', 'payables.invoice.create', 'accounting.ledger.read'], }); await user.click(await screen.findByRole('combobox', { name: 'Supplier' })); await user.click(await screen.findByRole('option', { name: /US Supplier Inc/ })); await user.click(await screen.findByRole('combobox', { name: 'Receipt line' })); await user.click(await screen.findByRole('option', { name: /GRN-2026-0001/ })); expect((screen.getByLabelText('Quantity (base units)') as HTMLInputElement).value).toBe('4.000000'); expect(screen.queryByRole('combobox', { name: 'Account' })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Save draft' })); await waitFor(() => expect(posted).toHaveLength(1)); expect(posted[0].lines[0]).toMatchObject({ trade_line_id: 'tl1', quantity: '4.000000', net_amount: '12.0000' }); expect(posted[0].lines[0]).not.toHaveProperty('account_id'); });});Append to web/src/features/receivables/__tests__/InvoiceDialog.test.tsx:
describe('delivery link (O3.2)', () => { it('bills a delivery line: quantity, price and link filled', async () => { const posted: { lines: Record<string, unknown>[] }[] = []; mockApi(); server.use( http.get('*/v1/receivables/matchable-lines', () => HttpResponse.json({ lines: [ { trade_line_id: 'dl1', document_id: 'do1', document_number: 'DO-2026-0001', line_no: 1, item_id: 'it1', description: 'Bolts', currency: 'MYR', available: '5.000000', unit_price: '9.0000' }, ], }), ), http.post('*/v1/receivables/invoices', async ({ request }) => { posted.push((await request.json()) as { lines: Record<string, unknown>[] }); return HttpResponse.json(invoice(), { status: 201 }); }), ); const { user } = await renderWithApp({ path: '/receivables/invoices/new', permissions: ['receivables.invoice.read', 'receivables.invoice.create', 'accounting.ledger.read'], }); await user.click(await screen.findByRole('combobox', { name: 'Customer' })); await user.click(await screen.findByRole('option', { name: /US Customer Inc/ })); await user.click(await screen.findByRole('combobox', { name: 'Delivery line' })); await user.click(await screen.findByRole('option', { name: /DO-2026-0001/ })); expect((screen.getByLabelText('Quantity') as HTMLInputElement).value).toBe('5.000000'); await user.click(screen.getByRole('button', { name: 'Save draft' })); await waitFor(() => expect(posted).toHaveLength(1)); expect(posted[0].lines[0]).toMatchObject({ trade_line_id: 'dl1', quantity: '5.000000', unit_price: '9.0000' }); });});- Step 2: Run them to verify they fail
Run: pnpm -C web test -- features/payables features/receivables
Expected: FAIL — no “Receipt line” / “Delivery line” combobox; no override field.
- Step 3: The API hooks
In web/src/features/payables/api.ts, change useInvoiceTransition’s mutationFn parameter type to Schemas['PurchaseInvoiceTransition'] & { id?: string }, and append:
/** A supplier's receipt (invoice) or return (credit note) lines with quantity left (O3 R-O3-4). */export function useMatchableLines(supplierId: string, kind: InvoiceKind) { return useQuery({ queryKey: ['payables', 'matchable-lines', supplierId, kind], enabled: !!supplierId, queryFn: async ({ signal }) => unwrap( await api.GET('/payables/matchable-lines', { params: { query: { supplier_id: supplierId, kind } }, signal }), ).lines, });}
export function useMatchTolerance() { return useQuery({ queryKey: ['payables', 'match-tolerance'], queryFn: async ({ signal }) => unwrap(await api.GET('/payables/match-tolerance', { signal })), });}
export function useSetMatchTolerance() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: Schemas['MatchTolerance']) => unwrap(await api.PUT('/payables/match-tolerance', { body })), onSuccess: (v) => qc.setQueryData(['payables', 'match-tolerance'], v), });}In web/src/features/receivables/api.ts, append:
/** A customer's delivery (invoice) or sales return (credit note) lines with quantity left (O3.2). */export function useMatchableSalesLines(customerId: string, kind: DocumentKind) { return useQuery({ queryKey: ['receivables', 'matchable-lines', customerId, kind], enabled: !!customerId, queryFn: async ({ signal }) => unwrap( await api.GET('/receivables/matchable-lines', { params: { query: { customer_id: customerId, kind } }, signal }), ).lines, });}- Step 4: The purchase invoice dialog matches lines
In web/src/features/payables/InvoiceDialog.tsx:
(a) Imports: add useMatchableLines to the ./api import, lineNet to the @/lib/money import, and import { trimQty } from '@/features/trade/kinds';.
(b) blankLine gains trade_line_id: null, quantity: null,; the view?.lines.map(…) initializer gains trade_line_id: l.trade_line_id, quantity: l.quantity,.
(c) Immediately after the useState that declares the header state h, add const matchable = useMatchableLines(h.supplier_id, kind);, and add const UNMATCHED = '__unmatched'; beside const NO_TAX = '__none';.
(d) In run, replace lines: selfBilled ? lines.map(…) : lines, with:
lines: lines.map((l) => { const line = selfBilled ? { ...l, classification_code: l.classification_code || SELF_BILLED_DEFAULT_CODE } : l; // A matched line is charged to GRNI by the server (R-O3-8): it sends no account. if (!line.trade_line_id) return { ...line, trade_line_id: undefined, quantity: undefined }; const matched = { ...line }; delete matched.account_id; return matched; }),(e) In the line grid, replace the whole <Field label={t('invoices.lines.account')} id={pl-a-${i}}> … </Field> block with:
{((matchable.data?.length ?? 0) > 0 || l.trade_line_id) && ( <Field label={t(`invoices.lines.match.${kind}`)} id={`pl-m-${i}`}> <Select id={`pl-m-${i}`} aria-label={t(`invoices.lines.match.${kind}`)} value={l.trade_line_id ?? UNMATCHED} onValueChange={(x: string) => { if (x === UNMATCHED) return setLine(i, { trade_line_id: null, quantity: null }); const m = matchable.data?.find((y) => y.trade_line_id === x); if (m) setTaxed(i, { trade_line_id: x, quantity: m.available, description: l.description || `${m.document_number} ${m.description}`, net_amount: lineNet(m.unit_price, m.available, '0'), }); }} options={[ { value: UNMATCHED, label: t('invoices.lines.match.none') }, ...(matchable.data ?? []).map((m) => ({ value: m.trade_line_id, label: `${m.document_number} L${m.line_no} · ${m.description} (${trimQty(m.available)})`, })), ]} className="w-full min-w-0" /> </Field> )} {l.trade_line_id ? ( <Field label={t('invoices.lines.quantity')} id={`pl-q-${i}`}> <Input id={`pl-q-${i}`} inputMode="decimal" value={l.quantity ?? ''} onChange={(e: ChangeEvent<HTMLInputElement>) => setLine(i, { quantity: e.target.value })} /> </Field> ) : ( <Field label={t('invoices.lines.account')} id={`pl-a-${i}`}> <Select id={`pl-a-${i}`} aria-label={t('invoices.lines.account')} value={l.account_id || undefined} onValueChange={(x: string) => setLine(i, { account_id: x })} options={expenseAccounts} className="w-full min-w-0" /> </Field> )}- Step 5: The drawer offers the override; the page offers the tolerance
In web/src/features/payables/InvoiceDrawer.tsx:
(a) Imports: Input (@/components/ui/Input), ProblemError alongside problemMessage from @/lib/api/problem.
(b) After const canCreate = useCan('payables.invoice.create'); add:
const canOverride = useCan('payables.invoice.match_override'); // OD-3: the edge a three-way mismatch refused, offered again with a reason. const [mismatch, setMismatch] = useState<string>(); const [overrideReason, setOverrideReason] = useState('');(c) Replace the <TransitionBar … /> element with:
<TransitionBar kind={inv.kind} transitions={v.available_transitions} onTake={async (tr, reason) => { try { return await move.mutateAsync({ from: inv.state, to: tr.to, ...(reason ? { reason } : {}) }); } catch (e) { if (e instanceof ProblemError && e.code === 'three_way_mismatch') setMismatch(tr.to); throw e; } }} /> {mismatch && canOverride && ( <section aria-labelledby="pi-match" className="rounded-card border border-border-subtle bg-surface p-4"> <h3 id="pi-match" className="mb-1 font-bold text-fg"> {t('invoices.match.title')} </h3> <p className="mb-2 text-caption text-fg-muted">{t('invoices.match.hint')}</p> <Field label={t('invoices.match.reason')} id="pi-override"> <Input id="pi-override" value={overrideReason} onChange={(e: ChangeEvent<HTMLInputElement>) => setOverrideReason(e.target.value)} /> </Field> <Button className="mt-2" disabled={!overrideReason.trim()} onClick={() => void act( () => move.mutateAsync({ from: inv.state, to: mismatch, match_override_reason: overrideReason.trim(), }), t('moved'), ).then(() => setMismatch(undefined)) } > {t('invoices.match.override')} </Button> </section> )}and change the first import line to import { useState, type ChangeEvent } from 'react';.
Create web/src/features/payables/MatchToleranceCard.tsx:
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { toast } from '@/components/ui/Toaster';import { MoneyInput } from '@/components/money/MoneyInput';import { useMe } from '@/features/auth/session';import { trimQty } from '@/features/trade/kinds';import { problemMessage } from '@/lib/api/problem';import { useMatchTolerance, useSetMatchTolerance } from './api';
/** O3 R-O3-6: how far a matched invoice's price may stray from its receipt's. */export function MatchToleranceCard() { const { t } = useTranslation('payables'); const { t: tc } = useTranslation(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const q = useMatchTolerance(); const save = useSetMatchTolerance(); const [edit, setEdit] = useState<{ percent: string; amount: string }>(); if (!q.data) return null; const tol = q.data; const cur = edit ?? { percent: trimQty(tol.percent), amount: tol.amount }; async function store() { try { await save.mutateAsync({ ...cur, revision: tol.revision }); toast.success(t('tolerance.saved')); setEdit(undefined); } catch (e) { toast.error(problemMessage(e, tc)); } } return ( <section aria-labelledby="pi-tol" className="rounded-card border border-border-subtle bg-surface p-4"> <h3 id="pi-tol" className="font-bold text-fg"> {t('tolerance.title')} </h3> <p className="mb-2 text-caption text-fg-muted">{t('tolerance.hint')}</p> <div className="grid gap-2 md:grid-cols-3"> <Field label={t('tolerance.percent')} id="tol-pct"> <Input id="tol-pct" inputMode="decimal" value={cur.percent} onChange={(e: ChangeEvent<HTMLInputElement>) => setEdit({ ...cur, percent: e.target.value })} /> </Field> <Field label={t('tolerance.amount', { base })} id="tol-amt"> <MoneyInput id="tol-amt" value={cur.amount} currency={base} onChange={(x) => setEdit({ ...cur, amount: x })} /> </Field> <Button className="self-end" onClick={() => void store()}> {t('tolerance.save')} </Button> </div> </section> );}In web/src/features/payables/InvoicesPage.tsx, import MatchToleranceCard and useCan (if not imported), add const canTolerance = useCan('org.profile.manage');, and render {canTolerance && <MatchToleranceCard />} immediately after the <PageHeader … /> element.
- Step 6: The sales invoice dialog links delivery lines
In web/src/features/receivables/InvoiceDialog.tsx:
(a) Add useMatchableSalesLines to the ./api import and import { trimQty } from '@/features/trade/kinds';.
(b) The view?.lines.map(…) initializer gains trade_line_id: l.trade_line_id,; blankLine gains trade_line_id: null,.
(c) After the customers/setLine declarations add:
const deliveries = useMatchableSalesLines(h.customer_id, kind); const UNLINKED = '__unlinked';(d) Immediately after the line’s description block (the <div className="md:col-span-2"> holding sl-d-${i}), add:
{((deliveries.data?.length ?? 0) > 0 || l.trade_line_id) && ( <Field label={t(`invoices.lines.match.${kind}`)} id={`sl-m-${i}`}> <Select id={`sl-m-${i}`} aria-label={t(`invoices.lines.match.${kind}`)} value={l.trade_line_id ?? UNLINKED} onValueChange={(x: string) => { if (x === UNLINKED) return setLine(i, { trade_line_id: null }); const m = deliveries.data?.find((y) => y.trade_line_id === x); if (m) setLine(i, { trade_line_id: x, quantity: m.available, unit_price: m.unit_price, description: l.description || `${m.document_number} ${m.description}`, }); }} options={[ { value: UNLINKED, label: t('invoices.lines.match.none') }, ...(deliveries.data ?? []).map((m) => ({ value: m.trade_line_id, label: `${m.document_number} L${m.line_no} · ${m.description} (${trimQty(m.available)})`, })), ]} className="w-full min-w-0" /> </Field> )}- Step 7: The two layouts on the designer
In web/src/features/printing/LayoutsPage.tsx, append 'purchase_order', and 'quotation', to KINDS.
- Step 8: The copy
In each payables.json, add under "invoices" → "lines": en "quantity": "Quantity (base units)", "match": { "purchase_invoice": "Receipt line", "supplier_credit_note": "Return line", "none": "Not matched" }; ms "quantity": "Kuantiti (unit asas)", "match": { "purchase_invoice": "Baris penerimaan", "supplier_credit_note": "Baris pemulangan", "none": "Tidak dipadankan" }; zh "quantity": "数量(基本单位)", "match": { "purchase_invoice": "收货明细", "supplier_credit_note": "退货明细", "none": "不匹配" }. Under "invoices" add "match": en { "title": "Three-way mismatch", "hint": "The quantity or price is outside what was received and the tolerance. As an approver who did not prepare it, you may approve it with a reason; the reason is recorded.", "reason": "Why approve past the mismatch?", "override": "Override and post" }; ms { "title": "Padanan tiga hala tidak sepadan", "hint": "Kuantiti atau harga melebihi apa yang diterima dan had toleransi. Sebagai pelulus yang tidak menyediakannya, anda boleh meluluskannya dengan sebab; sebab itu direkodkan.", "reason": "Mengapa diluluskan walaupun tidak sepadan?", "override": "Atasi dan pos" }; zh { "title": "三方匹配不符", "hint": "数量或价格超出已收货数量及容差。作为非制单人的审批人,您可以说明理由后批准;理由将被记录。", "reason": "为何在不符的情况下批准?", "override": "强制批准并过账" }. At the top level add "tolerance": en { "title": "Three-way match tolerance", "hint": "How far an invoice's price may stray from its receipt's. A line passes within either limit; 0 and 0 admit an exact match only.", "percent": "Percentage", "amount": "Amount per unit ({{base}})", "save": "Save tolerance", "saved": "Tolerance saved" }; ms { "title": "Toleransi padanan tiga hala", "hint": "Sejauh mana harga invois boleh berbeza daripada harga penerimaan. Baris lulus dalam mana-mana had; 0 dan 0 hanya menerima padanan tepat.", "percent": "Peratusan", "amount": "Amaun seunit ({{base}})", "save": "Simpan toleransi", "saved": "Toleransi disimpan" }; zh { "title": "三方匹配容差", "hint": "发票价格可偏离收货价格的范围。明细在任一限额内即通过;0 与 0 仅接受完全匹配。", "percent": "百分比", "amount": "每单位金额({{base}})", "save": "保存容差", "saved": "容差已保存" }.
In each receivables.json, add under "invoices" → "lines": en "match": { "sales_invoice": "Delivery line", "customer_credit_note": "Sales return line", "none": "Not linked" }; ms "match": { "sales_invoice": "Baris penghantaran", "customer_credit_note": "Baris pemulangan jualan", "none": "Tidak dipautkan" }; zh "match": { "sales_invoice": "送货明细", "customer_credit_note": "销售退货明细", "none": "不关联" }.
In each printing.json, add to "kinds": en "purchase_order": "Purchase orders", "quotation": "Quotations"; ms "purchase_order": "Pesanan belian", "quotation": "Sebut harga"; zh "purchase_order": "采购订单", "quotation": "报价单"; to "header": en "delivery_date": "Delivery date", "valid_until": "Valid until", "party_tin": "Supplier's or customer's TIN"; ms "delivery_date": "Tarikh penghantaran", "valid_until": "Sah sehingga", "party_tin": "TIN pembekal atau pelanggan"; zh "delivery_date": "交货日期", "valid_until": "有效期至", "party_tin": "供应商或客户税号"; to "columns": en "unit": "Unit", ms "unit": "Unit", zh "unit": "单位".
In each team.json, add to "modules": en "procurement": "Procurement", "sales": "Sales"; ms "procurement": "Perolehan", "sales": "Jualan"; zh "procurement": "采购", "sales": "销售"; and to "perm" (dots become underscores):
| key | en | ms | zh |
|---|---|---|---|
procurement_requisition_read |
Read requisitions | Baca permintaan pembelian | 查看请购单 |
procurement_requisition_create |
Raise requisitions | Buat permintaan pembelian | 创建请购单 |
procurement_requisition_transition |
Move requisitions along their workflow | Gerakkan permintaan pembelian dalam aliran kerja | 流转请购单 |
procurement_order_read |
Read purchase orders | Baca pesanan belian | 查看采购订单 |
procurement_order_create |
Prepare purchase orders | Sediakan pesanan belian | 创建采购订单 |
procurement_order_transition |
Move and close purchase orders | Gerakkan dan tutup pesanan belian | 流转与关闭采购订单 |
procurement_receipt_read |
Read receipts and purchase returns | Baca penerimaan dan pemulangan belian | 查看收货与采购退货 |
procurement_receipt_create |
Prepare receipts and purchase returns | Sediakan penerimaan dan pemulangan belian | 创建收货与采购退货 |
procurement_receipt_transition |
Post and cancel receipts and purchase returns | Pos dan batal penerimaan dan pemulangan belian | 过账与取消收货及采购退货 |
sales_quotation_read |
Read quotations | Baca sebut harga | 查看报价单 |
sales_quotation_create |
Prepare quotations | Sediakan sebut harga | 创建报价单 |
sales_quotation_transition |
Issue and cancel quotations | Keluarkan dan batal sebut harga | 发出与取消报价单 |
sales_order_read |
Read sales orders | Baca pesanan jualan | 查看销售订单 |
sales_order_create |
Prepare sales orders | Sediakan pesanan jualan | 创建销售订单 |
sales_order_transition |
Move and close sales orders | Gerakkan dan tutup pesanan jualan | 流转与关闭销售订单 |
sales_delivery_read |
Read deliveries and sales returns | Baca penghantaran dan pemulangan jualan | 查看送货与销售退货 |
sales_delivery_create |
Prepare deliveries and sales returns | Sediakan penghantaran dan pemulangan jualan | 创建送货与销售退货 |
sales_delivery_transition |
Post and cancel deliveries and sales returns | Pos dan batal penghantaran dan pemulangan jualan | 过账与取消送货及销售退货 |
payables_invoice_match_override |
Approve invoices past a three-way mismatch | Luluskan invois walaupun padanan tiga hala tidak sepadan | 三方匹配不符时批准发票 |
In each common.json, add under "refusals":
| code | en | ms | zh |
|---|---|---|---|
receiver_approved_order |
You approved this order, so someone else must receive it. | Anda meluluskan pesanan ini, jadi orang lain mesti menerimanya. | 您批准了此订单,须由他人收货。 |
invoiced_document |
An invoice is posted against this document. Void the invoice first. | Invois telah dipos terhadap dokumen ini. Batalkan invois dahulu. | 已有发票针对此单据过账,请先作废发票。 |
quotation_expired |
The quotation’s validity has passed. Issue a new one. | Tempoh sah sebut harga telah tamat. Keluarkan yang baharu. | 报价单已过有效期,请重新报价。 |
over_open_quantity |
That is more than is left open on the source line. | Itu melebihi baki terbuka pada baris sumber. | 超过来源明细的未完成数量。 |
source_not_approved |
The source document is not approved. | Dokumen sumber belum diluluskan. | 来源单据尚未批准。 |
source_closed |
The source document is closed. | Dokumen sumber telah ditutup. | 来源单据已关闭。 |
nothing_open |
Nothing is left open on the source document. | Tiada baki terbuka pada dokumen sumber. | 来源单据已无未完成数量。 |
has_children |
Later documents draw from this one. Cancel them first, or close it. | Dokumen kemudian bergantung pada dokumen ini. Batalkannya dahulu, atau tutup dokumen ini. | 后续单据引用了此单据,请先取消它们或关闭此单据。 |
insufficient_stock |
There is not enough stock at this location. | Stok di lokasi ini tidak mencukupi. | 此仓库库存不足。 |
three_way_mismatch |
The invoice does not match what was received. | Invois tidak sepadan dengan apa yang diterima. | 发票与收货不符。 |
match_override_permission |
Approving past a mismatch needs the override permission, which the Owner holds. | Meluluskan walaupun tidak sepadan memerlukan kebenaran khas yang dipegang oleh Pemilik. | 在不符时批准需要负责人持有的特别权限。 |
override_by_preparer |
You prepared this invoice, so someone else must approve it past the mismatch. | Anda menyediakan invois ini, jadi orang lain mesti meluluskannya. | 此发票由您编制,须由他人批准。 |
invoiced_exceeds_delivered |
That bills more than was delivered and not yet invoiced. | Itu melebihi kuantiti yang dihantar dan belum diinvois. | 超过已发货未开票的数量。 |
nothing_to_clear |
The matched receipt has nothing left to clear. | Penerimaan yang dipadankan tiada baki untuk dijelaskan. | 所匹配的收货已无可冲销余额。 |
- Step 9: Run the screen tests and the checks
Run: pnpm -C web test -- features/payables features/receivables features/printing lib/i18n && pnpm -C web typecheck && pnpm -C web lint
Expected: PASS — including the existing InvoiceDialog tests (an unmatched line still sends its account).
- Step 10: Commit
git add web/src/features/payables web/src/features/receivables web/src/features/printing web/src/lib/i18n/localesgit commit -m "Phase 4 O3: invoices matched to receipts and deliveries on screen; override, tolerance, layouts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 13: Both chains end to end
Section titled “Task 13: Both chains end to end”Files:
- Modify:
web/e2e/support.ts(appendensureOperations,arrangeOperations,issueQuotation) - Create:
web/e2e/operations.spec.ts
Interfaces:
-
Consumes: every screen of Tasks 11–12;
as,api,runTag,todayKL,uniqueReceipt,ensureFinanceAP,ensureFinanceAR,recordReceipt(support.ts). -
Produces: nothing other tasks use.
-
Step 1: The arrangements
Append to web/e2e/support.ts:
const TRADE_KEYS = [ 'procurement.requisition.read', 'procurement.requisition.create', 'procurement.requisition.transition', 'procurement.order.read', 'procurement.order.create', 'procurement.order.transition', 'procurement.receipt.read', 'procurement.receipt.create', 'procurement.receipt.transition', 'sales.quotation.read', 'sales.quotation.create', 'sales.quotation.transition', 'sales.order.read', 'sales.order.create', 'sales.order.transition', 'sales.delivery.read', 'sales.delivery.create', 'sales.delivery.transition',];
/** Arrange, not under test: a demo org seeded before O3 gets the keys 00742 grants a migrated one. */export async function ensureOperations() { const owner = await api('owner'); const { data } = await owner.GET('/org/roles'); const grant = async (name: string, keys: string[]) => { const role = data?.roles.find((r) => r.name === name); if (!role) throw new Error(`${name} role not seeded`); await owner.PUT('/org/roles/{id}/permissions', { params: { path: { id: role.id } }, body: { permissions: [...new Set([...role.permissions, ...keys])] }, }); }; await grant('Owner', [...TRADE_KEYS, 'payables.invoice.match_override', 'inventory.item.read']); await grant('Finance', [...TRADE_KEYS, 'inventory.item.read', 'inventory.item.manage']); await grant('Employee', ['procurement.requisition.read', 'procurement.requisition.create', 'procurement.requisition.transition', 'inventory.item.read']); await grant('Approver', ['procurement.requisition.read', 'procurement.requisition.transition']);}
export interface Operations { itemId: string; itemCode: string; unitId: string; locationCode: string; supplierCode: string; customerId: string; customerCode: string;}
/** Arrange, not under test: a stock item and a warehouse, a supplier whose bank details a second * person verified (so it can be paid), and a customer with a revenue account and tax code. */export async function arrangeOperations(tag: string): Promise<Operations> { const n = tag.replace(/\D/g, '').slice(-6); const owner = await api('owner'); const fin = await api('finance'); const { data: accounts } = await fin.GET('/accounting/accounts'); const acct = (code: string) => { const a = accounts?.accounts.find((x) => x.code === code); if (!a) throw new Error(`no ${code} account seeded`); return a.id; }; const { data: uoms } = await fin.GET('/inventory/uoms'); const unit = uoms?.uoms.find((u) => u.key === 'C62'); if (!unit) throw new Error('no C62 unit seeded'); const itemCode = `BOLT${n}`; const { data: item } = await fin.POST('/inventory/items', { body: { code: itemCode, names: { en: `${tag} bolt` }, type: 'stock', base_uom_id: unit.id, tracking: 'none', inventory_account_id: acct('1580'), cogs_account_id: acct('5200'), purchase_account_id: acct('5100'), }, }); if (!item) throw new Error('arrangeOperations: item'); const locationCode = `W${n}`; const { data: location } = await fin.POST('/inventory/locations', { body: { code: locationCode, names: { en: `${tag} warehouse` } }, }); if (!location) throw new Error('arrangeOperations: location');
const supplierCode = `S${n}`; const { data: supplier } = await fin.POST('/payables/suppliers', { body: { code: supplierCode, party_kind: 'organization', legal_name: `${tag} Bekalan Sdn Bhd`, country_code: 'MY', payment_terms_days: 30, currency: 'MYR' }, }); if (!supplier) throw new Error('arrangeOperations: supplier'); const { data: banks } = await fin.GET('/payables/banks'); const { data: banked } = await fin.PUT('/payables/suppliers/{id}/bank', { params: { path: { id: supplier.id }, header: { 'If-Match': `"${supplier.revision}"` } }, body: { bank_entry_id: banks!.banks[0].id, account_no: '5140123456', account_name: `${tag} Bekalan Sdn Bhd` }, }); if (!banked) throw new Error('arrangeOperations: supplier bank'); const verified = await owner.POST('/payables/suppliers/{id}/bank/verify', { params: { path: { id: supplier.id }, header: { 'If-Match': `"${banked.revision}"` } }, body: {}, }); if (!verified.response.ok) throw new Error(`arrangeOperations: verify HTTP ${verified.response.status}`);
const { data: codes } = await fin.GET('/accounting/tax-codes'); const os = codes?.tax_codes.find((c) => c.code === 'OS'); if (!os) throw new Error('no OS tax code seeded'); const customerCode = `C${n}`; const { data: customer } = await fin.POST('/receivables/customers', { body: { code: customerCode, party_kind: 'organization', legal_name: `${tag} Pelanggan Sdn Bhd`, country_code: 'MY', payment_terms_days: 30, currency: 'MYR', default_revenue_account_id: acct('4100'), default_tax_code_id: os.id }, }); if (!customer) throw new Error('arrangeOperations: customer'); return { itemId: item.id, itemCode, unitId: unit.id, locationCode, supplierCode, customerId: customer.id, customerCode };}
/** Arrange, not under test: an issued quotation for five of the item at 9.00, valid through today. */export async function issueQuotation(w: Operations, tag: string): Promise<string> { const fin = await api('finance'); const { data: v } = await fin.POST('/sales/quotations', { body: { party_id: w.customerId, currency: 'MYR', due_date: todayKL(), lines: [{ item_id: w.itemId, description: `${tag} bolts`, quantity: '5', uom_id: w.unitId, unit_price: '9.00' }] }, }); if (!v) throw new Error('issueQuotation: create'); const r = await fin.POST('/sales/quotations/{id}/transitions', { params: { path: { id: v.document.id } }, body: { from: 'draft', to: 'issued' }, }); if (!r.response.ok) throw new Error(`issueQuotation: issue HTTP ${r.response.status}`); return v.document.number;}- Step 2: The spec
Create web/e2e/operations.spec.ts:
import { expect, test, type Page } from '@playwright/test';import { arrangeOperations, as, ensureFinanceAP, ensureFinanceAR, ensureOperations, issueQuotation, recordReceipt, runTag, uniqueReceipt, type Operations,} from './support';
// The drawer's title h3 (the document number) comes first; the status chip is its sibling.const chip = (page: Page) => page.getByRole('dialog').getByRole('heading', { level: 3 }).first().locator('xpath=following-sibling::*[1]');const heading = async (page: Page) => ((await page.getByRole('dialog').getByRole('heading', { level: 3 }).first().textContent()) ?? '').trim();
test.describe.serial('Phase 4 O3: procurement and sales', () => { const tag = runTag(); let w: Operations;
test.beforeAll(async () => { await ensureFinanceAP(); await ensureFinanceAR(); await ensureOperations(); w = await arrangeOperations(tag); });
test('requisition → order → receipt → invoice → payment; GRNI clears', async ({ browser }) => { // 1. The employee asks for ten bolts. const emp = await as(browser, 'employee'); await emp.goto('/procurement/requisitions'); await emp.getByRole('button', { name: 'New requisition' }).first().click(); const rq = emp.getByRole('dialog'); await rq.getByRole('combobox', { name: 'Item' }).click(); await emp.getByRole('option', { name: new RegExp(w.itemCode) }).click(); await rq.getByLabel('Quantity', { exact: true }).fill('10'); await rq.getByLabel('Unit price').fill('3.00'); await rq.getByRole('button', { name: 'Submit' }).click(); await expect(chip(emp)).toHaveText('Pending approval'); const reqNo = await heading(emp);
// 2. Their superior approves. const apr = await as(browser, 'approver'); await apr.goto('/procurement/requisitions'); await apr.getByRole('row', { name: new RegExp(reqNo) }).click(); await apr.getByRole('dialog').getByRole('button', { name: 'Approve', exact: true }).click(); await expect(chip(apr)).toHaveText('Approved');
// 3. Finance orders it from the requisition; the owner approves the order. const fin = await as(browser, 'finance'); await fin.goto('/procurement/orders'); await fin.getByRole('button', { name: 'New purchase order' }).first().click(); const po = fin.getByRole('dialog'); await po.getByRole('combobox', { name: 'Start from a Purchase requisition' }).click(); await fin.getByRole('option', { name: reqNo }).click(); await po.getByRole('combobox', { name: 'Supplier' }).click(); await fin.getByRole('option', { name: new RegExp(w.supplierCode) }).click(); await po.getByRole('button', { name: 'Submit' }).click(); await expect(chip(fin)).toHaveText('Pending approval'); const poNo = await heading(fin); const own = await as(browser, 'owner'); await own.goto('/procurement/orders'); await own.getByRole('row', { name: new RegExp(poNo) }).click(); await own.getByRole('dialog').getByRole('button', { name: 'Approve', exact: true }).click(); await expect(chip(own)).toHaveText('Approved');
// 4. Finance — not the owner, who approved the order — receives the ten. await fin.goto('/procurement/orders'); await fin.getByRole('row', { name: new RegExp(poNo) }).click(); await fin.getByRole('dialog').getByRole('button', { name: 'New goods receipt' }).click(); const grn = fin.getByRole('dialog'); await grn.getByRole('combobox', { name: 'Location' }).click(); await fin.getByRole('option', { name: new RegExp(w.locationCode) }).click(); await grn.getByRole('textbox', { name: 'Quantity 1' }).fill('10'); await grn.getByRole('button', { name: 'Receive' }).click(); await expect(chip(fin)).toHaveText('Received');
// PRESENCE: received and not invoiced, the supplier is in GRNI aging. await fin.goto('/operations/reports'); await fin.getByRole('tab', { name: 'Goods received not invoiced' }).click(); await expect(fin.getByText(`${tag} Bekalan Sdn Bhd`)).toBeVisible();
// 5. Finance enters the supplier's invoice on the receipt line; the owner approves it. await fin.goto('/payables/invoices'); await fin.getByRole('button', { name: 'New invoice' }).first().click(); const inv = fin.getByRole('dialog'); await inv.getByRole('combobox', { name: 'Supplier' }).click(); await fin.getByRole('option', { name: new RegExp(w.supplierCode) }).click(); await inv.getByLabel("Supplier's document number").fill(`${tag}-INV`); await inv.locator('input[type=file]').setInputFiles(uniqueReceipt()); await expect(inv.getByRole('button', { name: 'Remove receipt.png' })).toBeVisible(); await inv.getByRole('combobox', { name: 'What the document is' }).click(); await fin.getByRole('option', { name: 'Receipt without e-Invoice' }).click(); await inv.getByLabel('Why there is no e-Invoice').fill('Supplier issued a paper invoice'); await inv.getByRole('combobox', { name: 'Receipt line' }).click(); await fin.getByRole('option', { name: /GRN-/ }).click(); await inv.getByRole('button', { name: 'Submit' }).click(); await expect(chip(fin)).toHaveText('Pending review'); await own.goto('/payables/invoices'); await own.getByRole('row', { name: new RegExp(`${tag}-INV`) }).click(); await own.getByRole('dialog').getByRole('button', { name: 'Approve and post', exact: true }).click(); await expect(chip(own)).toHaveText('Posted');
// ABSENCE: invoiced in full, the supplier leaves GRNI aging. await fin.goto('/operations/reports'); await fin.getByRole('tab', { name: 'Goods received not invoiced' }).click(); await expect(fin.getByText(`${tag} Bekalan Sdn Bhd`)).toHaveCount(0);
// 6. Finance pays it; the owner approves the payment. await fin.goto('/payables/payments'); await fin.getByRole('button', { name: 'New payment' }).first().click(); const pd = fin.getByRole('dialog'); await pd.getByRole('combobox', { name: 'Supplier' }).click(); await fin.getByRole('option', { name: new RegExp(w.supplierCode) }).click(); await pd.getByRole('combobox', { name: 'Pay from' }).click(); await fin.getByRole('option', { name: /1520/ }).click(); await pd.getByLabel(/^Pay PI-/).fill('30.00'); await pd.getByRole('button', { name: 'Submit' }).click(); await expect(chip(fin)).toHaveText('Pending approval'); const payNo = await heading(fin); await own.goto('/payables/payments'); await own.getByRole('row', { name: new RegExp(payNo) }).click(); await own.getByRole('dialog').getByRole('button', { name: 'Approve and record payment', exact: true }).click(); await expect(chip(own)).toHaveText('Paid'); });
test('quotation → sales order → delivery → invoice → receipt; delivered-not-invoiced clears', async ({ browser }) => { const quote = await issueQuotation(w, tag);
// 1. Finance takes the quotation into a sales order; the owner confirms it. const fin = await as(browser, 'finance'); await fin.goto('/sales/orders'); await fin.getByRole('button', { name: 'New sales order' }).first().click(); const so = fin.getByRole('dialog'); await so.getByRole('combobox', { name: 'Start from a Quotation' }).click(); await fin.getByRole('option', { name: quote }).click(); await so.getByRole('button', { name: 'Submit' }).click(); await expect(chip(fin)).toHaveText('Pending approval'); const soNo = await heading(fin); const own = await as(browser, 'owner'); await own.goto('/sales/orders'); await own.getByRole('row', { name: new RegExp(soNo) }).click(); await own.getByRole('dialog').getByRole('button', { name: 'Approve', exact: true }).click(); await expect(chip(own)).toHaveText('Confirmed');
// 2. Finance delivers the five, from the stock the first test received. await fin.goto('/sales/orders'); await fin.getByRole('row', { name: new RegExp(soNo) }).click(); await fin.getByRole('dialog').getByRole('button', { name: 'New delivery' }).click(); const dd = fin.getByRole('dialog'); await dd.getByRole('combobox', { name: 'Location' }).click(); await fin.getByRole('option', { name: new RegExp(w.locationCode) }).click(); await dd.getByRole('textbox', { name: 'Quantity 1' }).fill('5'); await dd.getByRole('button', { name: 'Deliver' }).click(); await expect(chip(fin)).toHaveText('Delivered');
// PRESENCE: delivered and not billed. await fin.goto('/operations/reports'); await fin.getByRole('tab', { name: 'Delivered not invoiced' }).click(); await expect(fin.getByText(`${tag} Pelanggan Sdn Bhd`)).toBeVisible();
// 3. Finance bills the delivery line and issues the invoice. await fin.goto('/receivables/invoices'); await fin.getByRole('button', { name: 'New invoice' }).first().click(); const ar = fin.getByRole('dialog'); await ar.getByRole('combobox', { name: 'Customer' }).click(); await fin.getByRole('option', { name: new RegExp(w.customerCode) }).click(); await ar.getByRole('combobox', { name: 'Delivery line' }).click(); await fin.getByRole('option', { name: /DO-/ }).click(); await ar.getByRole('button', { name: 'Save draft' }).click(); await fin.getByRole('dialog').getByRole('button', { name: 'Issue invoice', exact: true }).click(); await expect(fin.getByRole('dialog').getByText('Issued')).toBeVisible(); const invoiceId = new URL(fin.url()).pathname.split('/').pop()!;
// ABSENCE: billed, the delivery leaves the report. await fin.goto('/operations/reports'); await fin.getByRole('tab', { name: 'Delivered not invoiced' }).click(); await expect(fin.getByText(`${tag} Pelanggan Sdn Bhd`)).toHaveCount(0);
// 4. The customer pays (arranged through AR's API, as the receivables flows do). await recordReceipt('finance', { invoiceId, customerId: w.customerId, amount: '45.00' }); });});- Step 3: Run it against the dev stack
Run: pnpm -C web exec playwright test e2e/operations.spec.ts
Expected: 2 passed. If a selector names copy that differs (a label in trade.json, the AR drawer’s issue button), fix the spec to the rendered copy — never the copy to the spec.
- Step 4: Commit
git add web/e2e/support.ts web/e2e/operations.spec.tsgit commit -m "Phase 4 O3: end-to-end procurement and sales chains
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 14: Lock order, roadmap, deploy notes, the full suite
Section titled “Task 14: Lock order, roadmap, deploy notes, the full suite”Files:
- Modify:
docs/LOCK_ORDER.md,docs/ROADMAP.md,docs/DEPLOY_NOTES.md
Interfaces:
-
Consumes: everything above.
-
Produces: the roadmap ticks, the operator notes, the lock-order rows.
-
Step 1:
docs/LOCK_ORDER.md
Add to the Per module table, after O2’s inventory rows:
| Trade stock documents through O3 (receipts, deliveries, returns) | in a one-member organization, `organization` `FOR UPDATE` (`workflow.LockSoleOwner`) → `trade_document` `FOR UPDATE` → on a cancel or rejection, its live `trade_line` rows `FOR UPDATE` in `id` order → the approver and invoiced checks, reading `workflow_decision` and the AP/AR invoice lines without locks → O2's `inventory.Transition` order → the sole-owner audit row, last. `00740`'s trigger reads the decision's order rows without locks | `trade/stock.go` `TransitionStock` || Purchase invoice approval, three-way match (O3) | `purchase_invoice` `FOR UPDATE` → the matched receipt and return `trade_line` rows `FOR UPDATE` in `id` order → `purchase_match_tolerance` read → the override row and its audit → `workflow.Transition` (the accrual reads `receipt_valuation`) → audit, last. A receipt's cancel locks its header then its lines and never an invoice, so no cycle. A purchase return drafted on several receipt lines locks them in line order (O1's guard); an approval locking the same lines in id order may deadlock with it and Postgres aborts one — retry | `payables/transition.go` `TransitionInvoice`, `payables/match.go` `checkMatch` || Sales invoice issue on deliveries (O3) | `organization` (`LockSoleOwner`) → `sales_invoice` `FOR UPDATE` → a credit note's original `FOR UPDATE` → the delivery and sales-return `trade_line` rows `FOR UPDATE` in `id` order → issue → `workflow.Transition` → audit | `receivables/transition.go` `TransitionDocument`, `receivables/deliverymatch.go` |- Step 2:
docs/ROADMAP.md
Under Phase 4 — Operations, replace the two items
- [ ] Procurement: PR → PO → GRN → purchase invoice, on a generalized document chain- [ ] Sales: quotation → SO → DO → invoice, on the same chainwith
- [x] Procurement: PR → PO → GRN → purchase invoice, on a generalized document chain (O3: receipts only against an order, the order's approver kept off its receipt by the database, the three-way match at approval with an audited Owner override, GRNI cleared with price variance to P&L and realised FX, purchase returns and supplier credit notes, purchase-order PDF, open orders and GRNI aging; plan `docs/superpowers/plans/2026-09-30-phase-4-o3-procurement-sales.md`)- [x] Sales: quotation → SO → DO → invoice, on the same chain (O3: quotation validity and PDF, deliveries at cost, invoiced ≤ delivered − invoiced, sales returns and customer credit notes, open sales orders with backorders, delivered not invoiced; MyInvois unchanged)- Step 3:
docs/DEPLOY_NOTES.md
Insert after the first --- line (above the O2 entry):
## Phase 4 O3 — procurement and sales
Migrations `00740`–`00743`; permission keys `procurement.{requisition,order,receipt}.{read,create,transition}`,`sales.{quotation,order,delivery}.{read,create,transition}` and `payables.invoice.match_override`; documentlayout kinds `purchase_order` and `quotation`. No environment variable.
- **`00742` grants** the Owner every new key, Finance the eighteen trade keys, Employee the requisition keys and `inventory.item.read`, Approver requisition read and transition, and bumps `perm_version`.- **Separation of duties is enforced by the database:** a goods receipt cannot be posted by any member who approved (or held the authority for) its order. A one-member organization posts it under the sole-owner exception, with a reason on the audit row.- **Purchase invoices matched to receipts are checked at approval.** The tolerance is exact until an administrator sets it (Payables → Invoices → Three-way match tolerance, `org.profile.manage`). A mismatch blocks approval; an Owner who did not prepare the invoice may approve it with a reason (`purchase_invoice_match_override`, append-only, and the audit log).- **A matched invoice clears GRNI**; its price difference goes to Purchase price variance (5300), not into stock still held (spec Ruling R-1). Review 5300 at month end.- **An invoiced receipt, delivery or return cannot be cancelled**; void the invoice first.- **Down-migrations refuse once O3 has been used:** `00740` while a receipt was posted under the sole-owner exception, `00743` while an order or quotation layout is saved. Roll the code back, not these migrations.- Step 4: The full suite
Run, in order:
make generate && git status --porcelain # expected: no outputmake lint # expected: cleango test -p 2 -count=1 ./... # expected: ok for every packagemake web # expected: typecheck, lint, test and build passpnpm -C web exec playwright test # expected: every spec passes, operations.spec.ts includedIf a package outside O3’s files fails, read it before touching it: the usual cause is a drift test that lists tables, routes, modules or permissions (retention_test.go, routes_test.go, the cmd/*/wiring_test.go files) and needs the O3 entry this plan names in its task. Do not weaken an existing test to pass.
- Step 5: Commit
git add docs/LOCK_ORDER.md docs/ROADMAP.md docs/DEPLOY_NOTES.mdgit commit -m "Phase 4 O3: lock order, roadmap and deploy notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Self-review against the spec
Section titled “Self-review against the spec”| Spec | Where |
|---|---|
| O3.1 requisition: items or text, quantity, estimated price, needed-by; approver from grants | O1’s graph and grants; Task 9 routes (/procurement/requisitions), Task 11 screen (text line or item, due.purchase_requisition = needed by); R-O3-13 grants Approver the keys to act |
| O3.1 order from a requisition or direct; supplier, currency, prices, tax, delivery date | Task 9 (/procurement/orders, draft?from=), Task 11 (DocumentDialog start-from; O1’s Draft) |
| O3.1 order PDF as a C4 layout kind; no email | Task 8 (purchase_order), Task 9 (/pdf), Task 12 (designer) |
| O3.1 receipt only against an order; partial; location; serials and batches | Task 1 CreateStock (R-O3-1), lots through SetLineLots; Task 11 StockDialog |
| O3.1 receipt posts at order price, foreign converted at the receipt date’s rate; services received too | O2 (receiveLine, R-O2-3, R-O2-17); Task 1 per-base-unit price (R-O3-2); Task 5 foreign test |
| O3.1 the approver of an order cannot post its receipt, database-enforced | Task 1 (00740 trigger + Go check + sole-owner flag), drift test TestTheOrderApproverCannotPostItsReceipt |
| O3.1 / OD-3 three-way match at approval; tolerance % and RM, default 0; block; Owner-not-preparer override with reason, audited | Tasks 3–4 (purchase_match_tolerance, checkMatch, purchase_invoice_match_override + trigger), Task 10 (match_override key, 409 three_way_mismatch), drift test TestAThreeWayMismatchBlocksApprovalUnless… |
| O2.3 row 2 (O3’s by R-O2-8): Dr GRNI / Cr AP, price difference to PPV, FX to realised FX; R-1 PPV to P&L | Task 5 clearGRNI + tests |
| O3.1 purchase return from a receipt line at that receipt’s cost; AP credit note links to the return line | O2 (returnOutLine, R-O2-15); Task 1 CreateStock; Tasks 3–5 credit note on return lines |
| O3.2 quotation: validity, PDF, one-step workflow ending committed; expiry derived | O1 graph; Task 2 (Expired, valid_until_required); Task 8/9 PDF |
| O3.2 sales order from quotation or direct; no credit limit | Task 2 (expired quotation refused), Task 9/11 |
| O3.2 delivery from a sales order; partial; location; serials and batches; posts effect | Task 1 CreateStock; O2 posting |
| O3.2 sales invoice on delivery lines; invoiced ≤ delivered − invoiced; MyInvois unchanged | Task 6 (checkDeliveryMatch; posting and e-Invoice untouched, the posting asserted) |
| O3.2 sales return from a delivery line at the cost it left at; AR credit note links to the return line | O2 (returnInLine); Task 6 credit-note test |
| O3.3 reports: open orders, GRNI aging, open sales orders and backorders, delivered not invoiced | Task 7, Task 9 routes, Task 11 ReportsPage; stock on hand, movement and valuation are O2’s |
O3.3 permissions procurement.*, sales.*, granted by provisioning |
Task 9 (permissions.go, 00742) |
| Drift tests: approver cannot post its receipt; three-way mismatch blocks unless a non-preparer Owner overrides; new tables tenant-isolated | Tasks 1, 4; the existing RLS/constraint/retention tests (Task 3 names both tables) |
| Web: requisitions, orders, receipts, quotations, sales orders, deliveries, returns, chain view; operations nav group | Tasks 11–12 |
| E2E: PR→PO→GRN→invoice→pay and quote→SO→DO→invoice→receipt | Task 13 |
Gaps deliberately left, each a line: purchase price variance goes to P&L even while the stock is held (R-1; re-costing the FIFO layer is the upgrade); stock documents are entered in base units (R-O3-2); a matched AP line carries no analytic shares and its PPV line none either (as O2’s purchase-return PPV); GRNI aging is as of today, not historical; the chain view shows a document’s line of descent, not its siblings; no email of orders or quotations (no mailer).