Skip to content

Phase 4 O2: Stock and Costing 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: Stock moves in an append-only ledger with a balance that cannot go negative, costed per item by FIFO or weighted average (one method per organization, locked once stock moves), posting receipts to GRNI, deliveries and adjustments through a new effect semantic, with serial/batch enforcement, transfers, stock-take, stock reports and a valuation = GL integrity check.

Architecture: The workflow engine gains the effect semantic (“approved; posts with no control account”); accounting gains the grni control kind, a purchase-price-variance system role and an inventory owned-account module. internal/platform/inventory (O1’s package) gains the stock ledger (stock_move, trigger-maintained stock_balance), costing (item_cost, cost_layer), document application (Transition moves a receipt/delivery/return/adjustment’s stock, then runs workflow.Transition, whose Poster reads the moves), posting rules, transfers, reasons, reports and a reconcile check. internal/platform/inventory/adjustment creates adjustments and stock counts through O1’s trade.Create. internal/modules/inventory (O1’s module) serves twelve routes.

Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · kernel/money

Spec: docs/superpowers/specs/2026-09-30-operations-design.md (O2.1–O2.5; OD-1, OD-2)

Every task’s requirements implicitly include this section.

  • O2 starts after O1 is merged, and consumes O1 as defined in docs/superpowers/plans/2026-09-30-phase-4-o1-chain-items.md (“Interfaces O2 and O3 consume”, Tasks 2–5). The names this plan uses from it:
    • Migration ranges: O1’s Task 1 already declares O2’s 00720–00739 in migrationranges_test.go; this plan adds none. It uses 00720–00727.
    • Tables: trade_document(kind, number, supplier_party_id, customer_party_id, currency, fx_rate, location_id, instance_id); trade_line(document_id, document_kind, line_no, item_id, description, quantity, uom_id, uom_factor, quantity_base, unit_price, net_amount, shares, source_line_id, withdrawn_at) — only a stock_adjustment line may be negative (direction by sign); item(code, type, base_uom_id, inventory_account_id, cogs_account_id, sales_account_id, purchase_account_id, tracking) with item_accounts_by_type (a stock item names inventory and COGS accounts, others neither); location(code); uom registry keyed by UN/ECE code (C62 is “unit”). SQL functions trade_semantic(org, instance); the trigger item_fixed_once_used() raising constraint item_fixed, which Task 3 replaces (O1 says O2 must).
    • Semantic: O1’s 00700 renames the inline check to workflow_state_semantic_known (with committed); posting.Committed; workflow.CommitmentKinds.
    • Go: trade.Create(ctx, tx, c, actor uuid.UUID, in trade.DocumentInput) (trade.Document, error), trade.DocumentInput{Kind; PartyID; Currency; FXRate *money.Rate; DueDate; LocationID; Lines []trade.LineInput}, trade.LineInput{ItemID; Description; Quantity; UOMID; UnitPrice; TaxCodeID; Shares; SourceLineID}, trade.Document{ID, Kind, Number, InstanceID, Lines []trade.Line{ID, QuantityBase, …}}, trade.KindAdjustment, *trade.FieldError, trade.ErrNotFound, trade.ErrHasChildren; inventory.CreateItem/ItemInput, inventory.CreateLocation/LocationInput, inventory.Names, inventory.TypeStock/TypeService/TypeNonStock, inventory.TrackingNone/Serial/Batch, inventory.EntityItem; test fixture trade/tradetest.Env (New, As, UOM, Account, Supplier, Customer, Location), which stocktest embeds.
    • Module internal/modules/inventory (O1 Task 8): Register (with h := handlers{deps: deps}, pl := permissionLoader{db: deps.DB}), Deps{DB, Clock}, decodeStrict, mapErr, PermissionItemRead = "inventory.item.read", PermissionItemManage = "inventory.item.manage", its boot.go, permissions.go and errors.go. O1’s mapErr maps item-master errors only; stockProblem (Task 12) maps workflow, ledger and trade refusals for O2’s routes.
    • Boundaries (O1’s Global Constraints): platform/inventory never imports platform/trade; inventory/adjustment and the tests import both. O1 seeds no graph for the five stock kinds (Ruling O1-14): O2 does (Task 5). O1 registers no provisioning step, so orders 81 and 83 are free.
    • Unexported helper names in package inventory are prefixed (stockOrg, stockLabels, strp…) to avoid O1’s (O1 already has orgOf there).
  • Quantities are money.Rate in Go (as AR’s line quantity), NUMERIC(19,6) in SQL, strings on the wire. Money never float: money.Amount, NUMERIC(19,4), OpenAPI Money.
  • Every new table: org_id UUID NOT NULL REFERENCES organization (id), ENABLE and FORCE ROW LEVEL SECURITY, policy org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid, composite (org_id, x) foreign keys only. Constraints named <table>_<rule>, none ending _check/_key/_excl. Trigger refusals RAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on the name. Functions SET search_path = pg_catalog, public, pg_temp; no SECURITY DEFINER.
  • Every SQL string ties each org-scoped table to org_id = $n (directly or through x.org_id = y.org_id); every INSERT names org_id (TestEveryTenantQueryCarriesAnExplicitOrgPredicate).
  • Retention (internal/kernel/db/retention_test.go): stock_move, stock_transfer, receipt_valuation, stock_adjustment_detail, stock_count, stock_count_line → appendOnly (REVOKE UPDATE, DELETE); stock_balance, stock_batch, item_cost, cost_layer, stock_adjustment_reason → amendable (REVOKE DELETE); trade_line_lot → operational.
  • The move date is clock.Today(ctx, c). No back-dated moves; a correction is a reversing move.
  • Accounting reacts to semantics, never state names. Stock entries post in the base currency. Rules return a Draft; nothing here calls ledger.Post except tests.
  • Lock order (inventory), written into docs/LOCK_ORDER.md in Task 13: trade_document FOR UPDATE → on a cancel, its live trade_line rows FOR UPDATE in id order (O1’s order) → company_profile FOR SHARE → item_cost rows FOR UPDATE in item id order (created if missing) → cost_layer rows FOR UPDATE in seq order → stock_balance (upserted by stock_move’s trigger) → workflow.Transition (the Poster reads the moves) → audit.Write, last. company.SetCostingMethod takes company_profile FOR UPDATE. adjustment.Create writes its detail rows after trade.Create’s audit: plain INSERTs whose only locks are FOR KEY SHARE on rows this transaction wrote or that nothing locks FOR UPDATE.
  • make generate after every migration, OpenAPI or constants.yaml change; commit the generated files; git status clean.
  • Presence before absence. Every refusal test shows the accepted case first. Every new guard is watched failing once: each task with a 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 stocktest.New (which registers the Poster over inventory.Rules() and payables.Rules()); never t.Parallel().
  • Tests: while iterating, go test ./internal/platform/inventory/... -count=1 (plus the package a task touches); one full go test -p 2 -count=1 ./... in Task 13 only.
  • Never bare git stash. Use a WIP commit. Commits end with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Where the spec is silent. R-O2-n: decision — why.

  • R-O2-1 effect shares the instance’s accrual_open flag: entering it opens the instance’s posting, entering rejected/cancelled after it reverses (the Poster’s mirror). The validator treats it as an accrual without a control account (no cycle, no repeat, reversal flag required). An effect rule may return a Draft with no lines (a delivery of service items): nothing posts, and the cancellation of an effect kind with nothing on the ledger reverses nothing instead of failing — the only way a service-only delivery could ever be cancelled.
  • R-O2-2 stock_move has no journal_entry_id. The moves are written before the entry (the rule reads them) and the table is append-only, so the id cannot be stamped afterwards. The link is stock_move.trade_line_id → trade_document, and journal_entry(source_kind, source_id) is the document.
  • R-O2-3 Stock entries post in base currency. A receipt line is valued at its net_amount (O1’s unit_price × quantity, to the cent) in the document currency, converted at the receipt date’s rate (fx.Resolve), and recorded in append-only receipt_valuation (a reversal adds a negating row). O3’s purchase invoice clears GRNI from it (inventory.ReceivedValue).
  • R-O2-4 item_cost(quantity, value) is kept for both methods — it is the per-item lock and the valuation. The average is derived (value ÷ quantity, 4 places), never stored. FIFO layers carry remaining_value. The issue that empties an item (weighted average) or a layer (FIFO) takes the residue; an issue never takes more than the value held.
  • R-O2-5 A transfer is one item between two locations, carrying zero cost (cost is per item across the organization, spec O2.2).
  • R-O2-6 Stock that comes back (a sales return, a reversed issue) re-enters FIFO as a new layer at the cost it left at, not back into the layers it came from. A reversed purchase return’s layer keeps the receipt line it came from, so that receipt can be returned again.
  • R-O2-7 The posting rules and the seeded workflow graphs of the five stock kinds are O2’s. Commitment kinds (requisition, order, quotation, sales order) go through O1’s trade.Transition; the five stock kinds go through inventory.Transition, which does what trade.Transition does (header lock; a document with live children cannot be cancelled, trade.ErrHasChildren’s rule, checked with O1’s trade_semantic) and moves or reverses the stock between the header lock and workflow.Transition. It cannot wrap trade.Transition (no hook between its lock and the engine, and inventory may not import trade). O3 adds its own checks (separation of duties, three-way match) under the same header lock and calls inventory.Transition.
  • R-O2-8 The purchase-invoice side of O2.3’s table (Dr GRNI / Cr AP, price difference to PPV, FX to realised FX) is O3’s: it needs the invoiced quantity O3 adds to the invoice line for three-way match. O2 provides the grni control kind, chart.PurchasePriceVariance and inventory.ReceivedValue.
  • R-O2-9 The default costing method is weighted_average. PUT /inventory/costing-method (org.profile.manage) changes it until the first move.
  • R-O2-10 O1’s item_fixed_once_used() fixes type, base unit and tracking once a trade line names the item. Task 3 replaces it (constraint name kept: item_fixed) so those three are also fixed once a stock_move exists (a transfer names no trade line), and the inventory account is fixed once the item has moved: valuation compares each inventory account with its items’ value. Provisioning’s Inventory account is owned by module inventory, so no manual journal moves it.
  • R-O2-11 “Serial unique per item” means one location holds a serial at a time (a partial unique index on stock_balance); a serial may leave and come back (a return).
  • R-O2-12 Adjustments and stock counts are in the item’s base UOM and the base currency; an adjustment line’s direction is its quantity’s sign (O1’s trade_line_quantity_sign), and the API’s direction maps onto it. Reasons are their own table with a GL account; provisioning seeds count and damage on Stock adjustment (5400). Managing reasons needs accounting.account.manage (it chooses a GL account).
  • R-O2-13 Seeded adjustment flow: draft → submitted → approved (effect), approval and cancellation granted to the Owner seat. The sole-owner exception (Ruling O-3a) covers the five stock kinds’ void and the adjustment’s approval, so a one-member organization can use them.
  • R-O2-14 Stock reports are GET /inventory/stock/{on-hand,movements,valuation} under inventory.item.read, not /reports (they have no export kind). Movements are capped at 5,000 rows per request (422 too_many_rows; narrow the dates).
  • R-O2-15 A purchase return debits GRNI at the returned receipt’s value pro rata; the difference from the stock cost it leaves at goes to PPV (consistent with spec Ruling R-1).
  • R-O2-16 A stock count is one request: it records counted and system quantities and creates a draft adjustment for the differences, “in” lines at the current unit cost (average, or the newest FIFO layer’s; 0 when none). The adjustment then goes through its own approval.
  • R-O2-17 Spec O3.1 receives service and non-stock lines on a GRN (acceptance): O2 does — they post Expense (the item’s purchase account) / GRNI with no stock move. Only a description-only text line (no item) is refused on a receipt or purchase return (ErrLineNeedsItem): it has no account to post to. A service or non-stock item received needs a purchase account (ErrItemAccountMissing).
  • R-O2-18 Provisioning steps: inventory_stock_accounts order 81, inventory_adjustment_reasons order 83.
  • R-O2-19 No web UI in O2 (API only); the web gets only the effect tone and the grni manual-line flag its typecheck requires.
package inventory // internal/platform/inventory
const (
KindGoodsReceipt = "goods_receipt"
KindPurchaseReturn = "purchase_return"
KindDeliveryOrder = "delivery_order"
KindSalesReturn = "sales_return"
KindStockAdjustment = "stock_adjustment"
ControlKindGRNI = "grni"
)
// A serial (Quantity 1) or batch a trade line moves.
type Lot struct {
SerialNo string `json:"serial_no,omitempty"`
BatchNo string `json:"batch_no,omitempty"`
ExpiresOn *time.Time `json:"expires_on,omitempty"`
Quantity money.Rate `json:"quantity"`
}
// SetLineLots replaces a draft line's serials/batches (caller holds the
// document header FOR UPDATE). ErrAlreadyApplied once the line has moved.
func SetLineLots(ctx context.Context, tx pgx.Tx, lineID uuid.UUID, lots []Lot) error
type TransitionInput struct {
DocumentID uuid.UUID
From, To string
Actor uuid.UUID
Reason string
}
// Transition moves a stock document (the five stock kinds; the commitment
// kinds use O1's trade.Transition, R-O2-7): into its posting state it moves
// the stock (receipt in at value, delivery out at FIFO/average cost, returns
// at the cost of the line they return), then workflow.Transition posts;
// into rejected/cancelled after posting it reverses the moves first. A
// document with live children is not cancelled (ErrHasChildren, O1-7).
func Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (workflow.Decision, error)
// OnTransition is Transition's stock half, for a caller with its own
// wrapper: call it after locking the header and before workflow.Transition.
// posted is the instance's AccrualOpen before the move.
func OnTransition(ctx context.Context, tx pgx.Tx, c clock.Clock, docID uuid.UUID, to posting.Semantic, posted bool) error
// What a goods-receipt line brought in, net of reversals.
type Received struct {
Quantity money.Rate // base UOM
Amount money.Amount // document currency
Currency string
Base money.Amount // base currency: what GRNI was credited
}
func ReceivedValue(ctx context.Context, tx pgx.Tx, receiptLineID uuid.UUID) (Received, error)
func Rules() []accounting.PostingRule // registered by internal/modules/inventory's boot
package chart // internal/platform/accounting/chart
const RolePurchasePriceVariance = "purchase_price_variance"
func PurchasePriceVariance(ctx context.Context, tx pgx.Tx) (Account, error) // ErrNoPurchasePriceVariance

Plus posting.Effect, ledger.SemanticEffect, the GRNI control resolved by chart.ResolveControl(ctx, tx, inventory.ControlKindGRNI).

Path Responsibility
db/migrations/00720_effect_semantic.sql effect on workflow_state and journal_entry CHECKs
db/migrations/00721_grni_and_stock_accounts.sql grni control kind, purchase_price_variance role, inventory owner module
db/migrations/00722_stock_ledger.sql stock_transfer, stock_move, stock_balance, stock_batch, their triggers; replaces O1’s item_fixed_once_used()
db/migrations/00723_costing_method.sql company_profile.costing_method and its lock trigger
db/migrations/00724_cost_layers.sql item_cost, cost_layer
db/migrations/00725_stock_documents.sql trade_line_lot, receipt_valuation, stock_adjustment_reason, stock_adjustment_detail
db/migrations/00726_stock_count.sql stock_count, stock_count_line, stock_adjustment_detail.count_id
db/migrations/00727_inventory_stock_grants.sql existing Owners get the four new keys
internal/platform/posting/posting.go Effect
internal/platform/accounting/{rules,poster}.go, ledger/{types,post,query}.go effect accepted, empty effect, effect reversal
internal/platform/workflow/{validate,instance,authorize,actors,soleowner}.go effect in the engine
internal/platform/accounting/chart/{account,errors}.go grni subledger, PPV role
internal/platform/company/company.go CostingMethod, SetCostingMethod
internal/kernel/money/rate.go Rate.Sub, Neg, IsNegative
internal/platform/inventory/stock.go constants, errors, mapStockError, insertMove
internal/platform/inventory/step.go the stock-accounts provisioning step
internal/platform/inventory/graphs.go the five seeded graphs, sole-owner opt-in
internal/platform/inventory/costing.go beginStock, costIn, costOut, layers, CurrentUnitCost
internal/platform/inventory/stockline.go reading trade lines, lots
internal/platform/inventory/apply.go apply, reverse, OnTransition, Transition, ReceivedValue
internal/platform/inventory/posting.go the five posting rules
internal/platform/inventory/transfer.go TransferStock
internal/platform/inventory/reason.go adjustment reasons, their provisioning step
internal/platform/inventory/adjustment/adjustment.go Create, Get, RecordCount
internal/platform/inventory/integrity.go stock_valuation reconcile check
internal/platform/inventory/report.go on hand, movements, valuation
internal/platform/inventory/stocktest/stocktest.go the test world
internal/modules/inventory/stock_{permissions,boot,handlers}.go permissions, rule registration, routes
api/openapi.yaml, constants.yaml, web/src/... contract and vocabulary

This task is canonical for effect; O4’s Task 1 carries it over as 00770. If O4 landed first (git grep -n 'Effect Semantic = "effect"' internal/platform/posting/ hits), keep only this task’s tests that O4 lacks, add no 00720, and go to Task 2.

Files:

  • Create: db/migrations/00720_effect_semantic.sql
  • Modify: constants.yaml (workflowSemantics), then make generate
  • Modify: internal/platform/posting/posting.go, internal/platform/posting/posting_test.go
  • Modify: internal/platform/accounting/ledger/types.go, post.go (validateHeader), query.go (Unreversed)
  • Modify: internal/platform/accounting/rules.go (Register), poster.go (Post, reverse)
  • Modify: internal/platform/accounting/testrules_test.go (register testEffect)
  • Create: internal/platform/accounting/poster_effect_test.go, internal/platform/accounting/ledger/effect_test.go
  • Modify: internal/platform/workflow/validate.go (semantics, rule3, rule5), instance.go (flag), authorize.go (reporterMayPost), actors.go (FinalApproval), soleowner.go (soleOwnerEdge)
  • Create: internal/platform/workflow/effect_test.go, internal/platform/workflow/soleowner_effect_export_test.go
  • Modify: api/openapi.yaml (enums Semantic, WorkflowSemantic), web/src/components/workflow/semanticTone.ts

Interfaces:

  • Produces: posting.Effect Semantic = "effect"; ledger.SemanticEffect = "effect"; RuleSet.Register accepts a rule whose Semantic() is posting.Effect; Poster.Post posts nothing for an effect Draft with no lines; Poster.reverse returns nil when nothing is open and (kind, effect) has a rule; workflow.Transition sets accrual_open on entering an effect state.

  • Step 1: Write the failing tests

internal/platform/posting/posting_test.go — replace TestConstantsMatchTheVocabulary’s list and add the effect case to TestPostsToLedger:

func TestConstantsMatchTheVocabulary(t *testing.T) {
want := []string{}
for _, s := range []posting.Semantic{posting.Draft, posting.Pending, posting.Accrual,
posting.Payout, posting.Acknowledged, posting.Dispute, posting.Rejected,
posting.Cancelled, posting.Custom, posting.Committed, posting.Effect} {
want = append(want, string(s))
}
require.Equal(t, vocab.Semantics, want)
}

and inside TestPostsToLedger’s cases, after {posting.Event{Semantic: posting.Payout}, true},:

// Phase 4 O2.3: an effect posts with no control account.
{posting.Event{Semantic: posting.Effect}, true},

internal/platform/accounting/testrules_test.go — append:

const effectKind = "test_effect"
// testEffect: Dr Travel / Cr Cash at the document's amount, with no control
// account. A zero amount builds no lines: a delivery of services only.
type testEffect struct {
docs docStore
travel, cash uuid.UUID
}
func (testEffect) Kind() string { return effectKind }
func (testEffect) Semantic() posting.Semantic { return posting.Effect }
func (r testEffect) Build(_ context.Context, _ pgx.Tx, src accounting.Source) (accounting.Draft, error) {
d, err := r.docs.get(src.Event.SourceID)
if err != nil {
return accounting.Draft{}, err
}
out := accounting.Draft{JournalType: chart.JournalMisc, Currency: d.Currency,
Description: "Test effect " + src.Event.SourceID.String()}
if !d.Amount.IsZero() {
out.Lines = []ledger.Line{{AccountID: r.travel, Debit: d.Amount}, {AccountID: r.cash, Credit: d.Amount}}
}
return out, nil
}

and in poster_test.go’s newFixture, after rules.Register(testPayout{}):

rules.Register(testEffect{docs: docs, travel: b.Chart.Travel.ID, cash: b.Chart.Cash.ID})

internal/platform/accounting/poster_effect_test.go:

package accounting_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/accounting"
"github.com/mcom/erp/internal/platform/accounting/accountingtest"
"github.com/mcom/erp/internal/platform/accounting/ledger"
"github.com/mcom/erp/internal/platform/posting"
)
// effect files a test_effect document and returns its instance entering an
// effect state: no control kind.
func (f *fixture) effect(currency, amount string) posting.Event {
src := uuid.New()
f.docs[src] = doc{Currency: currency, Amount: amt(amount)}
return posting.Event{OrgID: f.e.Org, Kind: effectKind, InstanceID: uuid.New(), SourceID: src,
Semantic: posting.Effect, BusinessDate: accountingtest.Date("2026-09-20"), ActorMemberID: f.member}
}
// effectBalances is balances for effectKind: amount per account, debit positive.
func (f *fixture) effectBalances(t *testing.T, sourceID uuid.UUID) map[uuid.UUID]money.Amount {
t.Helper()
out := map[uuid.UUID]money.Amount{}
f.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT l.account_id, SUM(l.debit - l.credit)
FROM journal_line l
JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id
WHERE l.org_id = $1 AND e.source_kind = $2 AND e.source_id = $3
GROUP BY l.account_id`, f.e.Org, effectKind, sourceID)
require.NoError(t, err)
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var a money.Amount
require.NoError(t, rows.Scan(&id, &a))
out[id] = a
}
return rows.Err()
})
return out
}
func TestAnEffectPostsWithNoControlAccountAndReversesOnCancel(t *testing.T) {
f := newFixture(t)
ch := f.b.Chart
ev := f.effect("MYR", "40")
f.mustPost(t, ev)
got := f.entries(t, ev.InstanceID)
require.Len(t, got, 1)
require.Equal(t, ledger.SemanticEffect, got[0].Semantic)
before := f.effectBalances(t, ev.SourceID)
requireAmount(t, "40", before[ch.Travel.ID], "PRESENCE: travel")
requireAmount(t, "-40", before[ch.Cash.ID], "PRESENCE: cash")
require.ErrorIs(t, f.post(ev), accounting.ErrAlreadyPosted, "an effect posts once")
f.mustPost(t, then(ev, posting.Cancelled, "2026-09-20"))
got = f.entries(t, ev.InstanceID)
require.Len(t, got, 2)
require.Equal(t, ledger.SemanticReversal, got[1].Semantic)
for id, a := range f.effectBalances(t, ev.SourceID) {
require.True(t, a.IsZero(), "account %s still holds %s", id, a)
}
}
func TestAnEffectWithNothingToPostPostsAndReversesNothing(t *testing.T) {
f := newFixture(t)
// PRESENCE: a non-zero effect posts.
posted := f.effect("MYR", "5")
f.mustPost(t, posted)
require.Len(t, f.entries(t, posted.InstanceID), 1)
empty := f.effect("MYR", "0")
f.mustPost(t, empty)
require.Empty(t, f.entries(t, empty.InstanceID), "no lines, no entry")
f.mustPost(t, then(empty, posting.Cancelled, "2026-09-20"))
require.Empty(t, f.entries(t, empty.InstanceID), "and its cancellation reverses nothing")
// An accrual kind still refuses a reversal the ledger cannot back.
ghost := then(f.claim("MYR", "80"), posting.Rejected, "2026-09-20")
require.ErrorIs(t, f.post(ghost), accounting.ErrNothingToReverse)
}

internal/platform/accounting/ledger/effect_test.go:

package ledger_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/accounting/accountingtest"
"github.com/mcom/erp/internal/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/accounting/ledger"
)
func TestAnEffectEntryNamesItsSourceAndStaysOpenUntilReversed(t *testing.T) {
e := accountingtest.New(t)
b := e.SeedBooks(t)
in := ledger.EntryInput{JournalType: chart.JournalMisc, EntryDate: accountingtest.Date("2026-09-20"),
Description: "Stock effect", Semantic: ledger.SemanticEffect,
Lines: []ledger.Line{
{AccountID: b.Chart.Travel.ID, Debit: money.MustParse("10")},
{AccountID: b.Chart.Cash.ID, Credit: money.MustParse("10")},
}}
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := ledger.Post(ctx, tx, e.Clock, in)
return err
})
require.ErrorIs(t, err, ledger.ErrMissingSource)
src, inst := uuid.New(), uuid.New()
in.SourceKind, in.SourceID, in.InstanceID = "delivery_order", &src, &inst
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
got, err := ledger.Post(ctx, tx, e.Clock, in)
require.NoError(t, err, "journal_entry_semantic_known accepts effect")
require.Equal(t, ledger.SemanticEffect, got.Semantic)
open, err := ledger.Unreversed(ctx, tx, inst)
require.NoError(t, err)
require.Len(t, open, 1)
return nil
})
}

internal/platform/workflow/soleowner_effect_export_test.go:

package workflow
// SoleOwnerEdgeForTest exposes soleOwnerEdge to effect_test.go.
var SoleOwnerEdgeForTest = soleOwnerEdge

internal/platform/workflow/effect_test.go:

package workflow_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/posting"
"github.com/mcom/erp/internal/platform/workflow"
"github.com/mcom/erp/internal/platform/workflow/workflowtest"
)
// effectFlow is a stock adjustment's shape: submit, approve into effect,
// reject back to draft, cancel after posting (reversing).
func effectFlow() workflow.Graph {
return workflow.Graph{
Kind: "effect_flow", PostingOrder: workflow.AccrualFirst,
States: []workflow.StateSpec{
st("draft", posting.Draft, false), st("pending", posting.Pending, false),
st("posted", posting.Effect, false), st("rejected", posting.Rejected, false),
st("cancelled", posting.Cancelled, true),
},
Edges: []workflow.EdgeSpec{
ed("draft", "pending", workflow.GateReporter),
ed("pending", "posted", workflow.GateTarget),
rejecting("pending", "rejected", workflow.GateAdvance, false),
ed("rejected", "draft", workflow.GateReporter),
rejecting("posted", "cancelled", workflow.GateTarget, true),
},
Grants: []workflow.GrantSpec{anyone("posted"), anyone("cancelled")},
}
}
func requireEffectOnly(t *testing.T, g workflow.Graph, rule int, code string) {
t.Helper()
require.Empty(t, workflow.ValidateGraph(effectFlow(), "MYR"), "the effect fixture must be valid, or every absence below is vacuous")
vs := workflow.ValidateGraph(g, "MYR")
require.NotEmpty(t, vs, "expected %s", code)
for _, v := range vs {
require.Equal(t, rule, v.Rule, "unexpected violation %+v", v)
}
require.Contains(t, codes(vs), code)
}
func TestAnEffectNamesNoControlAccount(t *testing.T) {
g := effectFlow()
g.States[2].ControlKind = "ap"
requireEffectOnly(t, g, 0, "control_kind_forbidden")
}
func TestCancellingAfterAnEffectMustReverseIt(t *testing.T) {
g := effectFlow()
g.Edges[4].ReversesAccrual = false
requireEffectOnly(t, g, 5, "reversal_missing")
}
func TestAnEffectCannotBeEnteredTwice(t *testing.T) {
g := effectFlow()
g.Edges = append(g.Edges, ed("posted", "pending", workflow.GateReporter))
requireEffectOnly(t, g, 3, "effect_on_cycle")
}
type effectRecorder struct{ got []posting.Event }
func (r *effectRecorder) Post(_ context.Context, _ pgx.Tx, ev posting.Event) error {
r.got = append(r.got, ev)
return nil
}
func TestEnteringAnEffectOpensThePostingAndCancellingReversesIt(t *testing.T) {
w := workflowtest.NewWorld(t)
ann := w.Member("Ann")
w.Sit(ann, w.Seat("OWNER", nil))
rec := &effectRecorder{}
posting.ResetForTest()
t.Cleanup(posting.ResetForTest)
posting.Register(rec)
g := workflow.Graph{Kind: "effect_probe", PostingOrder: workflow.AccrualFirst,
States: []workflow.StateSpec{st("draft", posting.Draft, false), st("posted", posting.Effect, false),
st("cancelled", posting.Cancelled, true)},
Edges: []workflow.EdgeSpec{ed("draft", "posted", workflow.GateReporter),
rejecting("posted", "cancelled", workflow.GateReporter, true)}}
var inst workflow.Instance
w.MustTx(func(ctx context.Context, tx pgx.Tx) error {
if _, err := workflow.PublishGraph(ctx, tx, w.Clock, g, nil); err != nil {
return err
}
var err error
inst, err = workflow.Start(ctx, tx, w.Clock, workflow.StartInput{Kind: "effect_probe", SourceID: uuid.New(),
Reporter: ann, Amount: money.Zero()})
return err
})
w.MustTx(func(ctx context.Context, tx pgx.Tx) error {
if _, err := workflow.Transition(ctx, tx, w.Clock, workflow.TransitionInput{InstanceID: inst.ID,
From: "draft", To: "posted", Actor: ann}); err != nil {
return err
}
got, err := workflow.Get(ctx, tx, inst.ID)
require.NoError(t, err)
require.True(t, got.AccrualOpen, "entering an effect opens the instance's posting")
if _, err := workflow.Transition(ctx, tx, w.Clock, workflow.TransitionInput{InstanceID: inst.ID,
From: "posted", To: "cancelled", Actor: ann, Reason: "Counted twice"}); err != nil {
return err
}
got, err = workflow.Get(ctx, tx, inst.ID)
require.NoError(t, err)
require.False(t, got.AccrualOpen)
return nil
})
require.Len(t, rec.got, 2)
require.Equal(t, posting.Effect, rec.got[0].Semantic)
require.False(t, rec.got[0].PastAccrual)
require.Equal(t, posting.Cancelled, rec.got[1].Semantic)
require.True(t, rec.got[1].PastAccrual, "the cancellation is a reversal")
}
func TestTheSoleOwnerExceptionCoversAnEffectKindsIssueAndVoid(t *testing.T) {
g := effectFlow()
g.Kind = "effect_sole_owner"
def := workflow.Definition{Graph: g}
issue, void := g.Edges[1], g.Edges[4]
// PRESENCE of the absence: an unregistered kind gets nothing.
require.False(t, workflow.SoleOwnerEdgeForTest(def, issue))
workflow.AllowSoleOwnerException(g.Kind, true)
require.True(t, workflow.SoleOwnerEdgeForTest(def, issue), "the edge into the effect state")
require.True(t, workflow.SoleOwnerEdgeForTest(def, void), "the reversing exit from the effect state")
require.False(t, workflow.SoleOwnerEdgeForTest(def, g.Edges[0]), "submitting is not covered")
}
  • Step 2: Run the tests to verify they fail

Run: go test ./internal/platform/posting/... ./internal/platform/accounting/... ./internal/platform/workflow/... -count=1 Expected: build failure undefined: posting.Effect / undefined: ledger.SemanticEffect.

  • Step 3: Vocabulary and migration (O1’s Task 1 already declared the 00720–00739 range)

constants.yaml, under workflowSemantics: after - committed:

- effect

db/migrations/00720_effect_semantic.sql:

-- +goose Up
-- Phase 4 O2.3 (docs/superpowers/specs/2026-09-30-operations-design.md): the
-- effect semantic — approved, and its effect posts with no control account
-- (a delivery's cost of sales, a stock adjustment, O4's asset disposal).
-- O1's 00700 named the check workflow_state_semantic_known (with committed).
ALTER TABLE workflow_state
DROP CONSTRAINT workflow_state_semantic_known,
ADD CONSTRAINT workflow_state_semantic_known CHECK (semantic IN ('draft', 'pending', 'accrual', 'payout',
'acknowledged', 'dispute', 'rejected', 'cancelled', 'custom', 'committed', 'effect'));
ALTER TABLE journal_entry
DROP CONSTRAINT journal_entry_semantic_known,
ADD CONSTRAINT journal_entry_semantic_known CHECK (semantic IN
('accrual', 'payout', 'reversal', 'manual', 'year_end_close', 'effect')),
DROP CONSTRAINT journal_entry_workflow_source,
ADD CONSTRAINT journal_entry_workflow_source CHECK (semantic NOT IN ('accrual', 'payout', 'effect')
OR (source_kind IS NOT NULL AND source_id IS NOT NULL AND instance_id IS NOT NULL));
-- +goose Down
-- Fails while any state or entry says effect: roll the code back first.
ALTER TABLE journal_entry
DROP CONSTRAINT journal_entry_workflow_source,
ADD CONSTRAINT journal_entry_workflow_source CHECK (semantic NOT IN ('accrual', 'payout')
OR (source_kind IS NOT NULL AND source_id IS NOT NULL AND instance_id IS NOT NULL)),
DROP CONSTRAINT journal_entry_semantic_known,
ADD CONSTRAINT journal_entry_semantic_known CHECK (semantic IN
('accrual', 'payout', 'reversal', 'manual', 'year_end_close'));
ALTER TABLE workflow_state
DROP CONSTRAINT workflow_state_semantic_known,
ADD CONSTRAINT workflow_state_semantic_known CHECK (semantic IN ('draft', 'pending', 'accrual', 'payout',
'acknowledged', 'dispute', 'rejected', 'cancelled', 'custom', 'committed'));
  • Step 4: The seam, the ledger and the Poster

internal/platform/posting/posting.go — after Custom Semantic = "custom" (and O1’s Committed):

// Effect (Phase 4 O2.3): approved, and its effect posts with no control
// account — a delivery's cost of sales, a stock adjustment, an asset
// disposal. It opens the instance's posting as an accrual does
// (Event.PastAccrual), so rejected/cancelled after it reverses.
Effect Semantic = "effect"

and in PostsToLedger:

case Accrual, Payout, Effect:
return true

internal/platform/accounting/ledger/types.go — in the semantics block:

SemanticEffect = "effect"

internal/platform/accounting/ledger/post.go validateHeader — both switches:

switch in.Semantic {
case SemanticAccrual, SemanticPayout, SemanticEffect, SemanticManual, SemanticYearEndClose:
default:
case SemanticAccrual, SemanticPayout, SemanticEffect:
if in.SourceKind == "" || in.SourceID == nil || in.InstanceID == nil {

internal/platform/accounting/ledger/query.go Unreversed:

AND e.semantic IN ('accrual', 'payout', 'effect')

internal/platform/accounting/rules.go Register:

if sem != posting.Accrual && sem != posting.Payout && sem != posting.Effect {
panic(fmt.Sprintf("accounting: posting rule for %q: semantic %q is not accrual, payout or effect (Ruling R7)", kind, sem))
}

(update the PostingRule doc comment: “Semantic is Accrual, Payout or Effect only”.)

internal/platform/accounting/poster.go — in Post, right after draft, err := rule.Build(...)’s error check:

// R-O2-1: an effect with nothing to post (a delivery of service items
// only) writes no entry; reverse below accepts its cancellation.
if ev.Semantic == posting.Effect && len(draft.Lines) == 0 {
return nil
}

and in reverse, replace the len(open) == 0 branch:

if len(open) == 0 {
// An effect kind may have posted nothing (R-O2-1): nothing to undo.
if _, effect := p.rules.Lookup(ev.Kind, posting.Effect); effect {
return nil
}
return fmt.Errorf("accounting: reverse on %s of %s %s: %w", ev.Semantic, ev.Kind, ev.SourceID, ErrNothingToReverse)
}
  • Step 5: The engine

internal/platform/workflow/validate.go:

var semantics = map[posting.Semantic]bool{
posting.Draft: true, posting.Pending: true, posting.Accrual: true, posting.Payout: true,
posting.Acknowledged: true, posting.Dispute: true, posting.Rejected: true,
posting.Cancelled: true, posting.Custom: true, posting.Committed: true, posting.Effect: true,
}

In rule3 (a), after the payout_on_cycle block:

if c.is(i, posting.Effect) {
c.add(3, "effect_on_cycle", c.g.States[i].Key, "%s can be entered twice without a reversal", c.g.States[i].Key)
cyclic = true
}

In rule3 (b):

for _, sem := range []posting.Semantic{posting.Accrual, posting.Payout, posting.Effect} {

In rule5:

if c.is(i, posting.Accrual) || c.is(i, posting.Payout) || c.is(i, posting.Effect) {

(and its comment: “at or past an accrual, a payout or an effect”).

internal/platform/workflow/instance.go — in Transition’s flag switch:

switch to.Semantic {
case posting.Accrual, posting.Effect: // R-O2-1: an effect is an accrual with no control
open = true

internal/platform/workflow/authorize.go reporterMayPost:

case posting.Accrual, posting.Effect:
return true

internal/platform/workflow/actors.go FinalApproval:

case to.Semantic != posting.Accrual && to.Semantic != posting.Payout && to.Semantic != posting.Effect:
return false

internal/platform/workflow/soleowner.go soleOwnerEdge:

posts := func(s posting.Semantic) bool { return s == posting.Accrual || s == posting.Effect }
void := posts(from.Semantic) && to.Semantic == posting.Cancelled && e.ReversesAccrual
return (m.void && void) || (m.issue && posts(to.Semantic)) || (m.payout && to.Semantic == posting.Payout)
  • Step 6: Contract and web

api/openapi.yaml: append effect to the enum of components.schemas.Semantic and of components.schemas.WorkflowSemantic (after committed). In WorkflowState.control_kind’s description keep “Required on, and only on, an accrual state.”

web/src/components/workflow/semanticTone.ts, in TONE after payout:

// Phase 4 O2.3: an approved stock effect (a delivery, an adjustment) — posted, like accrual.
effect: 'green',

Run: make generate && git status --short Expected: internal/kernel/vocab/vocab.gen.go, web vocab/schema and internal/gen/* change; nothing else unexpected.

  • Step 7: Run the tests to verify they pass

Run: go test ./internal/platform/posting/... ./internal/platform/accounting/... ./internal/platform/workflow/... ./internal/kernel/... -count=1 && pnpm -C web typecheck Expected: PASS.

  • Step 8: Sabotage

Remove posting.Effect from rule5’s condition; run go test ./internal/platform/workflow/ -run TestCancellingAfterAnEffectMustReverseIt -count=1; read the red (expected reversal_missing); restore. Then delete the len(draft.Lines) == 0 early return in Poster.Post; run go test ./internal/platform/accounting/ -run TestAnEffectWithNothingToPost -count=1; read the red (too few lines); restore.

  • Step 9: Commit
Terminal window
git add constants.yaml db/migrations/00720_effect_semantic.sql internal/ api/openapi.yaml web/
git commit -m "Phase 4 O2: the effect semantic
Approved, and its effect posts with no control account (spec O2.3).
Shares accrual_open; an effect draft with no lines posts nothing and
its cancellation reverses nothing (R-O2-1).
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: grni, the purchase-price-variance role, the inventory owned account, and the stock accounts step

Section titled “Task 2: grni, the purchase-price-variance role, the inventory owned account, and the stock accounts step”

Files:

  • Create: db/migrations/00721_grni_and_stock_accounts.sql
  • Modify: constants.yaml (controlKinds), then make generate
  • Modify: internal/platform/accounting/chart/account.go (IsSubledgerKind, role, resolver), internal/platform/accounting/chart/errors.go
  • Modify: internal/platform/accounting/chart/account_test.go (the two classification maps; one new test)
  • Create: internal/platform/inventory/step.go, internal/platform/inventory/step_test.go
  • Modify: api/openapi.yaml (Account.system_role, Account.owner_module, WorkflowState.control_kind enums), web/src/features/accounting/controlKinds.ts

Interfaces:

  • Consumes: nothing from Task 1.

  • Produces: inventory.ControlKindGRNI = "grni", inventory.OwnerModule = "inventory", chart.RolePurchasePriceVariance, chart.PurchasePriceVariance(ctx, tx) (chart.Account, error), chart.ErrNoPurchasePriceVariance; provisioning creates 1580 Inventory (owned), 2170 GRNI (grni), 5200 Cost of goods sold, 5300 Purchase price variance (role), 5400 Stock adjustment.

  • Step 1: Write the failing tests

internal/platform/accounting/chart/account_test.go — in TestControlKindsMatchTheVocabulary’s typeFor add "grni": chart.TypeLiability,; in TestEveryControlKindSaysWhetherManualLinesMayTouchIt’s refused add "grni": true,. Append:

func TestPurchasePriceVarianceIsOneExpenseAccount(t *testing.T) {
e := accountingtest.New(t)
role := chart.RolePurchasePriceVariance
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := chart.PurchasePriceVariance(ctx, tx)
require.ErrorIs(t, err, chart.ErrNoPurchasePriceVariance)
a, err := chart.CreateAccount(ctx, tx, e.Clock, chart.AccountInput{Code: "5300",
Name: "Purchase price variance", Type: chart.TypeExpense, SystemRole: &role})
require.NoError(t, err)
got, err := chart.PurchasePriceVariance(ctx, tx)
require.NoError(t, err)
require.Equal(t, a.ID, got.ID)
return nil
})
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := chart.CreateAccount(ctx, tx, e.Clock, chart.AccountInput{Code: "3300",
Name: "Variance", Type: chart.TypeEquity, SystemRole: &role})
return err
})
var pg *pgconn.PgError
require.ErrorAs(t, err, &pg)
require.Equal(t, "account_system_role_type", pg.ConstraintName)
}

(add "github.com/jackc/pgx/v5/pgconn" to the imports if absent.)

internal/platform/inventory/step_test.go:

package inventory_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/accounting/chart"
"github.com/mcom/erp/internal/platform/accounting/ledger"
"github.com/mcom/erp/internal/platform/expenses/expensestest"
"github.com/mcom/erp/internal/platform/i18n"
"github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/payables/payablestest"
)
func TestProvisioningGivesTheStockAccounts(t *testing.T) {
e := payablestest.New(t)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
inv, err := chart.GetAccountByCode(ctx, tx, "1580")
require.NoError(t, err)
require.Equal(t, chart.TypeAsset, inv.Type)
require.NotNil(t, inv.OwnerModule)
require.Equal(t, inventory.OwnerModule, *inv.OwnerModule)
grni, err := chart.ResolveControl(ctx, tx, inventory.ControlKindGRNI)
require.NoError(t, err)
require.Equal(t, "2170", grni.Code)
require.Equal(t, chart.TypeLiability, grni.Type)
ppv, err := chart.PurchasePriceVariance(ctx, tx)
require.NoError(t, err)
require.Equal(t, "5300", ppv.Code)
for _, code := range []string{"5200", "5400"} {
a, err := chart.GetAccountByCode(ctx, tx, code)
require.NoError(t, err)
require.Equal(t, chart.TypeExpense, a.Type)
}
names, err := i18n.Load(ctx, tx, "account", []uuid.UUID{grni.ID}, "name")
require.NoError(t, err)
require.NotEmpty(t, names[grni.ID], "the account is named in translation rows")
return nil
})
}
func TestNoManualLineMovesTheInventoryAccount(t *testing.T) {
e := payablestest.New(t)
manual := func(code string) error {
return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
a, err := chart.GetAccountByCode(ctx, tx, code)
if err != nil {
return err
}
capital, err := chart.GetAccountByCode(ctx, tx, "3100")
if err != nil {
return err
}
owner := e.Owner
_, err = ledger.Post(ctx, tx, e.Clock, ledger.EntryInput{JournalType: chart.JournalMisc,
EntryDate: expensestest.Today, Description: "Stock found", Semantic: ledger.SemanticManual, PostedBy: &owner,
Lines: []ledger.Line{{AccountID: a.ID, Debit: money.MustParse("5")},
{AccountID: capital.ID, Credit: money.MustParse("5")}}})
return err
})
}
// PRESENCE: the same entry against an unowned asset posts.
require.NoError(t, manual("1570"))
var owned *ledger.OwnedAccountManualError
require.ErrorAs(t, manual("1580"), &owned)
}
  • Step 2: Run the tests to verify they fail

Run: go test ./internal/platform/accounting/chart/... ./internal/platform/inventory/ -run 'ControlKind|PurchasePriceVariance|StockAccounts|InventoryAccount' -count=1 Expected: build failure undefined: chart.RolePurchasePriceVariance.

  • Step 3: Migration and vocabulary

constants.yaml, under controlKinds: after - wht_payable:

- grni

db/migrations/00721_grni_and_stock_accounts.sql:

-- +goose Up
-- Phase 4 O2.3: goods received not invoiced is a control kind (a receipt's
-- accrual credits it, the purchase invoice clears it; no manual line may
-- move it). Purchase price variance is a system role, one active expense
-- account. The provisioned Inventory account is owned by module inventory,
-- so only stock postings move it and its balance can equal the stock's
-- value (spec O2.5).
ALTER TABLE account DROP CONSTRAINT account_control_kind_known,
ADD CONSTRAINT account_control_kind_known CHECK (control_kind IN
('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable','grni'));
ALTER TABLE account DROP CONSTRAINT account_control_kind_type,
ADD CONSTRAINT account_control_kind_type CHECK (control_kind IS NULL
OR (control_kind IN ('ap','petty_cash_ap','ap_trade','sst_payable','wht_payable','grni') AND type = 'liability')
OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset')
OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense')));
ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_control_kind_known,
ADD CONSTRAINT workflow_state_control_kind_known CHECK (control_kind IN
('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable','grni'));
ALTER TABLE account DROP CONSTRAINT account_system_role_known,
ADD CONSTRAINT account_system_role_known
CHECK (system_role IN ('retained_earnings', 'migration_clearing', 'purchase_price_variance'));
ALTER TABLE account DROP CONSTRAINT account_system_role_equity,
ADD CONSTRAINT account_system_role_type CHECK (system_role IS NULL
OR (system_role IN ('retained_earnings', 'migration_clearing') AND type = 'equity')
OR (system_role = 'purchase_price_variance' AND type = 'expense'));
CREATE UNIQUE INDEX account_one_purchase_price_variance ON account (org_id)
WHERE is_active AND system_role = 'purchase_price_variance';
ALTER TABLE account DROP CONSTRAINT account_owner_module_known,
ADD CONSTRAINT account_owner_module_known CHECK (owner_module IS NULL OR owner_module IN ('pettycash', 'inventory'));
-- +goose Down
-- Fails while an account or state carries grni, the role or the owner:
-- roll the code back first.
ALTER TABLE account DROP CONSTRAINT account_owner_module_known,
ADD CONSTRAINT account_owner_module_known CHECK (owner_module IS NULL OR owner_module IN ('pettycash'));
DROP INDEX account_one_purchase_price_variance;
ALTER TABLE account DROP CONSTRAINT account_system_role_type,
ADD CONSTRAINT account_system_role_equity CHECK (system_role IS NULL OR type = 'equity');
ALTER TABLE account DROP CONSTRAINT account_system_role_known,
ADD CONSTRAINT account_system_role_known CHECK (system_role IN ('retained_earnings', 'migration_clearing'));
ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_control_kind_known,
ADD CONSTRAINT workflow_state_control_kind_known CHECK (control_kind IN
('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable'));
ALTER TABLE account DROP CONSTRAINT account_control_kind_type,
ADD CONSTRAINT account_control_kind_type CHECK (control_kind IS NULL
OR (control_kind IN ('ap','petty_cash_ap','ap_trade','sst_payable','wht_payable') AND type = 'liability')
OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset')
OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense')));
ALTER TABLE account DROP CONSTRAINT account_control_kind_known,
ADD CONSTRAINT account_control_kind_known CHECK (control_kind IN
('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable'));
  • Step 4: Chart

internal/platform/accounting/chart/account.go — after RoleMigrationClearing:

// RolePurchasePriceVariance is the expense account a purchase invoice's
// price difference from its goods receipt goes to (Phase 4 spec O2.3,
// Ruling R-1: to P&L, not back into stock). One active per organization
// (00721's account_one_purchase_price_variance).
const RolePurchasePriceVariance = "purchase_price_variance"

after MigrationClearing:

// PurchasePriceVariance answers the organization's active purchase price
// variance account, else ErrNoPurchasePriceVariance.
func PurchasePriceVariance(ctx context.Context, tx pgx.Tx) (Account, error) {
return bySystemRole(ctx, tx, RolePurchasePriceVariance, ErrNoPurchasePriceVariance)
}

and in IsSubledgerKind add "grni" to the true case (update its comment: “…and grni, owned by inventory’s receipts and the purchase invoices that clear them”).

internal/platform/accounting/chart/errors.go — with the other sentinels:

// ErrNoPurchasePriceVariance: no active account carries the
// purchase_price_variance role (provisioning step inventory_stock_accounts).
ErrNoPurchasePriceVariance = errors.New("chart: no active account is designated purchase price variance")
  • Step 5: The provisioning step

internal/platform/inventory/step.go:

package inventory
import (
"context"
"errors"
"fmt"
"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/tenancy"
"github.com/mcom/erp/internal/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/provisioning"
)
const (
// OwnerModule owns the provisioned Inventory account: no manual journal
// line may move it (00054's owner_module, 00721).
OwnerModule = "inventory"
// ControlKindGRNI is goods received not invoiced (spec O2.3).
ControlKindGRNI = "grni"
// The stock accounts step, order 81 (R-O2-18): after the chart (70) and
// every account step before it (75–79). A new organization gets the
// accounts on its first provisioning, an existing one on its next
// POST /v1/org/provision.
StepKeyStockAccounts = "inventory_stock_accounts"
StepOrderStockAccounts = 81
)
func init() { provisioning.Register(stockAccountsStep{}) }
func strp(s string) *string { return &s }
// stockAccount is one account the step gives an organization that lacks
// it. A code already taken by something else counts as present (as
// payables' steps do): chart_of_accounts' control_kind_unmapped warning is
// what an administrator then sees.
type stockAccount struct {
code, parent, typ string
names map[string]string
controlKind, systemRole, ownerModule *string
}
var stockAccounts = []stockAccount{
{code: "1580", parent: "1500", typ: chart.TypeAsset, ownerModule: strp(OwnerModule),
names: map[string]string{"en": "Inventory", "ms": "Inventori", "zh": "存货"}},
{code: "2170", parent: "2100", typ: chart.TypeLiability, controlKind: strp(ControlKindGRNI),
names: map[string]string{"en": "Goods received not invoiced", "ms": "Barang diterima belum diinvois", "zh": "已收货未开票"}},
{code: "5200", parent: "5000", typ: chart.TypeExpense,
names: map[string]string{"en": "Cost of goods sold", "ms": "Kos barang dijual", "zh": "销货成本"}},
{code: "5300", parent: "5000", typ: chart.TypeExpense, systemRole: strp(chart.RolePurchasePriceVariance),
names: map[string]string{"en": "Purchase price variance", "ms": "Varians harga belian", "zh": "采购价格差异"}},
{code: "5400", parent: "5000", typ: chart.TypeExpense,
names: map[string]string{"en": "Stock adjustment", "ms": "Pelarasan stok", "zh": "存货调整"}},
}
func (a stockAccount) present(ctx context.Context, tx pgx.Tx) (bool, error) {
switch {
case a.controlKind != nil:
if _, err := chart.ResolveControl(ctx, tx, *a.controlKind); err == nil {
return true, nil
}
case a.systemRole != nil:
if _, err := chart.PurchasePriceVariance(ctx, tx); err == nil {
return true, nil
}
}
_, err := chart.GetAccountByCode(ctx, tx, a.code)
if errors.Is(err, chart.ErrNotFound) {
return false, nil
}
return err == nil, err
}
type stockAccountsStep struct{}
func (stockAccountsStep) Key() string { return StepKeyStockAccounts }
func (stockAccountsStep) Order() int { return StepOrderStockAccounts }
func (stockAccountsStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) {
for _, a := range stockAccounts {
ok, err := a.present(ctx, tx)
if err != nil {
return false, nil, fmt.Errorf("inventory: stock accounts check: %w", err)
}
if !ok {
return false, nil, nil
}
}
return true, nil, nil
}
func (stockAccountsStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error {
org, ok := tenancy.OrgFromContext(ctx)
if !ok {
return fmt.Errorf("inventory: stock accounts: %w", tenancy.ErrNoOrg)
}
for _, s := range stockAccounts {
ok, err := s.present(ctx, tx)
if err != nil {
return fmt.Errorf("inventory: stock accounts: %w", err)
}
if ok {
continue
}
in := chart.AccountInput{Code: s.code, Name: s.names["en"], Type: s.typ,
ControlKind: s.controlKind, SystemRole: s.systemRole, OwnerModule: s.ownerModule}
if parent, err := chart.GetAccountByCode(ctx, tx, s.parent); err == nil && !parent.IsPostable && parent.Type == s.typ {
in.ParentID = &parent.ID
}
a, err := chart.CreateAccount(ctx, tx, c, in)
if err != nil {
return fmt.Errorf("inventory: create account %s: %w", s.code, err)
}
for locale, name := range s.names {
if _, err := tx.Exec(ctx, `
INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value)
VALUES ($1, 'account', $2, 'name', $3, $4)
ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO NOTHING`,
org, a.ID, locale, name); err != nil {
return fmt.Errorf("inventory: account %s translation: %w", s.code, err)
}
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "account", EntityID: a.ID,
Action: "translate", After: s.names}); err != nil {
return err
}
}
return nil
}

(The step writes several audit rows: each chart.CreateAccount audits after the previous account’s translation rows, which are INSERTs of new rows only — the pattern of imports’ clearing step.)

  • Step 6: Contract and web

api/openapi.yaml: Account.system_role enum → [retained_earnings, migration_clearing, purchase_price_variance] (description: “purchase_price_variance is the expense account a purchase invoice’s price difference from its receipt goes to.”); Account.owner_module enum → [pettycash, inventory]; WorkflowState.control_kind enum append grni.

web/src/features/accounting/controlKinds.ts, in MANUAL_REFUSED after wht_payable: true,:

grni: true,

Run: make generate && git status --short

  • Step 7: Run the tests to verify they pass

Run: go test ./internal/platform/accounting/... ./internal/platform/inventory/ ./internal/platform/countrypack/... ./internal/app/ -count=1 && pnpm -C web typecheck Expected: PASS (TestProvisioningStepOrdersAreUnique included).

  • Step 8: Sabotage

Remove "grni" from IsSubledgerKind; run go test ./internal/platform/accounting/chart/ -run TestEveryControlKindSaysWhether -count=1; read the red; restore. Change the 1580 seed’s ownerModule to nil; run go test ./internal/platform/inventory/ -run TestNoManualLine -count=1; read the red; restore.

  • Step 9: Commit
Terminal window
git add constants.yaml db/migrations/00721_grni_and_stock_accounts.sql internal/ api/openapi.yaml web/
git commit -m "Phase 4 O2: grni, purchase price variance, the owned Inventory account
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: The stock ledger — moves, balances, lots, the test world

Section titled “Task 3: The stock ledger — moves, balances, lots, the test world”

Files:

  • Create: db/migrations/00722_stock_ledger.sql
  • Modify: internal/kernel/money/rate.go, internal/kernel/money/rate_test.go
  • Create: internal/platform/inventory/stock.go
  • Create: internal/platform/inventory/stock_export_test.go
  • Create: internal/platform/inventory/stocktest/stocktest.go
  • Create: internal/platform/inventory/stock_ledger_test.go
  • Modify: internal/kernel/db/retention_test.go

Interfaces:

  • Consumes: Task 2’s accounts (1580, 2170, 5100, 5200, 5300, 5400); O1’s tradetest.Env, inventory.CreateItem, the C62 unit, item_fixed_once_used().

  • Produces: money.Rate.Sub/Neg/IsNegative; in package inventory: CostingFIFO, CostingWeightedAverage, the five Kind* constants, every Err* of this task, mapStockError(err error) error, stockOrg(ctx) (uuid.UUID, error), type moveInput, insertMove(ctx, tx, c, moveInput) (uuid.UUID, error); stocktest.New(t) *Env (embeds *tradetest.Env) with WH1, WH2, Unit, InventoryAccount, PurchaseAccount, COGSAccount, GRNI, PPV, AdjustAccount, Item, ItemOn, TransferRow, OnHand, RequireBalancesAddUp, Qty, Amt, RequireQty, RequireAmount, RequirePg.

  • Step 1: Write the failing tests

internal/kernel/money/rate_test.go — append:

func TestRateArithmeticForQuantities(t *testing.T) {
a, b := MustParseRate("10.5"), MustParseRate("4")
require.Zero(t, a.Sub(b).Cmp(MustParseRate("6.5")))
require.Zero(t, b.Neg().Cmp(MustParseRate("-4")))
require.True(t, b.Neg().IsNegative())
require.False(t, b.IsNegative())
require.False(t, a.Sub(a).IsNegative(), "zero is not negative")
}

(if rate_test.go is in package money_test, prefix with money..)

internal/platform/inventory/stock_export_test.go:

package inventory
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/money"
)
// InsertMoveForTest is insertMove for the schema tests: a costless move
// sourced from a transfer row, with no document behind it.
func InsertMoveForTest(ctx context.Context, tx pgx.Tx, c clock.Clock, item, location, transfer uuid.UUID,
qty money.Rate, serial, batch string) (uuid.UUID, error) {
return insertMove(ctx, tx, c, moveInput{ItemID: item, LocationID: location, Quantity: qty,
Cost: money.Zero(), SerialNo: serial, BatchNo: batch, TransferID: &transfer})
}

internal/platform/inventory/stocktest/stocktest.go:

// Package stocktest is the stock test world: O1's tradetest organization
// (provisioned Malaysian books, Owner in the OWNER seat, Staff below it)
// with two warehouses, the C62 unit and the stock accounts provisioning
// gave it, and items and stock documents one call away. Items and
// locations go through O1's inventory.CreateItem and tradetest.Location.
package stocktest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"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/trade/tradetest"
)
type Env struct {
*tradetest.Env
WH1, WH2 uuid.UUID
Unit uuid.UUID
InventoryAccount, PurchaseAccount, COGSAccount uuid.UUID
GRNI, PPV, AdjustAccount uuid.UUID
}
func New(t *testing.T) *Env {
t.Helper()
e := &Env{Env: tradetest.New(t)}
e.WH1, e.WH2 = e.Location(t, "WH1").ID, e.Location(t, "WH2").ID
e.Unit = e.UOM(t, "C62")
e.InventoryAccount, e.PurchaseAccount, e.COGSAccount = e.Account(t, "1580"), e.Account(t, "5100"), e.Account(t, "5200")
e.GRNI, e.PPV, e.AdjustAccount = e.Account(t, "2170"), e.Account(t, "5300"), e.Account(t, "5400")
return e
}
// Item is a C62 item on the provisioned accounts: a stock item on inventory
// 1580 and cost of goods sold 5200; every item on purchases 5100 (a service
// received on a GRN is expensed there, R-O2-17).
func (e *Env) Item(t *testing.T, code, typ, tracking string) uuid.UUID {
t.Helper()
return e.ItemOn(t, code, typ, tracking, e.InventoryAccount)
}
// ItemOn is Item with another inventory account.
func (e *Env) ItemOn(t *testing.T, code, typ, tracking string, inventoryAccount uuid.UUID) uuid.UUID {
t.Helper()
purchase := e.PurchaseAccount
in := inventory.ItemInput{Code: code, Type: typ, Tracking: tracking, Names: inventory.Names{"en": code + " item"},
BaseUOMID: e.Unit, PurchaseAccountID: &purchase, IsActive: true}
if typ == inventory.TypeStock { // O1's item_accounts_by_type
inv, cogs := inventoryAccount, e.COGSAccount
in.InventoryAccountID, in.COGSAccountID = &inv, &cogs
}
var id uuid.UUID
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
it, err := inventory.CreateItem(ctx, tx, e.Clock, in)
id = it.ID
return err
})
return id
}
// TransferRow is a bare stock_transfer row, a source for schema-test moves.
func (e *Env) TransferRow(t *testing.T, item, from, to uuid.UUID) uuid.UUID {
t.Helper()
var id uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
INSERT INTO stock_transfer (org_id, item_id, from_location_id, to_location_id, quantity, transferred_on, created_by, created_at)
VALUES ($1, $2, $3, $4, 1, DATE '2026-09-21', $5, now()) RETURNING id`,
e.Org, item, from, to, e.Owner).Scan(&id))
return id
}
// OnHand is stock_balance.on_hand for (item, location, lot), zero when absent.
func (e *Env) OnHand(t *testing.T, item, location uuid.UUID, lot string) money.Rate {
t.Helper()
q := money.MustParseRate("0")
err := e.H.Admin.QueryRow(context.Background(), `
SELECT on_hand FROM stock_balance WHERE org_id = $1 AND item_id = $2 AND location_id = $3 AND lot = $4`,
e.Org, item, location, lot).Scan(&q)
if !errors.Is(err, pgx.ErrNoRows) {
require.NoError(t, err)
}
return q
}
// RequireBalancesAddUp is O2's drift check: every stock_balance row equals
// the sum of its moves, and every (item, location, lot) that moved has one.
func (e *Env) RequireBalancesAddUp(t *testing.T) {
t.Helper()
var rows, bad int
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
WITH moved AS (
SELECT item_id, location_id, coalesce(serial_no, batch_no, '') AS lot, sum(quantity) AS q
FROM stock_move WHERE org_id = $1 GROUP BY 1, 2, 3),
held AS (SELECT item_id, location_id, lot, on_hand FROM stock_balance WHERE org_id = $1)
SELECT count(*), count(*) FILTER (WHERE h.on_hand IS DISTINCT FROM m.q)
FROM moved m FULL JOIN held h USING (item_id, location_id, lot)`, e.Org).Scan(&rows, &bad))
require.Positive(t, rows, "no stock moved; the comparison would be vacuous")
require.Zero(t, bad, "stock_balance disagrees with the sum of stock_move")
}
func Qty(s string) money.Rate { return money.MustParseRate(s) }
func Amt(s string) money.Amount { return money.MustParse(s) }
func RequireQty(t *testing.T, want string, got money.Rate, msg ...any) {
t.Helper()
require.Zero(t, Qty(want).Cmp(got), append([]any{"want %s, got %s", want, got}, msg...)...)
}
func RequireAmount(t *testing.T, want string, got money.Amount, msg ...any) {
t.Helper()
require.Zero(t, Amt(want).Cmp(got), append([]any{"want %s, got %s", want, got}, msg...)...)
}
// RequirePg asserts err is a Postgres error with this SQLSTATE and, when
// constraint is not "", this constraint.
func RequirePg(t *testing.T, err error, code, constraint string) {
t.Helper()
var pg *pgconn.PgError
require.ErrorAs(t, err, &pg)
require.Equal(t, code, pg.Code, pg.Message)
if constraint != "" {
require.Equal(t, constraint, pg.ConstraintName)
}
}

internal/platform/inventory/stock_ledger_test.go:

package inventory_test
import (
"context"
"testing"
"github.com/google/uuid"
"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"
)
// mover inserts one costless move per call, in its own transaction.
func mover(e *stocktest.Env, transfer uuid.UUID) func(item, loc uuid.UUID, q, serial, batch string) error {
return func(item, loc uuid.UUID, q, serial, batch string) error {
return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := inventory.InsertMoveForTest(ctx, tx, e.Clock, item, loc, transfer, stocktest.Qty(q), serial, batch)
return err
})
}
}
func TestTheBalanceIsTheSumOfItsMoves(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
move := mover(e, e.TransferRow(t, bolt, e.WH1, e.WH2))
require.NoError(t, move(bolt, e.WH1, "10", "", ""))
require.NoError(t, move(bolt, e.WH1, "-4", "", ""))
require.NoError(t, move(bolt, e.WH2, "3", "", ""))
stocktest.RequireQty(t, "6", e.OnHand(t, bolt, e.WH1, ""))
stocktest.RequireQty(t, "3", e.OnHand(t, bolt, e.WH2, ""))
e.RequireBalancesAddUp(t)
}
func TestStockCannotGoBelowZero(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
move := mover(e, e.TransferRow(t, bolt, e.WH1, e.WH2))
require.NoError(t, move(bolt, e.WH1, "5", "", ""))
// PRESENCE: taking everything held is allowed.
require.NoError(t, move(bolt, e.WH1, "-5", "", ""))
require.ErrorIs(t, move(bolt, e.WH1, "-1", "", ""), inventory.ErrInsufficientStock)
require.ErrorIs(t, move(bolt, e.WH2, "-1", "", ""), inventory.ErrInsufficientStock, "a location never stocked")
stocktest.RequireQty(t, "0", e.OnHand(t, bolt, e.WH1, ""))
}
func TestATrackedItemMovesWithItsLot(t *testing.T) {
e := stocktest.New(t)
phone := e.Item(t, "PHONE", "stock", "serial")
rice := e.Item(t, "RICE", "stock", "batch")
bolt := e.Item(t, "BOLT", "stock", "none")
labour := e.Item(t, "LABOUR", "service", "none")
move := mover(e, e.TransferRow(t, phone, e.WH1, e.WH2))
// PRESENCE: each item moves with exactly its lot.
require.NoError(t, move(phone, e.WH1, "1", "SN-1", ""))
require.NoError(t, move(rice, e.WH1, "25", "", "B-7"))
require.NoError(t, move(bolt, e.WH1, "3", "", ""))
require.ErrorIs(t, move(phone, e.WH1, "1", "", ""), inventory.ErrSerialRequired)
require.ErrorIs(t, move(phone, e.WH1, "2", "SN-2", ""), inventory.ErrSerialRequired, "one unit per serial")
require.ErrorIs(t, move(rice, e.WH1, "1", "", ""), inventory.ErrBatchRequired)
require.ErrorIs(t, move(bolt, e.WH1, "1", "", "B-1"), inventory.ErrUntrackedLot)
require.ErrorIs(t, move(labour, e.WH1, "1", "", ""), inventory.ErrNotStockItem)
// One place holds a serial at a time; it may leave and arrive elsewhere.
require.ErrorIs(t, move(phone, e.WH2, "1", "SN-1", ""), inventory.ErrSerialHeld)
require.NoError(t, move(phone, e.WH1, "-1", "SN-1", ""))
require.NoError(t, move(phone, e.WH2, "1", "SN-1", ""))
stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH2, "SN-1"))
stocktest.RequireQty(t, "25", e.OnHand(t, rice, e.WH1, "B-7"))
e.RequireBalancesAddUp(t)
}
func TestOnlyAMoveWritesTheBalanceAndNoMoveIsEdited(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
move := mover(e, e.TransferRow(t, bolt, e.WH1, e.WH2))
require.NoError(t, move(bolt, e.WH1, "2", "", ""))
// PRESENCE: the rows exist for the app role to try.
stocktest.RequireQty(t, "2", e.OnHand(t, bolt, e.WH1, ""))
exec := func(sql string) error {
return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, sql, e.Org)
return err
})
}
stocktest.RequirePg(t, exec(`UPDATE stock_balance SET on_hand = 100 WHERE org_id = $1`), "23514", "stock_balance_by_move")
stocktest.RequirePg(t, exec(`INSERT INTO stock_balance (org_id, item_id, location_id, lot, lot_kind, on_hand)
SELECT org_id, item_id, $1::uuid, lot, lot_kind, 5 FROM stock_balance WHERE org_id = $1`), "23514", "")
stocktest.RequirePg(t, exec(`UPDATE stock_move SET quantity = 100 WHERE org_id = $1`), "42501", "")
stocktest.RequirePg(t, exec(`DELETE FROM stock_move WHERE org_id = $1`), "42501", "")
stocktest.RequireQty(t, "2", e.OnHand(t, bolt, e.WH1, ""))
}
func TestAnItemsInventoryAccountIsFixedOnceItHasMoved(t *testing.T) {
e := stocktest.New(t)
still := e.Item(t, "STILL", "stock", "none")
moved := e.Item(t, "MOVED", "stock", "none")
require.NoError(t, mover(e, e.TransferRow(t, moved, e.WH1, e.WH2))(moved, e.WH1, "1", "", ""))
other := e.Account(t, "1570")
repoint := func(item uuid.UUID) error {
return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE item SET inventory_account_id = $3 WHERE org_id = $1 AND id = $2`, e.Org, item, other)
return err
})
}
require.NoError(t, repoint(still), "PRESENCE: an item that never moved may change account")
stocktest.RequirePg(t, repoint(moved), "23514", "item_fixed")
// O1's rule looked only at trade lines; a transfer names none (R-O2-10).
err := e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE item SET tracking = 'batch' WHERE org_id = $1 AND id = $2`, e.Org, moved)
return err
})
stocktest.RequirePg(t, err, "23514", "item_fixed")
}

(The second exec passes e.Org as $1 and as a bogus location id: the guard refuses before the foreign key is checked — stock_balance_guard is BEFORE INSERT.)

  • Step 2: Run the tests to verify they fail

Run: go test ./internal/kernel/money/ ./internal/platform/inventory/ -count=1 Expected: build failure a.Sub undefined / undefined: inventory.InsertMoveForTest.

  • Step 3: money.Rate helpers

internal/kernel/money/rate.go, after Add:

// Sub, Neg and IsNegative make a Rate usable as a stock quantity (Phase 4
// O2): quantities are NUMERIC(19,6), carried as Rate like AR's line
// quantity — dimensionless, and never money.
func (r Rate) Sub(o Rate) Rate { return Rate{d: r.d.Sub(o.d)} }
func (r Rate) Neg() Rate { return Rate{d: r.d.Neg()} }
func (r Rate) IsNegative() bool { return r.d.IsNegative() }
  • Step 4: Migration

db/migrations/00722_stock_ledger.sql:

-- +goose Up
-- Phase 4 O2.1 (docs/superpowers/specs/2026-09-30-operations-design.md): the
-- stock ledger. stock_move is append-only; stock_balance is its running sum,
-- written only by stock_move's own trigger, and refuses to go below zero
-- (OD-2). Cost is per item across the organization (item_cost, 00724), so a
-- transfer moves quantity and no value (R-O2-5). A move has no journal
-- entry column: it is written before the entry that reads it (R-O2-2).
CREATE TABLE stock_transfer (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
item_id UUID NOT NULL,
from_location_id UUID NOT NULL,
to_location_id UUID NOT NULL,
quantity NUMERIC(19,6) NOT NULL CONSTRAINT stock_transfer_quantity_positive CHECK (quantity > 0),
transferred_on DATE NOT NULL,
note TEXT NOT NULL DEFAULT ''
CONSTRAINT stock_transfer_note_language CHECK (note !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT stock_transfer_tenant_id UNIQUE (org_id, id),
CONSTRAINT stock_transfer_two_locations CHECK (from_location_id <> to_location_id),
CONSTRAINT stock_transfer_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id),
CONSTRAINT stock_transfer_from_fk FOREIGN KEY (org_id, from_location_id) REFERENCES location (org_id, id),
CONSTRAINT stock_transfer_to_fk FOREIGN KEY (org_id, to_location_id) REFERENCES location (org_id, id),
CONSTRAINT stock_transfer_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id)
);
CREATE TABLE stock_move (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
seq BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY,
item_id UUID NOT NULL,
location_id UUID NOT NULL,
quantity NUMERIC(19,6) NOT NULL CONSTRAINT stock_move_quantity_nonzero CHECK (quantity <> 0),
unit_cost NUMERIC(19,4) NOT NULL CONSTRAINT stock_move_unit_cost_nonnegative CHECK (unit_cost >= 0),
total_cost NUMERIC(19,4) NOT NULL,
move_date DATE NOT NULL,
serial_no TEXT CONSTRAINT stock_move_serial_shape CHECK (serial_no IS NULL OR btrim(serial_no) <> ''),
batch_no TEXT CONSTRAINT stock_move_batch_shape CHECK (batch_no IS NULL OR btrim(batch_no) <> ''),
trade_line_id UUID,
transfer_id UUID,
reverses_move_id UUID,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT stock_move_tenant_id UNIQUE (org_id, id),
CONSTRAINT stock_move_cost_follows_quantity CHECK (total_cost = 0 OR sign(total_cost) = sign(quantity)),
CONSTRAINT stock_move_one_source CHECK (num_nonnulls(trade_line_id, transfer_id) = 1),
CONSTRAINT stock_move_transfer_moves_no_value CHECK (transfer_id IS NULL OR total_cost = 0),
CONSTRAINT stock_move_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id),
CONSTRAINT stock_move_location_fk FOREIGN KEY (org_id, location_id) REFERENCES location (org_id, id),
CONSTRAINT stock_move_trade_line_fk FOREIGN KEY (org_id, trade_line_id) REFERENCES trade_line (org_id, id),
CONSTRAINT stock_move_transfer_fk FOREIGN KEY (org_id, transfer_id) REFERENCES stock_transfer (org_id, id),
CONSTRAINT stock_move_reverses_fk FOREIGN KEY (org_id, reverses_move_id) REFERENCES stock_move (org_id, id)
);
CREATE UNIQUE INDEX stock_move_reversed_once ON stock_move (org_id, reverses_move_id) WHERE reverses_move_id IS NOT NULL;
CREATE INDEX stock_move_line_idx ON stock_move (org_id, trade_line_id) WHERE trade_line_id IS NOT NULL;
CREATE INDEX stock_move_item_idx ON stock_move (org_id, item_id, move_date, seq);
-- lot is '' for an untracked item, else the serial or batch number. A
-- serial is held by one location at a time (R-O2-11).
CREATE TABLE stock_balance (
org_id UUID NOT NULL REFERENCES organization (id),
item_id UUID NOT NULL,
location_id UUID NOT NULL,
lot TEXT NOT NULL,
lot_kind TEXT NOT NULL CONSTRAINT stock_balance_lot_kind_known CHECK (lot_kind IN ('none', 'batch', 'serial')),
on_hand NUMERIC(19,6) NOT NULL,
CONSTRAINT stock_balance_pkey PRIMARY KEY (org_id, item_id, location_id, lot),
CONSTRAINT stock_balance_on_hand_nonnegative CHECK (on_hand >= 0),
CONSTRAINT stock_balance_serial_is_one CHECK (lot_kind <> 'serial' OR on_hand <= 1),
CONSTRAINT stock_balance_lot_matches_kind CHECK ((lot_kind = 'none') = (lot = '')),
CONSTRAINT stock_balance_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id),
CONSTRAINT stock_balance_location_fk FOREIGN KEY (org_id, location_id) REFERENCES location (org_id, id)
);
CREATE UNIQUE INDEX stock_balance_serial_held_once ON stock_balance (org_id, item_id, lot)
WHERE lot_kind = 'serial' AND on_hand > 0;
-- A batch's expiry, the first one a receipt gave it.
CREATE TABLE stock_batch (
org_id UUID NOT NULL REFERENCES organization (id),
item_id UUID NOT NULL,
batch_no TEXT NOT NULL,
expires_on DATE,
CONSTRAINT stock_batch_pkey PRIMARY KEY (org_id, item_id, batch_no),
CONSTRAINT stock_batch_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id)
);
-- A move names the lot its item's tracking demands, and only a stock item
-- moves (spec O2.4: refused by the database).
-- +goose StatementBegin
CREATE FUNCTION stock_move_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
item_type TEXT;
item_tracking TEXT;
BEGIN
SELECT i.type, i.tracking INTO item_type, item_tracking
FROM public.item i WHERE i.org_id = NEW.org_id AND i.id = NEW.item_id;
IF item_type IS DISTINCT FROM 'stock' THEN
RAISE EXCEPTION 'item %: only a stock item moves stock', NEW.item_id
USING ERRCODE = '23514', CONSTRAINT = 'stock_move_stock_item';
END IF;
IF item_tracking = 'serial' AND (NEW.serial_no IS NULL OR NEW.batch_no IS NOT NULL OR abs(NEW.quantity) <> 1) THEN
RAISE EXCEPTION 'item %: a serial-tracked item moves one unit per serial number', NEW.item_id
USING ERRCODE = '23514', CONSTRAINT = 'stock_move_serial_required';
ELSIF item_tracking = 'batch' AND (NEW.batch_no IS NULL OR NEW.serial_no IS NOT NULL) THEN
RAISE EXCEPTION 'item %: a batch-tracked item moves with its batch number', NEW.item_id
USING ERRCODE = '23514', CONSTRAINT = 'stock_move_batch_required';
ELSIF item_tracking = 'none' AND (NEW.serial_no IS NOT NULL OR NEW.batch_no IS NOT NULL) THEN
RAISE EXCEPTION 'item %: an untracked item takes no serial or batch number', NEW.item_id
USING ERRCODE = '23514', CONSTRAINT = 'stock_move_untracked';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER stock_move_guard BEFORE INSERT ON stock_move
FOR EACH ROW EXECUTE FUNCTION stock_move_guard();
-- The balance moves with every move, in the same statement, under the row
-- lock the upsert takes (spec O2.1).
-- +goose StatementBegin
CREATE FUNCTION stock_move_apply() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
kind TEXT;
BEGIN
SELECT CASE i.tracking WHEN 'serial' THEN 'serial' WHEN 'batch' THEN 'batch' ELSE 'none' END INTO kind
FROM public.item i WHERE i.org_id = NEW.org_id AND i.id = NEW.item_id;
INSERT INTO public.stock_balance (org_id, item_id, location_id, lot, lot_kind, on_hand)
VALUES (NEW.org_id, NEW.item_id, NEW.location_id, coalesce(NEW.serial_no, NEW.batch_no, ''), kind, NEW.quantity)
ON CONFLICT ON CONSTRAINT stock_balance_pkey
DO UPDATE SET on_hand = public.stock_balance.on_hand + EXCLUDED.on_hand;
RETURN NULL;
END $$;
-- +goose StatementEnd
CREATE TRIGGER stock_move_apply AFTER INSERT ON stock_move
FOR EACH ROW EXECUTE FUNCTION stock_move_apply();
-- Anything else that writes stock_balance is refused: only the trigger
-- above (depth 2) may — the pattern of 00150's petty_cash_float guard.
-- +goose StatementBegin
CREATE FUNCTION stock_balance_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF pg_trigger_depth() < 2 THEN
RAISE EXCEPTION 'stock_balance moves only with a stock_move'
USING ERRCODE = '23514', CONSTRAINT = 'stock_balance_by_move';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER stock_balance_guard BEFORE INSERT OR UPDATE ON stock_balance
FOR EACH ROW EXECUTE FUNCTION stock_balance_guard();
-- R-O2-10: O1's item_fixed_once_used() (00711) fixes an item's type, base
-- unit and tracking once a trade line names it. Replaced (O1 asks O2 to) so
-- they are also fixed once a stock_move exists — a transfer names no trade
-- line — and the inventory account once the item has moved: valuation
-- compares each inventory account with its items' value. The constraint
-- name stays item_fixed, which O1's errors already map.
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION item_fixed_once_used() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
moved BOOLEAN;
BEGIN
moved := EXISTS (SELECT 1 FROM public.stock_move m WHERE m.org_id = NEW.org_id AND m.item_id = NEW.id);
IF (NEW.type IS DISTINCT FROM OLD.type OR NEW.base_uom_id IS DISTINCT FROM OLD.base_uom_id
OR NEW.tracking IS DISTINCT FROM OLD.tracking)
AND (moved OR EXISTS (SELECT 1 FROM public.trade_line l WHERE l.org_id = NEW.org_id AND l.item_id = NEW.id)) THEN
RAISE EXCEPTION 'item %: a document or a stock move names it, so its type, base unit and tracking are fixed', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'item_fixed';
END IF;
IF NEW.inventory_account_id IS DISTINCT FROM OLD.inventory_account_id AND moved THEN
RAISE EXCEPTION 'item %: its stock has moved, so its inventory account is fixed', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'item_fixed';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
DROP TRIGGER item_fixed_once_used ON item;
CREATE TRIGGER item_fixed_once_used BEFORE UPDATE OF type, base_uom_id, tracking, inventory_account_id ON item
FOR EACH ROW WHEN (NEW.type IS DISTINCT FROM OLD.type OR NEW.base_uom_id IS DISTINCT FROM OLD.base_uom_id
OR NEW.tracking IS DISTINCT FROM OLD.tracking
OR NEW.inventory_account_id IS DISTINCT FROM OLD.inventory_account_id)
EXECUTE FUNCTION item_fixed_once_used();
ALTER TABLE stock_transfer ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock_transfer FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON stock_transfer
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE stock_move ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock_move FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON stock_move
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE stock_balance ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock_balance FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON stock_balance
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE stock_batch ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock_batch FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON stock_batch
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 stock_move, stock_transfer FROM erp_app;
REVOKE DELETE ON stock_balance, stock_batch FROM erp_app;
GRANT USAGE ON SEQUENCE stock_move_seq_seq TO erp_app;
-- +goose Down
-- O1's 00711 function and trigger, as they were.
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION item_fixed_once_used() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF EXISTS (SELECT 1 FROM public.trade_line WHERE org_id = NEW.org_id AND item_id = NEW.id) THEN
RAISE EXCEPTION 'item %: a document names it, so its type, base unit and tracking are fixed', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'item_fixed';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
DROP TRIGGER item_fixed_once_used ON item;
CREATE TRIGGER item_fixed_once_used BEFORE UPDATE OF type, base_uom_id, tracking ON item
FOR EACH ROW WHEN (NEW.type IS DISTINCT FROM OLD.type OR NEW.base_uom_id IS DISTINCT FROM OLD.base_uom_id
OR NEW.tracking IS DISTINCT FROM OLD.tracking)
EXECUTE FUNCTION item_fixed_once_used();
DROP TABLE stock_balance;
DROP TABLE stock_batch;
DROP TABLE stock_move;
DROP TABLE stock_transfer;
DROP FUNCTION stock_balance_guard();
DROP FUNCTION stock_move_apply();
DROP FUNCTION stock_move_guard();

(GENERATED ALWAYS AS IDENTITY owns an implicit sequence; if \d stock_move shows another name than stock_move_seq_seq, use that name — journal_entry_posted_seq_seq in 00052 is the precedent.)

  • Step 5: stock.go

internal/platform/inventory/stock.go:

package inventory
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// Costing methods, company_profile.costing_method (OD-1).
const (
CostingFIFO = "fifo"
CostingWeightedAverage = "weighted_average"
)
// The trade document kinds whose posting moves stock (spec O2.3). Literals:
// the trade package's constants are O1's, and inventory does not import trade.
const (
KindGoodsReceipt = "goods_receipt"
KindPurchaseReturn = "purchase_return"
KindDeliveryOrder = "delivery_order"
KindSalesReturn = "sales_return"
KindStockAdjustment = "stock_adjustment"
)
const itemTypeStock = "stock"
var (
ErrInsufficientStock = errors.New("inventory: not enough stock")
ErrSerialRequired = errors.New("inventory: a serial-tracked item moves one unit per serial number")
ErrBatchRequired = errors.New("inventory: a batch-tracked item moves with its batch number")
ErrUntrackedLot = errors.New("inventory: an untracked item takes no serial or batch number")
ErrSerialHeld = errors.New("inventory: that serial number is already in stock")
ErrNotStockItem = errors.New("inventory: only a stock item moves stock")
ErrItemFixed = errors.New("inventory: an item's type, tracking and inventory account are fixed once it has moved")
ErrLotsMismatch = errors.New("inventory: the serials or batches do not add up to the quantity")
ErrAlreadyApplied = errors.New("inventory: this line has already moved stock")
ErrLayerConsumed = errors.New("inventory: the goods this receipt brought in have already been issued")
ErrValueExhausted = errors.New("inventory: this would leave the item with a negative value; post an adjustment instead")
ErrNoSourceLine = errors.New("inventory: a return line names the received or delivered line it returns")
ErrWrongKind = errors.New("inventory: the document is not a stock document")
ErrLineNeedsItem = errors.New("inventory: a receipt or return line needs an item")
ErrNoLocation = errors.New("inventory: the document names no location")
ErrNoDirection = errors.New("inventory: an adjustment line says whether stock comes in or goes out")
ErrSameLocation = errors.New("inventory: a transfer moves stock between two different locations")
ErrQuantityNotPositive = errors.New("inventory: the quantity must be positive")
ErrBatchExpiryMismatch = errors.New("inventory: that batch is already held with another expiry date")
ErrDocumentNotFound = errors.New("inventory: no such document in this organization")
ErrItemNotFound = errors.New("inventory: no such item in this organization")
ErrHasChildren = errors.New("inventory: other documents draw from this one; cancel them first")
)
func stockOrg(ctx context.Context) (uuid.UUID, error) {
org, ok := tenancy.OrgFromContext(ctx)
if !ok {
return uuid.Nil, fmt.Errorf("inventory: %w", tenancy.ErrNoOrg)
}
return org, nil
}
// mapStockError turns the database's refusals into this package's
// sentinels, keeping the database's message beside them.
func mapStockError(err error) error {
var pg *pgconn.PgError
if !errors.As(err, &pg) {
return err
}
var sentinel error
switch pg.ConstraintName {
case "stock_balance_on_hand_nonnegative", "item_cost_quantity_nonnegative":
sentinel = ErrInsufficientStock
case "stock_balance_serial_is_one", "stock_balance_serial_held_once":
sentinel = ErrSerialHeld
case "stock_move_serial_required":
sentinel = ErrSerialRequired
case "stock_move_batch_required":
sentinel = ErrBatchRequired
case "stock_move_untracked":
sentinel = ErrUntrackedLot
case "stock_move_stock_item":
sentinel = ErrNotStockItem
case "item_fixed":
sentinel = ErrItemFixed
case "item_cost_value_nonnegative", "item_cost_empty_is_worthless":
sentinel = ErrValueExhausted
case "trade_line_lot_serial_once", "trade_line_lot_batch_once", "trade_line_lot_one_kind",
"trade_line_lot_serial_is_one", "trade_line_lot_expiry_on_batch":
sentinel = ErrLotsMismatch
default:
return err
}
return fmt.Errorf("%w (%s)", sentinel, pg.Message)
}
// moveInput is one stock_move. Quantity and Cost are signed alike: in is
// positive, out negative. SerialNo and BatchNo are "" when absent.
type moveInput struct {
ItemID, LocationID uuid.UUID
Quantity money.Rate
Cost money.Amount
SerialNo, BatchNo string
TradeLineID *uuid.UUID
TransferID *uuid.UUID
ReversesID *uuid.UUID
}
// insertMove writes one move dated the business date (spec O2.1: no
// back-dated moves). Its trigger checks the lot and moves the balance.
func insertMove(ctx context.Context, tx pgx.Tx, c clock.Clock, in moveInput) (uuid.UUID, error) {
org, err := stockOrg(ctx)
if err != nil {
return uuid.Nil, err
}
unit := money.Zero()
if !in.Cost.IsZero() {
cost, qty := in.Cost, in.Quantity
if cost.IsNegative() {
cost, qty = cost.Neg(), qty.Neg()
}
if unit, err = cost.MulDiv(money.OneRate(), qty); err != nil {
return uuid.Nil, err
}
}
var id uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO stock_move (org_id, item_id, location_id, quantity, unit_cost, total_cost, move_date,
serial_no, batch_no, trade_line_id, transfer_id, reverses_move_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NULLIF($9, ''), $10, $11, $12, $13)
RETURNING id`,
org, in.ItemID, in.LocationID, in.Quantity, unit, in.Cost, clock.Today(ctx, c),
in.SerialNo, in.BatchNo, in.TradeLineID, in.TransferID, in.ReversesID, c.Now()).Scan(&id)
if err != nil {
return uuid.Nil, mapStockError(fmt.Errorf("inventory: move: %w", err))
}
return id, nil
}
  • Step 6: Retention

internal/kernel/db/retention_test.go: add "stock_move" and "stock_transfer" to appendOnly with the comment // Phase 4 O2.1: the stock ledger and its transfers; a correction is a reversing move.; add "stock_balance" and "stock_batch" to amendable with // Phase 4 O2.1: a move's running balance (written by its trigger) and a batch's expiry; never deleted.

Run: make generate && git status --short

  • Step 7: Run the tests to verify they pass

Run: go test ./internal/kernel/money/ ./internal/kernel/db/ ./internal/platform/inventory/ -count=1 Expected: PASS (TestEveryTableIsTenantIsolated, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestEveryConstraintIsNamed, TestOnlyOperationalTablesGrantDeleteToTheAppRole included).

  • Step 8: Sabotage

Comment out the stock_balance_on_hand_nonnegative line in 00722 (and its trailing comma); run go test ./internal/platform/inventory/ -run TestStockCannotGoBelowZero -count=1; read the red; restore. Change pg_trigger_depth() < 2 to < 1; run -run TestOnlyAMoveWritesTheBalance; read the red; restore.

  • Step 9: Commit
Terminal window
git add db/migrations/00722_stock_ledger.sql internal/
git commit -m "Phase 4 O2: the stock ledger, its balance and lot guards
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: The costing method, locked by the first move

Section titled “Task 4: The costing method, locked by the first move”

Files:

  • Create: db/migrations/00723_costing_method.sql
  • Modify: internal/platform/company/company.go (Profile, get, new SetCostingMethod)
  • Create: internal/platform/inventory/costing_method_test.go
  • Modify: internal/platform/inventory/stocktest/stocktest.go (UseCosting)

Interfaces:

  • Consumes: inventory.CostingFIFO/CostingWeightedAverage (Task 3), stocktest (Task 3).

  • Produces: company.Profile.CostingMethod string; company.SetCostingMethod(ctx context.Context, tx pgx.Tx, c clock.Clock, method string) (company.Profile, error); company.ErrCostingMethodUnknown, company.ErrCostingMethodLocked; stocktest.Env.UseCosting(t, method).

  • Step 1: Write the failing test

internal/platform/inventory/costing_method_test.go:

package inventory_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/company"
"github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/inventory/stocktest"
)
func TestTheCostingMethodLocksOnTheFirstMove(t *testing.T) {
e := stocktest.New(t)
set := func(m string) error {
return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := company.SetCostingMethod(ctx, tx, e.Clock, m)
return err
})
}
method := func() string {
var m string
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
p, err := company.Get(ctx, tx)
m = p.CostingMethod
return err
})
return m
}
require.Equal(t, inventory.CostingWeightedAverage, method(), "the default (R-O2-9)")
require.ErrorIs(t, set("lifo"), company.ErrCostingMethodUnknown)
// PRESENCE: before any stock moves, the method changes both ways.
require.NoError(t, set(inventory.CostingFIFO))
require.Equal(t, inventory.CostingFIFO, method())
require.NoError(t, set(inventory.CostingWeightedAverage))
bolt := e.Item(t, "BOLT", "stock", "none")
require.NoError(t, mover(e, e.TransferRow(t, bolt, e.WH1, e.WH2))(bolt, e.WH1, "1", "", ""))
require.ErrorIs(t, set(inventory.CostingFIFO), company.ErrCostingMethodLocked)
// The database refuses it whatever path writes (OD-1).
err := e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE company_profile SET costing_method = 'fifo' WHERE org_id = $1`, e.Org)
return err
})
stocktest.RequirePg(t, err, "23514", "company_profile_costing_method_locked")
require.NoError(t, set(inventory.CostingWeightedAverage), "setting the method it already has is no change")
require.Equal(t, inventory.CostingWeightedAverage, method())
}
  • Step 2: Run the test to verify it fails

Run: go test ./internal/platform/inventory/ -run TestTheCostingMethod -count=1 Expected: build failure undefined: company.SetCostingMethod.

  • Step 3: Migration

db/migrations/00723_costing_method.sql:

-- +goose Up
-- Phase 4 OD-1: one costing method per organization, FIFO or weighted
-- average, locked once the first stock movement posts (MFRS 102 ¶25; a
-- change would be an MFRS 108 policy change). Weighted average by default
-- (R-O2-9).
ALTER TABLE company_profile ADD COLUMN costing_method TEXT NOT NULL DEFAULT 'weighted_average'
CONSTRAINT company_profile_costing_method_known CHECK (costing_method IN ('fifo', 'weighted_average'));
-- +goose StatementBegin
CREATE FUNCTION company_profile_costing_method_locked() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF EXISTS (SELECT 1 FROM public.stock_move m WHERE m.org_id = NEW.org_id) THEN
RAISE EXCEPTION 'the costing method cannot change once stock has moved'
USING ERRCODE = '23514', CONSTRAINT = 'company_profile_costing_method_locked';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER company_profile_costing_method_locked BEFORE UPDATE OF costing_method ON company_profile
FOR EACH ROW WHEN (NEW.costing_method IS DISTINCT FROM OLD.costing_method)
EXECUTE FUNCTION company_profile_costing_method_locked();
-- +goose Down
DROP TRIGGER company_profile_costing_method_locked ON company_profile;
DROP FUNCTION company_profile_costing_method_locked();
ALTER TABLE company_profile DROP COLUMN costing_method;
  • Step 4: company

internal/platform/company/company.go:

In Profile, after Timezone string:

// CostingMethod is fifo or weighted_average (Phase 4 OD-1). Read here;
// written only by SetCostingMethod, never by Update.
CostingMethod string

In get, add costing_method to the SELECT after timezone and &p.CostingMethod to the Scan after &p.Timezone.

After the sentinels:

// ErrCostingMethodUnknown: the costing method is fifo or weighted_average.
var ErrCostingMethodUnknown = errors.New("company: the costing method is fifo or weighted_average")
// ErrCostingMethodLocked: stock has moved, so the method is fixed (OD-1,
// MFRS 102 ¶25); 00723's trigger is what refuses it.
var ErrCostingMethodLocked = errors.New("company: the costing method cannot change once stock has moved")

At the end of the file:

// SetCostingMethod changes the organization's costing method until the
// first stock movement (OD-1). The profile row is taken FOR UPDATE first:
// every stock movement reads the method under FOR SHARE (inventory's
// beginStock), so the change and a first move are serialized, and 00723's
// trigger then sees the move or the move sees the new method.
func SetCostingMethod(ctx context.Context, tx pgx.Tx, c clock.Clock, method string) (Profile, error) {
if method != "fifo" && method != "weighted_average" {
return Profile{}, ErrCostingMethodUnknown
}
before, err := GetForUpdate(ctx, tx)
if err != nil {
return Profile{}, err
}
if before.CostingMethod == method {
return before, nil
}
if _, err := tx.Exec(ctx, `UPDATE company_profile SET costing_method = $2, updated_at = $3 WHERE org_id = $1`,
before.OrgID, method, c.Now()); err != nil {
var pg *pgconn.PgError
if errors.As(err, &pg) && pg.ConstraintName == "company_profile_costing_method_locked" {
return Profile{}, ErrCostingMethodLocked
}
return Profile{}, fmt.Errorf("company: set costing method: %w", err)
}
after := before
after.CostingMethod = method
return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "company_profile", EntityID: before.OrgID,
Action: "set_costing_method", Before: map[string]string{"costing_method": before.CostingMethod},
After: map[string]string{"costing_method": method}})
}

(import "github.com/jackc/pgx/v5/pgconn".)

internal/platform/inventory/stocktest/stocktest.go — append:

// UseCosting sets the organization's costing method; call it before any
// stock moves.
func (e *Env) UseCosting(t *testing.T, method string) {
t.Helper()
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := company.SetCostingMethod(ctx, tx, e.Clock, method)
return err
})
}

(import "github.com/mcom/erp/internal/platform/company".)

Run: make generate && git status --short

  • Step 5: Run the tests to verify they pass

Run: go test ./internal/platform/company/... ./internal/platform/inventory/ -count=1 Expected: PASS.

  • Step 6: Sabotage

Delete the CREATE TRIGGER company_profile_costing_method_locked … statement; run -run TestTheCostingMethod; read the red (ErrCostingMethodLocked expected, nil got); restore.

  • Step 7: Commit
Terminal window
git add db/migrations/00723_costing_method.sql internal/
git commit -m "Phase 4 O2: the costing method, locked by the first stock move (OD-1)
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Files:

  • Create: internal/platform/inventory/graphs.go
  • Create: internal/platform/inventory/graphs_test.go
  • Modify (only if O1 registered them): O1’s workflow.RegisterSeededGraph calls for the five kinds — delete them and their builders.

Interfaces:

  • Consumes: posting.Effect (Task 1), ControlKindGRNI (Task 2), the Kind* constants (Task 3).

  • Produces: seeded graphs, state keys the tests use — goods_receipt: draft → received (accrual, grni) → cancelled; purchase_return: draft → returned (accrual, grni) → cancelled; delivery_order: draft → delivered (effect) → cancelled; sales_return: draft → received (effect) → cancelled; stock_adjustment: draft → submitted → approved (effect) → cancelled, submitted → rejected → draft. Each draft → cancelled is a reporter’s discard. The sole-owner exception covers all five voids and the adjustment’s approval (R-O2-13).

  • Step 1: Write the failing tests

internal/platform/inventory/graphs_test.go:

package inventory_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/payables/payablestest"
"github.com/mcom/erp/internal/platform/posting"
"github.com/mcom/erp/internal/platform/workflow"
"github.com/mcom/erp/internal/platform/workflow/workflowtest"
)
var stockPosting = map[string]struct {
sem posting.Semantic
control string
}{
inventory.KindGoodsReceipt: {posting.Accrual, inventory.ControlKindGRNI},
inventory.KindPurchaseReturn: {posting.Accrual, inventory.ControlKindGRNI},
inventory.KindDeliveryOrder: {posting.Effect, ""},
inventory.KindSalesReturn: {posting.Effect, ""},
inventory.KindStockAdjustment: {posting.Effect, ""},
}
func TestEveryStockFlowIsValidAndPostsOnce(t *testing.T) {
found := 0
for _, g := range workflow.SeededGraphs(uuid.New()) {
want, ok := stockPosting[g.Kind]
if !ok {
continue
}
found++
workflowtest.RequireSeededGraphValid(t, g.Kind)
n := 0
for _, s := range g.States {
if s.Semantic == posting.Accrual || s.Semantic == posting.Effect {
n++
require.Equal(t, want.sem, s.Semantic, g.Kind)
require.Equal(t, want.control, s.ControlKind, g.Kind)
}
}
require.Equal(t, 1, n, "%s posts exactly once", g.Kind)
}
require.Equal(t, len(stockPosting), found, "every stock kind is seeded")
}
func TestAProvisionedOrganizationHasTheStockFlows(t *testing.T) {
e := payablestest.New(t)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
for kind := range stockPosting {
d, err := workflow.Latest(ctx, tx, kind)
require.NoError(t, err, kind)
require.Equal(t, 1, d.Version, kind)
}
return nil
})
}
  • Step 2: Run the tests to verify they fail

Run: go test ./internal/platform/inventory/ -run 'StockFlow' -count=1 Expected: FAIL every stock kind is seeded: expected 5, actual 0.

  • Step 3: graphs.go

internal/platform/inventory/graphs.go:

package inventory
import (
"github.com/google/uuid"
"github.com/mcom/erp/internal/platform/posting"
"github.com/mcom/erp/internal/platform/workflow"
)
func init() {
for _, k := range []struct {
kind string
build func(uuid.UUID) workflow.Graph
}{
{KindGoodsReceipt, stockGraph(KindGoodsReceipt, "received", posting.Accrual, ControlKindGRNI,
stockLabels("Received", "已收货", "Diterima"), stockLabels("Receive", "收货", "Terima"))},
{KindPurchaseReturn, stockGraph(KindPurchaseReturn, "returned", posting.Accrual, ControlKindGRNI,
stockLabels("Returned", "已退货", "Dipulangkan"), stockLabels("Return", "退货", "Pulangkan"))},
{KindDeliveryOrder, stockGraph(KindDeliveryOrder, "delivered", posting.Effect, "",
stockLabels("Delivered", "已发货", "Dihantar"), stockLabels("Deliver", "发货", "Hantar"))},
{KindSalesReturn, stockGraph(KindSalesReturn, "received", posting.Effect, "",
stockLabels("Received back", "已退回", "Diterima semula"), stockLabels("Receive back", "收回", "Terima semula"))},
{KindStockAdjustment, adjustmentGraph},
} {
workflow.RegisterSeededGraph(k.kind, k.build)
// R-O2-13: a one-member organization voids its own stock documents,
// and approves its own adjustments, with a reason.
workflow.AllowSoleOwnerException(k.kind, k.kind == KindStockAdjustment)
}
}
func stockLabels(en, zh, ms string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh, "ms": ms} }
var (
labelDraft = stockLabels("Draft", "草稿", "Draf")
labelCancelled = stockLabels("Cancelled", "已取消", "Dibatalkan")
labelDiscard = stockLabels("Discard", "作废", "Buang")
labelCancel = stockLabels("Cancel", "取消", "Batal")
)
// stockGraph is a receipt's, delivery's or return's flow: the storekeeper
// who prepared it posts it (its semantic moves the stock and posts), a
// draft is discarded, and cancelling after posting — the Owner seat's —
// reverses the entry and the moves. posted stays non-terminal so the
// cancellation can leave it.
func stockGraph(kind, posted string, sem posting.Semantic, control string, postedLabel, action workflow.Labels) func(uuid.UUID) workflow.Graph {
return func(owner uuid.UUID) workflow.Graph {
return workflow.Graph{
Kind: kind, PostingOrder: workflow.AccrualFirst,
States: []workflow.StateSpec{
{Key: "draft", Semantic: posting.Draft, DisplayOrder: 1, Labels: labelDraft},
{Key: posted, Semantic: sem, ControlKind: control, DisplayOrder: 2, Labels: postedLabel},
{Key: "cancelled", Semantic: posting.Cancelled, DisplayOrder: 3, IsTerminal: true, Labels: labelCancelled},
},
Edges: []workflow.EdgeSpec{
{From: "draft", To: posted, Gate: workflow.GateReporter, ActionKey: "post", DisplayOrder: 1, Labels: action},
{From: "draft", To: "cancelled", Gate: workflow.GateReporter, ActionKey: "discard", DisplayOrder: 2,
RequiresReason: true, Labels: labelDiscard},
{From: posted, To: "cancelled", Gate: workflow.GateTarget, ActionKey: "cancel", DisplayOrder: 3,
RequiresReason: true, ReversesAccrual: true, Labels: labelCancel},
},
Grants: []workflow.GrantSpec{{StateKey: "cancelled", GranteeKind: workflow.GranteePosition, PositionID: &owner}},
}.Normalized()
}
}
// adjustmentGraph (R-O2-13): an adjustment is submitted and approved by
// the Owner seat — a write-off is never its preparer's own decision.
func adjustmentGraph(owner uuid.UUID) workflow.Graph {
ownerOn := func(state string) workflow.GrantSpec {
return workflow.GrantSpec{StateKey: state, GranteeKind: workflow.GranteePosition, PositionID: &owner}
}
return workflow.Graph{
Kind: KindStockAdjustment, PostingOrder: workflow.AccrualFirst,
States: []workflow.StateSpec{
{Key: "draft", Semantic: posting.Draft, DisplayOrder: 1, Labels: labelDraft},
{Key: "submitted", Semantic: posting.Pending, DisplayOrder: 2, Labels: stockLabels("Submitted", "已提交", "Dihantar")},
{Key: "approved", Semantic: posting.Effect, DisplayOrder: 3, Labels: stockLabels("Approved", "已批准", "Diluluskan")},
{Key: "rejected", Semantic: posting.Rejected, DisplayOrder: 4, Labels: stockLabels("Rejected", "已拒绝", "Ditolak")},
{Key: "cancelled", Semantic: posting.Cancelled, DisplayOrder: 5, IsTerminal: true, Labels: labelCancelled},
},
Edges: []workflow.EdgeSpec{
{From: "draft", To: "submitted", Gate: workflow.GateReporter, ActionKey: "submit", DisplayOrder: 1,
Labels: stockLabels("Submit", "提交", "Hantar")},
{From: "submitted", To: "approved", Gate: workflow.GateTarget, ActionKey: "approve", DisplayOrder: 2,
Labels: stockLabels("Approve", "批准", "Luluskan")},
{From: "submitted", To: "rejected", Gate: workflow.GateAdvance, ActionKey: "reject", DisplayOrder: 3,
RequiresReason: true, Labels: stockLabels("Reject", "拒绝", "Tolak")},
{From: "rejected", To: "draft", Gate: workflow.GateReporter, ActionKey: "revise", DisplayOrder: 4,
Labels: stockLabels("Revise", "修改", "Semak semula")},
{From: "draft", To: "cancelled", Gate: workflow.GateReporter, ActionKey: "discard", DisplayOrder: 5,
RequiresReason: true, Labels: labelDiscard},
{From: "approved", To: "cancelled", Gate: workflow.GateTarget, ActionKey: "cancel", DisplayOrder: 6,
RequiresReason: true, ReversesAccrual: true, Labels: labelCancel},
},
Grants: []workflow.GrantSpec{ownerOn("approved"), ownerOn("cancelled")},
}.Normalized()
}
  • Step 4: Run the tests to verify they pass

Run: go test ./internal/platform/inventory/ ./internal/app/ -run 'StockFlow|SeededGraph' -count=1 Expected: PASS (TestEverySeededGraphPassesTheValidator, TestEverySeededGraphShipsAllThreeLocales included).

  • Step 5: Sabotage

Set the posted → cancelled edge’s ReversesAccrual to false; run -run TestEveryStockFlowIsValid; read the red (reversal_missing); restore.

  • Step 6: Commit
Terminal window
git add internal/platform/inventory/graphs.go internal/platform/inventory/graphs_test.go
git commit -m "Phase 4 O2: seeded flows for receipts, deliveries, returns and adjustments
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Costing and applying stock documents

Section titled “Task 6: Costing and applying stock documents”

Files:

  • Create: db/migrations/00724_cost_layers.sql, db/migrations/00725_stock_documents.sql
  • Create: internal/platform/inventory/costing.go, internal/platform/inventory/stockline.go, internal/platform/inventory/apply.go
  • Modify: internal/platform/inventory/stock_export_test.go (ApplyForTest, ReverseForTest)
  • Modify: internal/platform/inventory/stocktest/stocktest.go (documents, costs, rates)
  • Create: internal/platform/inventory/stock_documents_test.go
  • Modify: internal/kernel/db/retention_test.go

Interfaces:

  • Consumes: insertMove, moveInput, mapStockError, stockOrg, the errors and kinds (Task 3); company.Profile.CostingMethod (Task 4); the seeded flows (Task 5, so trade.Create can start an instance).

  • Produces (package inventory): type Lot, type Received, type TransitionInput, SetLineLots, ReceivedValue, OnTransition, Transition (signatures in “Interfaces O3 consumes”); CurrentUnitCost(ctx, tx, itemID uuid.UUID) (money.Amount, error); CostingLocked(ctx, tx) (bool, error); unexported type stockLine (fields below), documentLines(ctx, tx, org, docID) ([]stockLine, error), receivedValue(ctx, tx, org, lineID) (Received, error), liveLineCost(ctx, tx, org, lineID) (money.Amount, error) (signed sum of a line’s unreversed moves), checkLots(tracking string, qty money.Rate, lots []Lot) ([]Lot, error), beginStock(ctx, tx, org, items []uuid.UUID) (string, error); tables stock_adjustment_reason(id, org_id, code, account_id, is_active, created_at), stock_adjustment_detail(org_id, document_id, reason_id).

  • Produces (stocktest): SupplierParty, CustomerParty, MoveErr, Post, Cancel (through inventory.Transition; a no-op Poster until Task 7), type DocLine, Line(item, qty, price), type DocRef{ID, InstanceID uuid.UUID; Kind string; Lines []uuid.UUID}, Doc, DocAt, Apply, ApplyErr, Reverse, ReverseErr, ItemCost, LineCost, Received, SeedRate, RequireCostsAddUp.

  • Step 1: Write the failing tests

internal/platform/inventory/stock_export_test.go — append:

func ApplyForTest(ctx context.Context, tx pgx.Tx, c clock.Clock, doc uuid.UUID) error {
return apply(ctx, tx, c, doc)
}
func ReverseForTest(ctx context.Context, tx pgx.Tx, c clock.Clock, doc uuid.UUID) error {
return reverse(ctx, tx, c, doc)
}

internal/platform/inventory/stocktest/stocktest.go — in New, before return e, add e.nopPoster(t); then append (imports: strings, time, github.com/mcom/erp/internal/platform/accounting/fx, github.com/mcom/erp/internal/platform/posting, github.com/mcom/erp/internal/platform/trade):

// SupplierParty is a new MYR supplier's party id (tradetest.Supplier).
func (e *Env) SupplierParty(t *testing.T) uuid.UUID {
t.Helper()
return e.Supplier(t, "SUP-"+strings.ToUpper(uuid.NewString()[:6]))
}
// CustomerParty is a new MYR customer's party id (tradetest.Customer).
func (e *Env) CustomerParty(t *testing.T) uuid.UUID {
t.Helper()
return e.Customer(t, "CUS-"+strings.ToUpper(uuid.NewString()[:6]))
}
// DocLine is one line of a test document, quantity in the item's base UOM.
type DocLine struct {
Item uuid.UUID
Qty, Price string
Source *uuid.UUID
Lots []inventory.Lot
}
func Line(item uuid.UUID, qty, price string) DocLine { return DocLine{Item: item, Qty: qty, Price: price} }
type DocRef struct {
ID, InstanceID uuid.UUID
Kind string
Lines []uuid.UUID
}
// Doc is a draft stock document at WH1, created by Staff through O1's
// trade.Create. A foreign document carries a nominal header rate (O1-3):
// O2 values a receipt at the receipt date's rate, never the header's.
func (e *Env) Doc(t *testing.T, kind string, party *uuid.UUID, currency string, lines ...DocLine) DocRef {
t.Helper()
return e.DocAt(t, kind, e.WH1, party, currency, lines...)
}
func (e *Env) DocAt(t *testing.T, kind string, location uuid.UUID, party *uuid.UUID, currency string, lines ...DocLine) DocRef {
t.Helper()
ref := DocRef{Kind: kind}
require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
in := trade.DocumentInput{Kind: kind, PartyID: party, Currency: currency, LocationID: &location}
if currency != "MYR" {
rate := money.MustParseRate("4.2")
in.FXRate = &rate
}
for _, l := range lines {
item := l.Item
in.Lines = append(in.Lines, trade.LineInput{ItemID: &item, Quantity: Qty(l.Qty), UOMID: e.Unit,
UnitPrice: Amt(l.Price), SourceLineID: l.Source})
}
d, err := trade.Create(ctx, tx, e.Clock, e.Staff, in)
if err != nil {
return err
}
ref.ID, ref.InstanceID = d.ID, d.InstanceID
for i, l := range d.Lines {
ref.Lines = append(ref.Lines, l.ID)
if len(lines[i].Lots) > 0 {
if err := inventory.SetLineLots(ctx, tx, l.ID, lines[i].Lots); err != nil {
return err
}
}
}
return nil
}))
return ref
}
// nopPoster lets Task 6's documents move through their workflow before the
// posting rules exist (Task 7 replaces it with the real rules): O1 lets a
// return name only an approved source (Ruling O1-6).
type nopPoster struct{}
func (nopPoster) Post(context.Context, pgx.Tx, posting.Event) error { return nil }
func (e *Env) nopPoster(t *testing.T) {
t.Helper()
posting.ResetForTest()
posting.Register(nopPoster{})
t.Cleanup(posting.ResetForTest)
}
// postedState is each flow's posting state (Task 5's seeded graphs).
var postedState = map[string]string{inventory.KindGoodsReceipt: "received", inventory.KindPurchaseReturn: "returned",
inventory.KindDeliveryOrder: "delivered", inventory.KindSalesReturn: "received"}
func (e *Env) MoveErr(d DocRef, by uuid.UUID, from, to, reason string) 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: from, To: to, Actor: by, Reason: reason})
return err
})
}
// Post moves a receipt, delivery or return from draft into its posting
// state, as its preparer (Staff) does.
func (e *Env) Post(t *testing.T, d DocRef) {
t.Helper()
require.NoError(t, e.MoveErr(d, e.Staff, "draft", postedState[d.Kind], ""))
}
// Cancel cancels a posted receipt, delivery or return, as the Owner seat.
func (e *Env) Cancel(t *testing.T, d DocRef) {
t.Helper()
require.NoError(t, e.MoveErr(d, e.Owner, postedState[d.Kind], "cancelled", "Entered in error"))
}
func (e *Env) ApplyErr(d DocRef) error {
return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
return inventory.ApplyForTest(ctx, tx, e.Clock, d.ID)
})
}
func (e *Env) Apply(t *testing.T, d DocRef) { t.Helper(); require.NoError(t, e.ApplyErr(d)) }
func (e *Env) ReverseErr(d DocRef) error {
return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
return inventory.ReverseForTest(ctx, tx, e.Clock, d.ID)
})
}
func (e *Env) Reverse(t *testing.T, d DocRef) { t.Helper(); require.NoError(t, e.ReverseErr(d)) }
// ItemCost is the item's item_cost row: quantity and value, zero when absent.
func (e *Env) ItemCost(t *testing.T, item uuid.UUID) (money.Rate, money.Amount) {
t.Helper()
q, v := Qty("0"), Amt("0")
err := e.H.Admin.QueryRow(context.Background(),
`SELECT quantity, value FROM item_cost WHERE org_id = $1 AND item_id = $2`, e.Org, item).Scan(&q, &v)
if !errors.Is(err, pgx.ErrNoRows) {
require.NoError(t, err)
}
return q, v
}
// LineCost is the signed cost of a line's unreversed moves: in positive,
// out negative, zero once reversed.
func (e *Env) LineCost(t *testing.T, line uuid.UUID) money.Amount {
t.Helper()
v := Amt("0")
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
SELECT coalesce(sum(m.total_cost), 0) FROM stock_move m
WHERE m.org_id = $1 AND m.trade_line_id = $2 AND m.reverses_move_id IS NULL
AND NOT EXISTS (SELECT 1 FROM stock_move r WHERE r.org_id = m.org_id AND r.reverses_move_id = m.id)`,
e.Org, line).Scan(&v))
return v
}
func (e *Env) Received(t *testing.T, line uuid.UUID) inventory.Received {
t.Helper()
var r inventory.Received
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
r, err = inventory.ReceivedValue(ctx, tx, line)
return err
})
return r
}
// SeedRate stores one Bank Negara reference rate (MYR per unit).
func (e *Env) SeedRate(t *testing.T, currency, day, rate string) {
t.Helper()
d, err := time.Parse("2006-01-02", day)
require.NoError(t, err)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := fx.StoreReference(ctx, tx, e.Clock, []fx.ReferenceRate{{Source: "bnm", Currency: currency,
RateDate: d, Session: "1130", Rate: money.MustParseRate(rate)}})
return err
})
}
// RequireCostsAddUp is the costing drift check: every item_cost's value
// is the sum of its moves' cost and its quantity the sum of their
// quantity; under FIFO, the open layers hold exactly that.
func (e *Env) RequireCostsAddUp(t *testing.T) {
t.Helper()
var items, bad, badLayers int
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
WITH moved AS (SELECT item_id, sum(quantity) AS q, sum(total_cost) AS v FROM stock_move WHERE org_id = $1 GROUP BY 1),
layered AS (SELECT item_id, sum(remaining_quantity) AS q, sum(remaining_value) AS v FROM cost_layer WHERE org_id = $1 GROUP BY 1)
SELECT count(*),
count(*) FILTER (WHERE c.quantity <> m.q OR c.value <> m.v),
count(*) FILTER (WHERE p.costing_method = 'fifo' AND (coalesce(l.q, 0) <> c.quantity OR coalesce(l.v, 0) <> c.value))
FROM item_cost c
JOIN moved m ON m.item_id = c.item_id
JOIN company_profile p ON p.org_id = c.org_id
LEFT JOIN layered l ON l.item_id = c.item_id
WHERE c.org_id = $1`, e.Org).Scan(&items, &bad, &badLayers))
require.Positive(t, items, "no item was costed; the comparison would be vacuous")
require.Zero(t, bad, "item_cost disagrees with the sum of stock_move")
require.Zero(t, badLayers, "the FIFO layers disagree with item_cost")
}

internal/platform/inventory/stock_documents_test.go:

package inventory_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/inventory/stocktest"
)
func TestWeightedAverageIssuesAtTheAverageAndTheLastIssueTakesTheResidue(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "1", "1.00")))
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "2", "2.00")))
q, v := e.ItemCost(t, bolt)
stocktest.RequireQty(t, "3", q)
stocktest.RequireAmount(t, "5", v)
issue := func() stocktest.DocRef {
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "1", "9.00"))
e.Post(t, d)
return d
}
stocktest.RequireAmount(t, "-1.6667", e.LineCost(t, issue().Lines[0]), "5 / 3, rounded")
stocktest.RequireAmount(t, "-1.6667", e.LineCost(t, issue().Lines[0]), "3.3333 / 2, rounded")
stocktest.RequireAmount(t, "-1.6666", e.LineCost(t, issue().Lines[0]), "the issue that empties the item takes the residue")
q, v = e.ItemCost(t, bolt)
stocktest.RequireQty(t, "0", q)
stocktest.RequireAmount(t, "0", v, "valuation never drifts")
e.RequireBalancesAddUp(t)
e.RequireCostsAddUp(t)
}
func TestFIFOIssuesTheOldestLayersFirst(t *testing.T) {
e := stocktest.New(t)
e.UseCosting(t, inventory.CostingFIFO)
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00")))
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "2.00")))
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "15", "5.00"))
e.Post(t, d)
stocktest.RequireAmount(t, "-20", e.LineCost(t, d.Lines[0]), "10 × 1.00 + 5 × 2.00")
q, v := e.ItemCost(t, bolt)
stocktest.RequireQty(t, "5", q)
stocktest.RequireAmount(t, "10", v)
e.RequireCostsAddUp(t)
}
func TestAPurchaseReturnLeavesFromItsOwnReceiptsLayer(t *testing.T) {
e := stocktest.New(t)
e.UseCosting(t, inventory.CostingFIFO)
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
a := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00"))
e.Post(t, a)
b := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "2.00"))
e.Post(t, b)
ret := e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR",
stocktest.DocLine{Item: bolt, Qty: "4", Price: "2.00", Source: &b.Lines[0]})
e.Post(t, ret)
stocktest.RequireAmount(t, "-8", e.LineCost(t, ret.Lines[0]), "the returned receipt's own layer")
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "10", "5.00"))
e.Post(t, d)
stocktest.RequireAmount(t, "-10", e.LineCost(t, d.Lines[0]), "the older receipt is still issued first")
// a's goods are all issued: returning one of them is refused.
gone := e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR",
stocktest.DocLine{Item: bolt, Qty: "1", Price: "1.00", Source: &a.Lines[0]})
require.ErrorIs(t, e.ApplyErr(gone), inventory.ErrLayerConsumed)
e.RequireCostsAddUp(t)
}
func TestASalesReturnComesBackAtTheCostItLeftAt(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00")))
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "3.00")))
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00"))
e.Post(t, d)
stocktest.RequireAmount(t, "-8", e.LineCost(t, d.Lines[0]))
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "5.00")))
r := e.Doc(t, inventory.KindSalesReturn, &cust, "MYR",
stocktest.DocLine{Item: bolt, Qty: "2", Price: "9.00", Source: &d.Lines[0]})
e.Post(t, r)
stocktest.RequireAmount(t, "4", e.LineCost(t, r.Lines[0]), "2.00 a unit, as delivered — not today's average")
e.RequireCostsAddUp(t)
}
func TestReversingADocumentPutsItsStockAndValueBack(t *testing.T) {
e := stocktest.New(t)
e.UseCosting(t, inventory.CostingFIFO)
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
// PRESENCE: a receipt nothing was issued from reverses.
spare := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "3", "1.00"))
e.Post(t, spare)
e.Reverse(t, spare)
stocktest.RequireAmount(t, "0", e.LineCost(t, spare.Lines[0]))
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00"))
e.Post(t, r)
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00"))
e.Post(t, d)
require.ErrorIs(t, e.ReverseErr(r), inventory.ErrLayerConsumed, "its goods were issued")
e.Reverse(t, d)
stocktest.RequireQty(t, "10", e.OnHand(t, bolt, e.WH1, ""))
q, v := e.ItemCost(t, bolt)
stocktest.RequireQty(t, "10", q)
stocktest.RequireAmount(t, "10", v)
stocktest.RequireAmount(t, "0", e.LineCost(t, d.Lines[0]))
e.RequireBalancesAddUp(t)
e.RequireCostsAddUp(t)
}
func TestASerialReceiptMovesOneUnitPerSerial(t *testing.T) {
e := stocktest.New(t)
phone := e.Item(t, "PHONE", "stock", "serial")
sup := e.SupplierParty(t)
missing := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(phone, "2", "100.00"))
require.ErrorIs(t, e.ApplyErr(missing), inventory.ErrLotsMismatch)
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.DocLine{Item: phone, Qty: "2", Price: "100.00",
Lots: []inventory.Lot{{SerialNo: "SN-1", Quantity: stocktest.Qty("1")}, {SerialNo: "SN-2", Quantity: stocktest.Qty("1")}}})
e.Post(t, r)
stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH1, "SN-1"))
stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH1, "SN-2"))
stocktest.RequireAmount(t, "200", e.LineCost(t, r.Lines[0]))
err := e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
return inventory.SetLineLots(ctx, tx, r.Lines[0], nil)
})
require.ErrorIs(t, err, inventory.ErrAlreadyApplied, "a moved line's lots are its moves")
e.RequireBalancesAddUp(t)
}
func TestAReceiptIsValuedInBaseAtItsDatesRate(t *testing.T) {
e := stocktest.New(t)
e.SeedRate(t, "USD", "2026-09-18", "4.2000")
bolt := e.Item(t, "BOLT", "stock", "none")
sup := e.SupplierParty(t)
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "USD", stocktest.Line(bolt, "10", "2.00"))
e.Post(t, r)
got := e.Received(t, r.Lines[0])
stocktest.RequireQty(t, "10", got.Quantity)
stocktest.RequireAmount(t, "20", got.Amount)
require.Equal(t, "USD", got.Currency)
stocktest.RequireAmount(t, "84", got.Base)
stocktest.RequireAmount(t, "84", e.LineCost(t, r.Lines[0]), "stock is valued in base")
require.ErrorIs(t, e.ApplyErr(r), inventory.ErrAlreadyApplied)
e.Reverse(t, r)
stocktest.RequireQty(t, "0", e.Received(t, r.Lines[0]).Quantity, "a reversed receipt received nothing")
}

(add "context" and "github.com/jackc/pgx/v5" to that file’s imports.)

  • Step 2: Run the tests to verify they fail

Run: go test ./internal/platform/inventory/ -count=1 Expected: build failure undefined: apply / undefined: inventory.SetLineLots.

  • Step 3: Migrations

db/migrations/00724_cost_layers.sql:

-- +goose Up
-- Phase 4 O2.2: cost is per item across the organization. item_cost is the
-- item's quantity and value under either method (R-O2-4): the row every
-- movement of the item locks first, and the valuation the GL is checked
-- against (O2.5). The average is value / quantity, never stored. Under
-- FIFO each inbound move adds a cost_layer; an issue consumes the oldest.
CREATE TABLE item_cost (
org_id UUID NOT NULL REFERENCES organization (id),
item_id UUID NOT NULL,
quantity NUMERIC(19,6) NOT NULL DEFAULT 0 CONSTRAINT item_cost_quantity_nonnegative CHECK (quantity >= 0),
value NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT item_cost_value_nonnegative CHECK (value >= 0),
CONSTRAINT item_cost_pkey PRIMARY KEY (org_id, item_id),
CONSTRAINT item_cost_empty_is_worthless CHECK (quantity > 0 OR value = 0),
CONSTRAINT item_cost_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id)
);
CREATE TABLE cost_layer (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
seq BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY,
item_id UUID NOT NULL,
move_id UUID NOT NULL,
-- The goods-receipt line whose goods this is; a purchase return consumes
-- only its receipt's layers (spec O3.1).
receipt_line_id UUID,
unit_cost NUMERIC(19,4) NOT NULL CONSTRAINT cost_layer_unit_cost_nonnegative CHECK (unit_cost >= 0),
quantity NUMERIC(19,6) NOT NULL CONSTRAINT cost_layer_quantity_positive CHECK (quantity > 0),
remaining_quantity NUMERIC(19,6) NOT NULL,
remaining_value NUMERIC(19,4) NOT NULL CONSTRAINT cost_layer_remaining_value_nonnegative CHECK (remaining_value >= 0),
CONSTRAINT cost_layer_tenant_id UNIQUE (org_id, id),
CONSTRAINT cost_layer_move_once UNIQUE (org_id, move_id),
CONSTRAINT cost_layer_remaining_within CHECK (remaining_quantity >= 0 AND remaining_quantity <= quantity),
CONSTRAINT cost_layer_empty_is_worthless CHECK (remaining_quantity > 0 OR remaining_value = 0),
CONSTRAINT cost_layer_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id),
CONSTRAINT cost_layer_move_fk FOREIGN KEY (org_id, move_id) REFERENCES stock_move (org_id, id),
CONSTRAINT cost_layer_receipt_line_fk FOREIGN KEY (org_id, receipt_line_id) REFERENCES trade_line (org_id, id)
);
CREATE INDEX cost_layer_open_idx ON cost_layer (org_id, item_id, seq) WHERE remaining_quantity > 0;
ALTER TABLE item_cost ENABLE ROW LEVEL SECURITY;
ALTER TABLE item_cost FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON item_cost
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE cost_layer ENABLE ROW LEVEL SECURITY;
ALTER TABLE cost_layer FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON cost_layer
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE DELETE ON item_cost, cost_layer FROM erp_app;
GRANT USAGE ON SEQUENCE cost_layer_seq_seq TO erp_app;
-- +goose Down
DROP TABLE cost_layer;
DROP TABLE item_cost;

db/migrations/00725_stock_documents.sql:

-- +goose Up
-- Phase 4 O2: what stock documents carry beyond O1's trade_line.
-- trade_line_lot: the serials and batches a draft line will move (spec
-- O2.4), replaced while the document is a draft; the moves are the record.
CREATE TABLE trade_line_lot (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
trade_line_id UUID NOT NULL,
serial_no TEXT CONSTRAINT trade_line_lot_serial_shape CHECK (serial_no IS NULL OR btrim(serial_no) <> ''),
batch_no TEXT CONSTRAINT trade_line_lot_batch_shape CHECK (batch_no IS NULL OR btrim(batch_no) <> ''),
expires_on DATE,
quantity NUMERIC(19,6) NOT NULL CONSTRAINT trade_line_lot_quantity_positive CHECK (quantity > 0),
CONSTRAINT trade_line_lot_one_kind CHECK (num_nonnulls(serial_no, batch_no) = 1),
CONSTRAINT trade_line_lot_serial_is_one CHECK (serial_no IS NULL OR quantity = 1),
CONSTRAINT trade_line_lot_expiry_on_batch CHECK (expires_on IS NULL OR batch_no IS NOT NULL),
CONSTRAINT trade_line_lot_serial_once UNIQUE (org_id, trade_line_id, serial_no),
CONSTRAINT trade_line_lot_batch_once UNIQUE (org_id, trade_line_id, batch_no),
CONSTRAINT trade_line_lot_line_fk FOREIGN KEY (org_id, trade_line_id) REFERENCES trade_line (org_id, id)
);
-- receipt_valuation (R-O2-3): what a goods-receipt line brought in, in its
-- currency and in base at the receipt date's rate. Append-only; a
-- reversal is a negating row. O3's purchase invoice clears GRNI from it.
CREATE TABLE receipt_valuation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
trade_line_id UUID NOT NULL,
quantity NUMERIC(19,6) NOT NULL,
amount NUMERIC(19,4) NOT NULL,
currency TEXT NOT NULL CONSTRAINT receipt_valuation_currency_code CHECK (currency ~ '^[A-Z]{3}$'),
base_amount NUMERIC(19,4) NOT NULL,
valued_on DATE NOT NULL,
reverses_id UUID,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT receipt_valuation_tenant_id UNIQUE (org_id, id),
CONSTRAINT receipt_valuation_sign CHECK (CASE WHEN reverses_id IS NULL
THEN quantity > 0 AND amount >= 0 AND base_amount >= 0
ELSE quantity < 0 AND amount <= 0 AND base_amount <= 0 END),
CONSTRAINT receipt_valuation_line_fk FOREIGN KEY (org_id, trade_line_id) REFERENCES trade_line (org_id, id),
CONSTRAINT receipt_valuation_reverses_fk FOREIGN KEY (org_id, reverses_id) REFERENCES receipt_valuation (org_id, id)
);
CREATE UNIQUE INDEX receipt_valuation_reversed_once ON receipt_valuation (org_id, reverses_id) WHERE reverses_id IS NOT NULL;
CREATE INDEX receipt_valuation_line_idx ON receipt_valuation (org_id, trade_line_id);
-- An adjustment's reason and the account it posts against (spec O2.3,
-- R-O2-12). Names are translation rows (entity kind stock_adjustment_reason).
CREATE TABLE stock_adjustment_reason (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
code TEXT NOT NULL CONSTRAINT stock_adjustment_reason_code_shape CHECK (code ~ '^[a-z][a-z0-9_]{0,31}$'),
account_id UUID NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT stock_adjustment_reason_tenant_id UNIQUE (org_id, id),
CONSTRAINT stock_adjustment_reason_code_once UNIQUE (org_id, code),
CONSTRAINT stock_adjustment_reason_account_fk FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id)
);
-- O2's own row beside O1's document: never an ALTER of O1's tables.
-- Written once with the document. A line's direction is its sign (O1).
CREATE TABLE stock_adjustment_detail (
org_id UUID NOT NULL REFERENCES organization (id),
document_id UUID NOT NULL,
reason_id UUID NOT NULL,
CONSTRAINT stock_adjustment_detail_pkey PRIMARY KEY (org_id, document_id),
CONSTRAINT stock_adjustment_detail_document_fk FOREIGN KEY (org_id, document_id) REFERENCES trade_document (org_id, id),
CONSTRAINT stock_adjustment_detail_reason_fk FOREIGN KEY (org_id, reason_id) REFERENCES stock_adjustment_reason (org_id, id)
);
ALTER TABLE trade_line_lot ENABLE ROW LEVEL SECURITY;
ALTER TABLE trade_line_lot FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON trade_line_lot
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE receipt_valuation ENABLE ROW LEVEL SECURITY;
ALTER TABLE receipt_valuation FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON receipt_valuation
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE stock_adjustment_reason ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock_adjustment_reason FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON stock_adjustment_reason
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE stock_adjustment_detail ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock_adjustment_detail FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON stock_adjustment_detail
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 receipt_valuation, stock_adjustment_detail FROM erp_app;
REVOKE DELETE ON stock_adjustment_reason FROM erp_app;
-- +goose Down
DROP TABLE stock_adjustment_detail;
DROP TABLE stock_adjustment_reason;
DROP TABLE receipt_valuation;
DROP TABLE trade_line_lot;

internal/kernel/db/retention_test.go: appendOnly gains "receipt_valuation", "stock_adjustment_detail" (// Phase 4 O2: a receipt's valuation (a reversal is a negating row) and an adjustment's reason, written with the document.); amendable gains "item_cost", "cost_layer", "stock_adjustment_reason" (// Phase 4 O2.2: an item's cost and its FIFO layers move with every issue; a reason is retired, never deleted.); operational gains "trade_line_lot" (// Phase 4 O2.4: a draft line's serials and batches, replaced while it is a draft; the moves are the record.).

  • Step 4: costing.go

internal/platform/inventory/costing.go:

package inventory
import (
"bytes"
"context"
"errors"
"fmt"
"slices"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/company"
)
// beginStock opens every stock movement in LOCK_ORDER.md's inventory order:
// company_profile FOR SHARE (company.SetCostingMethod takes it FOR UPDATE,
// so the method cannot change under a move), then each item's item_cost
// row, created if missing, FOR UPDATE in item id order — every movement of
// an item happens under that lock. It returns the costing method.
func beginStock(ctx context.Context, tx pgx.Tx, org uuid.UUID, items []uuid.UUID) (string, error) {
p, err := company.GetForShare(ctx, tx)
if err != nil {
return "", err
}
ids := slices.Clone(items)
slices.SortFunc(ids, func(a, b uuid.UUID) int { return bytes.Compare(a[:], b[:]) })
ids = slices.Compact(ids)
if len(ids) == 0 {
return p.CostingMethod, nil
}
for _, id := range ids {
if _, err := tx.Exec(ctx, `
INSERT INTO item_cost (org_id, item_id) VALUES ($1, $2)
ON CONFLICT ON CONSTRAINT item_cost_pkey DO NOTHING`, org, id); err != nil {
return "", fmt.Errorf("inventory: item cost: %w", err)
}
}
if _, err := tx.Exec(ctx, `
SELECT 1 FROM item_cost WHERE org_id = $1 AND item_id = ANY($2) ORDER BY item_id FOR UPDATE`, org, ids); err != nil {
return "", fmt.Errorf("inventory: lock item costs: %w", err)
}
return p.CostingMethod, nil
}
// costIn adds an inbound move's quantity and cost to its item; under FIFO
// it also opens a layer for the move (receiptLine: the goods-receipt line
// the goods came from, for a later purchase return).
func costIn(ctx context.Context, tx pgx.Tx, org uuid.UUID, method string, item, move uuid.UUID,
qty money.Rate, cost money.Amount, receiptLine *uuid.UUID) error {
if _, err := tx.Exec(ctx, `
UPDATE item_cost SET quantity = quantity + $3, value = value + $4 WHERE org_id = $1 AND item_id = $2`,
org, item, qty, cost); err != nil {
return mapStockError(fmt.Errorf("inventory: cost in: %w", err))
}
if method != CostingFIFO {
return nil
}
unit, err := cost.MulDiv(money.OneRate(), qty)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO cost_layer (org_id, item_id, move_id, receipt_line_id, unit_cost, quantity, remaining_quantity, remaining_value)
VALUES ($1, $2, $3, $4, $5, $6, $6, $7)`, org, item, move, receiptLine, unit, qty, cost)
if err != nil {
return fmt.Errorf("inventory: open layer: %w", err)
}
return nil
}
// outRule narrows an issue: a purchase return consumes only its receipt's
// FIFO layers (receiptLine) and, under weighted average, leaves at its
// receipt's cost (atCost) rather than the average.
type outRule struct {
receiptLine *uuid.UUID
atCost *money.Amount
}
// costOut takes qty of item out of its cost and returns what it cost
// (positive). Weighted average: the average, rounded to 4 places, times
// qty; the issue that empties the item takes the residue (spec O2.2).
// FIFO: the oldest layers. Never more than the value held (R-O2-4).
func costOut(ctx context.Context, tx pgx.Tx, org uuid.UUID, method string, item uuid.UUID, qty money.Rate, r outRule) (money.Amount, error) {
var held money.Rate
var value money.Amount
if err := tx.QueryRow(ctx, `SELECT quantity, value FROM item_cost WHERE org_id = $1 AND item_id = $2`,
org, item).Scan(&held, &value); err != nil {
return money.Amount{}, fmt.Errorf("inventory: cost out: %w", err)
}
if qty.Cmp(held) > 0 {
return money.Amount{}, ErrInsufficientStock
}
var cost money.Amount
switch {
case method == CostingFIFO:
c, err := consumeLayers(ctx, tx, org, item, qty, r.receiptLine)
if err != nil {
return money.Amount{}, err
}
cost = c
case qty.Cmp(held) == 0:
cost = value
case r.atCost != nil:
cost = *r.atCost
default:
avg, err := value.MulDiv(money.OneRate(), held)
if err != nil {
return money.Amount{}, err
}
cost = avg.MulRate(qty)
}
if cost.Cmp(value) > 0 {
cost = value
}
if _, err := tx.Exec(ctx, `
UPDATE item_cost SET quantity = quantity - $3, value = value - $4 WHERE org_id = $1 AND item_id = $2`,
org, item, qty, cost); err != nil {
return money.Amount{}, mapStockError(fmt.Errorf("inventory: cost out: %w", err))
}
return cost, nil
}
type layer struct {
id uuid.UUID
remaining money.Rate
remainingValue money.Amount
unit money.Amount
}
// consumeLayers takes qty from the item's open layers, oldest first (only
// receiptLine's when given). A layer emptied gives its whole remaining
// value: the residue stays with the layer, never with the next one.
func consumeLayers(ctx context.Context, tx pgx.Tx, org, item uuid.UUID, qty money.Rate, receiptLine *uuid.UUID) (money.Amount, error) {
rows, err := tx.Query(ctx, `
SELECT id, remaining_quantity, remaining_value, unit_cost FROM cost_layer
WHERE org_id = $1 AND item_id = $2 AND remaining_quantity > 0
AND ($3::uuid IS NULL OR receipt_line_id = $3)
ORDER BY seq FOR UPDATE`, org, item, receiptLine)
if err != nil {
return money.Amount{}, fmt.Errorf("inventory: layers: %w", err)
}
var layers []layer
for rows.Next() {
var l layer
if err := rows.Scan(&l.id, &l.remaining, &l.remainingValue, &l.unit); err != nil {
rows.Close()
return money.Amount{}, err
}
layers = append(layers, l)
}
rows.Close()
if err := rows.Err(); err != nil {
return money.Amount{}, err
}
need, cost := qty, money.Zero()
for _, l := range layers {
if !need.IsPositive() {
break
}
take, v := l.remaining, l.remainingValue
if need.Cmp(take) < 0 {
take = need
if v = l.unit.MulRate(take); v.Cmp(l.remainingValue) > 0 {
v = l.remainingValue
}
}
if _, err := tx.Exec(ctx, `
UPDATE cost_layer SET remaining_quantity = remaining_quantity - $3, remaining_value = remaining_value - $4
WHERE org_id = $1 AND id = $2`, org, l.id, take, v); err != nil {
return money.Amount{}, fmt.Errorf("inventory: consume layer: %w", err)
}
cost, need = cost.Add(v), need.Sub(take)
}
if need.IsPositive() {
if receiptLine != nil {
return money.Amount{}, ErrLayerConsumed
}
return money.Amount{}, ErrInsufficientStock
}
return cost, nil
}
// uncostIn takes an inbound move back out at exactly the cost it came in
// at (a reversal mirrors). Under FIFO its layer must be untouched.
func uncostIn(ctx context.Context, tx pgx.Tx, org uuid.UUID, method string, m liveMove) error {
if method == CostingFIFO {
var qty, remaining money.Rate
err := tx.QueryRow(ctx, `
SELECT quantity, remaining_quantity FROM cost_layer WHERE org_id = $1 AND move_id = $2 FOR UPDATE`,
org, m.ID).Scan(&qty, &remaining)
if err != nil {
return fmt.Errorf("inventory: layer of move %s: %w", m.ID, err)
}
if qty.Cmp(remaining) != 0 {
return ErrLayerConsumed
}
if _, err := tx.Exec(ctx, `
UPDATE cost_layer SET remaining_quantity = 0, remaining_value = 0 WHERE org_id = $1 AND move_id = $2`,
org, m.ID); err != nil {
return fmt.Errorf("inventory: close layer: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE item_cost SET quantity = quantity - $3, value = value - $4 WHERE org_id = $1 AND item_id = $2`,
org, m.ItemID, m.Quantity, m.Cost); err != nil {
return mapStockError(fmt.Errorf("inventory: reverse cost: %w", err))
}
return nil
}
// CurrentUnitCost is what one more unit of item would be valued at today
// (R-O2-16): the average under weighted average, the newest layer's unit
// cost under FIFO; zero when the item has never been costed.
func CurrentUnitCost(ctx context.Context, tx pgx.Tx, itemID uuid.UUID) (money.Amount, error) {
org, err := stockOrg(ctx)
if err != nil {
return money.Amount{}, err
}
p, err := company.Get(ctx, tx)
if err != nil {
return money.Amount{}, err
}
unit := money.Zero()
if p.CostingMethod == CostingFIFO {
err = tx.QueryRow(ctx, `
SELECT unit_cost FROM cost_layer WHERE org_id = $1 AND item_id = $2
ORDER BY (remaining_quantity > 0) DESC, seq DESC LIMIT 1`, org, itemID).Scan(&unit)
} else {
var qty money.Rate
var value money.Amount
err = tx.QueryRow(ctx, `SELECT quantity, value FROM item_cost WHERE org_id = $1 AND item_id = $2`,
org, itemID).Scan(&qty, &value)
if err == nil && qty.IsPositive() {
unit, err = value.MulDiv(money.OneRate(), qty)
}
}
if errors.Is(err, pgx.ErrNoRows) {
return money.Zero(), nil
}
return unit, err
}
// CostingLocked reports whether stock has moved, so the costing method is
// fixed (OD-1).
func CostingLocked(ctx context.Context, tx pgx.Tx) (bool, error) {
org, err := stockOrg(ctx)
if err != nil {
return false, err
}
var moved bool
err = tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM stock_move WHERE org_id = $1)`, org).Scan(&moved)
return moved, err
}
  • Step 5: stockline.go

internal/platform/inventory/stockline.go:

package inventory
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/accounting/analytic"
)
// Lot is one serial (Quantity 1) or batch a trade line moves (spec O2.4).
type Lot struct {
SerialNo string `json:"serial_no,omitempty"`
BatchNo string `json:"batch_no,omitempty"`
ExpiresOn *time.Time `json:"expires_on,omitempty"`
Quantity money.Rate `json:"quantity"`
}
// stockLine is a trade line with what moving and posting it needs from its
// header and its item. It reads O1's tables directly (inventory does not
// import trade): withdrawn lines are skipped, the party is the supplier or
// the customer, and an adjustment line's sign is its direction (R-O2-12).
type stockLine struct {
ID, DocumentID uuid.UUID
LineNo int
Kind, Number string
Currency string
PartyID *uuid.UUID
LocationID *uuid.UUID
ItemID *uuid.UUID
ItemType, Tracking string
InventoryAccountID *uuid.UUID
COGSAccountID *uuid.UUID
PurchaseAccountID *uuid.UUID
Quantity money.Rate // in the line's UOM
BaseQuantity money.Rate // trade_line.quantity_base; negative only on an adjustment going out
UnitPrice money.Amount // per line UOM, in Currency
NetAmount money.Amount // trade_line.net_amount: unit price × quantity, to the cent
SourceLineID *uuid.UUID
Shares []analytic.Share
}
func (l stockLine) stock() bool { return l.ItemID != nil && l.ItemType == itemTypeStock }
const stockLineSelect = `
SELECT l.id, l.document_id, l.line_no, d.kind, d.number, d.currency,
coalesce(d.supplier_party_id, d.customer_party_id), d.location_id,
l.item_id, coalesce(i.type, ''), coalesce(i.tracking, ''),
i.inventory_account_id, i.cogs_account_id, i.purchase_account_id,
l.quantity, l.quantity_base, l.unit_price, l.net_amount, l.source_line_id, l.shares
FROM trade_line l
JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id
LEFT JOIN item i ON i.org_id = l.org_id AND i.id = l.item_id
WHERE l.org_id = $1 AND l.withdrawn_at IS NULL`
func scanStockLine(row pgx.Row) (stockLine, error) {
var l stockLine
var shares []byte
if err := row.Scan(&l.ID, &l.DocumentID, &l.LineNo, &l.Kind, &l.Number, &l.Currency, &l.PartyID, &l.LocationID,
&l.ItemID, &l.ItemType, &l.Tracking, &l.InventoryAccountID, &l.COGSAccountID, &l.PurchaseAccountID,
&l.Quantity, &l.BaseQuantity, &l.UnitPrice, &l.NetAmount, &l.SourceLineID, &shares); err != nil {
return stockLine{}, err
}
if len(shares) > 0 {
var stored []struct {
AnalyticAccountID uuid.UUID `json:"analytic_account_id"`
Percentage money.Rate `json:"percentage"`
}
if err := json.Unmarshal(shares, &stored); err != nil {
return stockLine{}, fmt.Errorf("inventory: line shares: %w", err)
}
for _, s := range stored {
l.Shares = append(l.Shares, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage})
}
}
return l, nil
}
// documentLines is doc's lines in line order.
func documentLines(ctx context.Context, tx pgx.Tx, org, docID uuid.UUID) ([]stockLine, error) {
rows, err := tx.Query(ctx, stockLineSelect+` AND l.document_id = $2 ORDER BY l.line_no`, org, docID)
if err != nil {
return nil, fmt.Errorf("inventory: document lines: %w", err)
}
defer rows.Close()
var out []stockLine
for rows.Next() {
l, err := scanStockLine(rows)
if err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// checkLots is what lots must be for an item's tracking: none for an
// untracked item (one lot, the whole quantity), one serial per unit for a
// serial item, batches adding up to the quantity for a batch item.
func checkLots(tracking string, qty money.Rate, lots []Lot) ([]Lot, error) {
if tracking == "none" {
if len(lots) > 0 {
return nil, ErrUntrackedLot
}
return []Lot{{Quantity: qty}}, nil
}
var sum money.Rate
for _, lot := range lots {
switch {
case tracking == "serial" && (lot.SerialNo == "" || lot.BatchNo != "" || lot.Quantity.Cmp(money.OneRate()) != 0):
return nil, ErrSerialRequired
case tracking == "batch" && (lot.BatchNo == "" || lot.SerialNo != "" || !lot.Quantity.IsPositive()):
return nil, ErrBatchRequired
}
sum = sum.Add(lot.Quantity)
}
if len(lots) == 0 || sum.Cmp(qty) != 0 {
return nil, ErrLotsMismatch
}
return lots, nil
}
// lineLots is l's captured lots, checked against its item.
func lineLots(ctx context.Context, tx pgx.Tx, org uuid.UUID, l stockLine) ([]Lot, error) {
rows, err := tx.Query(ctx, `
SELECT coalesce(serial_no, ''), coalesce(batch_no, ''), expires_on, quantity
FROM trade_line_lot WHERE org_id = $1 AND trade_line_id = $2
ORDER BY serial_no NULLS LAST, batch_no NULLS LAST`, org, l.ID)
if err != nil {
return nil, fmt.Errorf("inventory: lots: %w", err)
}
defer rows.Close()
var lots []Lot
for rows.Next() {
var lot Lot
if err := rows.Scan(&lot.SerialNo, &lot.BatchNo, &lot.ExpiresOn, &lot.Quantity); err != nil {
return nil, err
}
lots = append(lots, lot)
}
if err := rows.Err(); err != nil {
return nil, err
}
return checkLots(l.Tracking, l.BaseQuantity, lots)
}
// SetLineLots replaces the serials and batches of a line that has not
// moved (a draft). The caller holds the document header FOR UPDATE.
func SetLineLots(ctx context.Context, tx pgx.Tx, lineID uuid.UUID, lots []Lot) error {
org, err := stockOrg(ctx)
if err != nil {
return err
}
var moved bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM stock_move WHERE org_id = $1 AND trade_line_id = $2)`,
org, lineID).Scan(&moved); err != nil {
return fmt.Errorf("inventory: lots: %w", err)
}
if moved {
return ErrAlreadyApplied
}
if _, err := tx.Exec(ctx, `DELETE FROM trade_line_lot WHERE org_id = $1 AND trade_line_id = $2`, org, lineID); err != nil {
return fmt.Errorf("inventory: lots: %w", err)
}
for _, lot := range lots {
if _, err := tx.Exec(ctx, `
INSERT INTO trade_line_lot (org_id, trade_line_id, serial_no, batch_no, expires_on, quantity)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5, $6)`,
org, lineID, lot.SerialNo, lot.BatchNo, lot.ExpiresOn, lot.Quantity); err != nil {
return mapStockError(fmt.Errorf("inventory: lot: %w", err))
}
}
return nil
}
// recordBatch keeps a batch's first known expiry, and refuses another.
func recordBatch(ctx context.Context, tx pgx.Tx, org, item uuid.UUID, lot Lot) error {
var stored *time.Time
err := tx.QueryRow(ctx, `
INSERT INTO stock_batch (org_id, item_id, batch_no, expires_on) VALUES ($1, $2, $3, $4)
ON CONFLICT ON CONSTRAINT stock_batch_pkey
DO UPDATE SET expires_on = coalesce(stock_batch.expires_on, EXCLUDED.expires_on)
RETURNING expires_on`, org, item, lot.BatchNo, lot.ExpiresOn).Scan(&stored)
if err != nil {
return fmt.Errorf("inventory: batch: %w", err)
}
if lot.ExpiresOn != nil && (stored == nil || !stored.Equal(*lot.ExpiresOn)) {
return ErrBatchExpiryMismatch
}
return nil
}
  • Step 6: apply.go

internal/platform/inventory/apply.go:

package inventory
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/fx"
"github.com/mcom/erp/internal/platform/company"
"github.com/mcom/erp/internal/platform/posting"
"github.com/mcom/erp/internal/platform/workflow"
)
var stockKinds = map[string]bool{KindGoodsReceipt: true, KindPurchaseReturn: true, KindDeliveryOrder: true,
KindSalesReturn: true, KindStockAdjustment: true}
// Received is what a goods-receipt line brought in, net of any reversal.
type Received struct {
Quantity money.Rate // base UOM
Amount money.Amount // document currency
Currency string
Base money.Amount // base currency: what GRNI was credited
}
// ReceivedValue is Received for one goods-receipt line; zero when it was
// never received or its receipt was cancelled.
func ReceivedValue(ctx context.Context, tx pgx.Tx, receiptLineID uuid.UUID) (Received, error) {
org, err := stockOrg(ctx)
if err != nil {
return Received{}, err
}
return receivedValue(ctx, tx, org, receiptLineID)
}
func receivedValue(ctx context.Context, tx pgx.Tx, org, line uuid.UUID) (Received, error) {
var r Received
err := tx.QueryRow(ctx, `
SELECT coalesce(sum(quantity), 0), coalesce(sum(amount), 0), coalesce(max(currency), ''), coalesce(sum(base_amount), 0)
FROM receipt_valuation WHERE org_id = $1 AND trade_line_id = $2`, org, line).
Scan(&r.Quantity, &r.Amount, &r.Currency, &r.Base)
if err != nil {
return Received{}, fmt.Errorf("inventory: received value: %w", err)
}
return r, nil
}
// liveLineCost is the signed cost of a line's moves that are neither
// reversals nor reversed: positive in, negative out.
func liveLineCost(ctx context.Context, tx pgx.Tx, org, line uuid.UUID) (money.Amount, error) {
var v money.Amount
err := tx.QueryRow(ctx, `
SELECT coalesce(sum(m.total_cost), 0) FROM stock_move m
WHERE m.org_id = $1 AND m.trade_line_id = $2 AND m.reverses_move_id IS NULL
AND NOT EXISTS (SELECT 1 FROM stock_move r WHERE r.org_id = m.org_id AND r.reverses_move_id = m.id)`,
org, line).Scan(&v)
if err != nil {
return money.Amount{}, fmt.Errorf("inventory: line cost: %w", err)
}
return v, nil
}
// toBase converts amount at today's rate, the rate a receipt is valued at
// (R-O2-3) and the one ledger.Post would use for the same date.
func toBase(ctx context.Context, tx pgx.Tx, c clock.Clock, amount money.Amount, currency string) (money.Amount, error) {
p, err := company.Get(ctx, tx)
if err != nil {
return money.Amount{}, err
}
q, err := fx.Resolve(ctx, tx, currency, p.BaseCurrency, clock.Today(ctx, c))
if err != nil {
return money.Amount{}, err
}
return amount.MulRate(q.Rate), nil
}
// apply moves the stock of every line of doc. Lines are taken in line
// order after every item's cost row is locked (beginStock).
func apply(ctx context.Context, tx pgx.Tx, c clock.Clock, docID uuid.UUID) error {
org, err := stockOrg(ctx)
if err != nil {
return err
}
lines, err := documentLines(ctx, tx, org, docID)
if err != nil {
return err
}
var items []uuid.UUID
for _, l := range lines {
if l.stock() {
items = append(items, *l.ItemID)
}
}
method, err := beginStock(ctx, tx, org, items)
if err != nil {
return err
}
for _, l := range lines {
var err error
switch l.Kind {
case KindGoodsReceipt:
err = receiveLine(ctx, tx, c, org, method, l)
case KindDeliveryOrder:
err = issueLine(ctx, tx, c, org, method, l)
case KindPurchaseReturn:
err = returnOutLine(ctx, tx, c, org, method, l)
case KindSalesReturn:
err = returnInLine(ctx, tx, c, org, method, l)
case KindStockAdjustment:
err = adjustLine(ctx, tx, c, org, method, l)
default:
return fmt.Errorf("%w: %s", ErrWrongKind, l.Kind)
}
if err != nil {
return fmt.Errorf("inventory: line %d: %w", l.LineNo, err)
}
}
return nil
}
// receiveLine values a receipt line at its net amount converted at today's
// rate (R-O2-3), records it, and brings a stock item in at that
// value. A service or non-stock line is received too (spec O3.1) and moves
// nothing.
func receiveLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error {
if l.ItemID == nil {
return ErrLineNeedsItem
}
held, err := receivedValue(ctx, tx, org, l.ID)
if err != nil {
return err
}
if !held.Quantity.IsZero() {
return ErrAlreadyApplied
}
amount := l.NetAmount
base, err := toBase(ctx, tx, c, amount, l.Currency)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO receipt_valuation (org_id, trade_line_id, quantity, amount, currency, base_amount, valued_on, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
org, l.ID, l.BaseQuantity, amount, l.Currency, base, clock.Today(ctx, c), c.Now()); err != nil {
return fmt.Errorf("inventory: receipt valuation: %w", err)
}
if !l.stock() {
return nil
}
line := l.ID
return moveIn(ctx, tx, c, org, method, l, base, &line)
}
func issueLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error {
if !l.stock() {
return nil // a service or text line delivers no stock
}
return moveOut(ctx, tx, c, org, method, l, outRule{})
}
// returnOutLine sends a purchase return's goods back at their receipt's
// cost: under FIFO from that receipt's own layers (the goods must still be
// held), under weighted average at the receipt's unit value (spec O3.1).
func returnOutLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error {
if l.ItemID == nil {
return ErrLineNeedsItem
}
if l.SourceLineID == nil {
return ErrNoSourceLine
}
if !l.stock() {
return nil
}
src, err := receivedValue(ctx, tx, org, *l.SourceLineID)
if err != nil {
return err
}
if !src.Quantity.IsPositive() {
return ErrNoSourceLine
}
at, err := src.Base.MulDiv(l.BaseQuantity, src.Quantity)
if err != nil {
return err
}
return moveOut(ctx, tx, c, org, method, l, outRule{receiptLine: l.SourceLineID, atCost: &at})
}
// returnInLine brings a sales return's goods back at the cost they left at:
// the delivery line's own moves, pro rata (spec O3.2).
func returnInLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error {
if !l.stock() {
return nil
}
if l.SourceLineID == nil {
return ErrNoSourceLine
}
var qty money.Rate
var cost money.Amount
if err := tx.QueryRow(ctx, `
SELECT coalesce(sum(m.quantity), 0), coalesce(sum(m.total_cost), 0) FROM stock_move m
WHERE m.org_id = $1 AND m.trade_line_id = $2 AND m.reverses_move_id IS NULL
AND NOT EXISTS (SELECT 1 FROM stock_move r WHERE r.org_id = m.org_id AND r.reverses_move_id = m.id)`,
org, *l.SourceLineID).Scan(&qty, &cost); err != nil {
return fmt.Errorf("inventory: delivered cost: %w", err)
}
if !qty.IsNegative() {
return ErrNoSourceLine
}
back, err := cost.Neg().MulDiv(l.BaseQuantity, qty.Neg())
if err != nil {
return err
}
return moveIn(ctx, tx, c, org, method, l, back, nil)
}
// adjustLine: the quantity's sign is the direction (R-O2-12, O1's
// trade_line_quantity_sign). In at the line's unit cost (base currency,
// base UOM), out at FIFO or average cost; the moves then carry the size.
func adjustLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error {
if !l.stock() {
return ErrNotStockItem
}
if l.BaseQuantity.IsNegative() {
l.BaseQuantity = l.BaseQuantity.Neg()
return moveOut(ctx, tx, c, org, method, l, outRule{})
}
return moveIn(ctx, tx, c, org, method, l, l.UnitPrice.MulRate(l.BaseQuantity), nil)
}
// moveIn brings l's base quantity into its location at total, one move per
// lot, the cost split pro rata with the last lot taking the residue.
func moveIn(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine,
total money.Amount, receiptLine *uuid.UUID) error {
if l.LocationID == nil {
return ErrNoLocation
}
lots, err := lineLots(ctx, tx, org, l)
if err != nil {
return err
}
allocated := money.Zero()
for i, lot := range lots {
cost := total.Sub(allocated)
if i < len(lots)-1 {
if cost, err = total.MulDiv(lot.Quantity, l.BaseQuantity); err != nil {
return err
}
}
allocated = allocated.Add(cost)
line := l.ID
move, err := insertMove(ctx, tx, c, moveInput{ItemID: *l.ItemID, LocationID: *l.LocationID, Quantity: lot.Quantity,
Cost: cost, SerialNo: lot.SerialNo, BatchNo: lot.BatchNo, TradeLineID: &line})
if err != nil {
return err
}
if lot.BatchNo != "" {
if err := recordBatch(ctx, tx, org, *l.ItemID, lot); err != nil {
return err
}
}
if err := costIn(ctx, tx, org, method, *l.ItemID, move, lot.Quantity, cost, receiptLine); err != nil {
return err
}
}
return nil
}
// moveOut takes l's base quantity out of its location: the cost is decided
// once for the line (costOut), then split over its lots like moveIn.
func moveOut(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine, r outRule) error {
if l.LocationID == nil {
return ErrNoLocation
}
lots, err := lineLots(ctx, tx, org, l)
if err != nil {
return err
}
total, err := costOut(ctx, tx, org, method, *l.ItemID, l.BaseQuantity, r)
if err != nil {
return err
}
allocated := money.Zero()
for i, lot := range lots {
cost := total.Sub(allocated)
if i < len(lots)-1 {
if cost, err = total.MulDiv(lot.Quantity, l.BaseQuantity); err != nil {
return err
}
}
allocated = allocated.Add(cost)
line := l.ID
if _, err := insertMove(ctx, tx, c, moveInput{ItemID: *l.ItemID, LocationID: *l.LocationID,
Quantity: lot.Quantity.Neg(), Cost: cost.Neg(), SerialNo: lot.SerialNo, BatchNo: lot.BatchNo,
TradeLineID: &line}); err != nil {
return err
}
}
return nil
}
// liveMove is one unreversed move of a document, with what reversing it needs.
type liveMove struct {
ID, ItemID, LocationID, LineID uuid.UUID
Quantity money.Rate
Cost money.Amount
SerialNo, BatchNo string
Kind string
SourceLineID *uuid.UUID
}
// reverse mirrors every unreversed move of doc, newest first, and takes
// the costing back with it: an inbound move leaves at the cost it came in
// at (FIFO: its layer must be untouched); an outbound move comes back at
// the cost it left at, as a new layer under FIFO (R-O2-6). A receipt's
// valuation gets its negating row.
func reverse(ctx context.Context, tx pgx.Tx, c clock.Clock, docID uuid.UUID) error {
org, err := stockOrg(ctx)
if err != nil {
return err
}
rows, err := tx.Query(ctx, `
SELECT m.id, m.item_id, m.location_id, m.trade_line_id, m.quantity, m.total_cost,
coalesce(m.serial_no, ''), coalesce(m.batch_no, ''), d.kind, l.source_line_id
FROM stock_move m
JOIN trade_line l ON l.org_id = m.org_id AND l.id = m.trade_line_id
JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id
WHERE m.org_id = $1 AND l.document_id = $2 AND m.reverses_move_id IS NULL
AND NOT EXISTS (SELECT 1 FROM stock_move r WHERE r.org_id = m.org_id AND r.reverses_move_id = m.id)
ORDER BY m.seq DESC`, org, docID)
if err != nil {
return fmt.Errorf("inventory: reverse: %w", err)
}
var moves []liveMove
var items []uuid.UUID
for rows.Next() {
var m liveMove
if err := rows.Scan(&m.ID, &m.ItemID, &m.LocationID, &m.LineID, &m.Quantity, &m.Cost,
&m.SerialNo, &m.BatchNo, &m.Kind, &m.SourceLineID); err != nil {
rows.Close()
return err
}
moves = append(moves, m)
items = append(items, m.ItemID)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
method, err := beginStock(ctx, tx, org, items)
if err != nil {
return err
}
for _, m := range moves {
if m.Quantity.IsPositive() {
if err := uncostIn(ctx, tx, org, method, m); err != nil {
return err
}
}
move, line, reverses := m.ID, m.LineID, m.ID
mirror, err := insertMove(ctx, tx, c, moveInput{ItemID: m.ItemID, LocationID: m.LocationID,
Quantity: m.Quantity.Neg(), Cost: m.Cost.Neg(), SerialNo: m.SerialNo, BatchNo: m.BatchNo,
TradeLineID: &line, ReversesID: &reverses})
if err != nil {
return fmt.Errorf("inventory: reverse move %s: %w", move, err)
}
if m.Quantity.IsNegative() {
var receiptLine *uuid.UUID
if m.Kind == KindPurchaseReturn {
receiptLine = m.SourceLineID
}
if err := costIn(ctx, tx, org, method, m.ItemID, mirror, m.Quantity.Neg(), m.Cost.Neg(), receiptLine); err != nil {
return err
}
}
}
if _, err := tx.Exec(ctx, `
INSERT INTO receipt_valuation (org_id, trade_line_id, quantity, amount, currency, base_amount, valued_on, reverses_id, created_at)
SELECT v.org_id, v.trade_line_id, -v.quantity, -v.amount, v.currency, -v.base_amount, $3, v.id, $4
FROM receipt_valuation v
JOIN trade_line l ON l.org_id = v.org_id AND l.id = v.trade_line_id
WHERE v.org_id = $1 AND l.document_id = $2 AND v.reverses_id IS NULL
AND NOT EXISTS (SELECT 1 FROM receipt_valuation r WHERE r.org_id = v.org_id AND r.reverses_id = v.id)`,
org, docID, clock.Today(ctx, c), c.Now()); err != nil {
return fmt.Errorf("inventory: reverse valuation: %w", err)
}
return nil
}
// OnTransition is a stock document's stock half of a transition, called
// after the header lock and before workflow.Transition: entering the
// posting state (accrual for a receipt or purchase return, effect
// otherwise) moves the stock; entering rejected or cancelled after it
// (posted: the instance's AccrualOpen before the move) reverses the moves.
func OnTransition(ctx context.Context, tx pgx.Tx, c clock.Clock, docID uuid.UUID, to posting.Semantic, posted bool) error {
switch {
case to == posting.Accrual || to == posting.Effect:
return apply(ctx, tx, c, docID)
case (to == posting.Rejected || to == posting.Cancelled) && posted:
return reverse(ctx, tx, c, docID)
}
return nil
}
// TransitionInput moves one stock document along its workflow.
type TransitionInput struct {
DocumentID uuid.UUID
From, To string
Actor uuid.UUID
Reason string
}
// Transition is the one way a stock document changes state (R-O2-7: the
// commitment kinds use trade.Transition). ORDER (LOCK_ORDER.md, inventory):
// trade_document FOR UPDATE → on a cancel, its live lines FOR UPDATE in id
// order → company_profile
// FOR SHARE → item_cost rows in item id order → cost_layer rows in seq
// order → stock_balance (stock_move's trigger) → workflow.Transition (the
// Poster reads the moves) → audit, last. O3's receipt, delivery and return
// transitions run their own checks under the same header lock, then call
// this (a second FOR UPDATE of a held row is a no-op).
func Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (workflow.Decision, error) {
org, err := stockOrg(ctx)
if err != nil {
return workflow.Decision{}, err
}
var kind string
var instanceID uuid.UUID
err = tx.QueryRow(ctx, `SELECT kind, instance_id FROM trade_document WHERE org_id = $1 AND id = $2 FOR UPDATE`,
org, in.DocumentID).Scan(&kind, &instanceID)
if errors.Is(err, pgx.ErrNoRows) {
return workflow.Decision{}, ErrDocumentNotFound
}
if err != nil {
return workflow.Decision{}, fmt.Errorf("inventory: lock document: %w", err)
}
if !stockKinds[kind] {
return workflow.Decision{}, fmt.Errorf("%w: %s", ErrWrongKind, kind)
}
inst, err := workflow.Get(ctx, tx, instanceID)
if err != nil {
return workflow.Decision{}, err
}
def, err := workflow.Load(ctx, tx, inst.DefinitionID)
if err != nil {
return workflow.Decision{}, err
}
to, ok := def.State(in.To)
if !ok {
return workflow.Decision{}, workflow.ErrNoSuchEdge
}
if to.Semantic == posting.Cancelled {
// trade.Transition's rule (Ruling O1-7), with its lock: a document
// others draw from (a receipt with a live return) is not cancelled.
if _, err := tx.Exec(ctx, `SELECT 1 FROM trade_line WHERE org_id = $1 AND document_id = $2
AND withdrawn_at IS NULL ORDER BY id FOR UPDATE`, org, in.DocumentID); err != nil {
return workflow.Decision{}, fmt.Errorf("inventory: lock lines: %w", err)
}
var live int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM trade_line c
JOIN trade_line p ON p.org_id = c.org_id AND p.id = c.source_line_id
JOIN trade_document d ON d.org_id = c.org_id AND d.id = c.document_id
WHERE c.org_id = $1 AND p.document_id = $2 AND c.withdrawn_at IS NULL
AND trade_semantic(d.org_id, d.instance_id) IS DISTINCT FROM 'cancelled'`, org, in.DocumentID).Scan(&live); err != nil {
return workflow.Decision{}, fmt.Errorf("inventory: live children: %w", err)
}
if live > 0 {
return workflow.Decision{}, ErrHasChildren
}
}
if err := OnTransition(ctx, tx, c, in.DocumentID, to.Semantic, inst.AccrualOpen); err != nil {
return workflow.Decision{}, err
}
d, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: instanceID,
From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason})
if err != nil {
return workflow.Decision{}, err
}
actor := in.Actor
return d, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "trade_document", EntityID: in.DocumentID,
Action: "transition", ActorID: &actor, Before: map[string]string{"state": inst.StateKey},
After: map[string]string{"state": in.To}})
}

Run: make generate && git status --short

  • Step 7: Run the tests to verify they pass

Run: go test ./internal/platform/inventory/... ./internal/kernel/db/ ./internal/kernel/layering/ -count=1 Expected: PASS (TestEveryTenantQueryCarriesAnExplicitOrgPredicate included).

  • Step 8: Sabotage

In costOut, delete the case qty.Cmp(held) == 0: branch; run -run TestWeightedAverageIssues; read the red (-1.6666 expected); restore. In uncostIn, delete the qty.Cmp(remaining) != 0 check; run -run TestReversingADocument; read the red; restore.

  • Step 9: Commit
Terminal window
git add db/migrations/00724_cost_layers.sql db/migrations/00725_stock_documents.sql internal/
git commit -m "Phase 4 O2: FIFO and weighted-average costing; stock documents move stock
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: Posting — receipts to GRNI, effects at cost, reversals

Section titled “Task 7: Posting — receipts to GRNI, effects at cost, reversals”

Files:

  • Create: internal/platform/inventory/posting.go
  • Modify: internal/platform/inventory/stocktest/stocktest.go (Poster in New; Post, Cancel, MoveErr, Entries, LinesByAccount, AccountBalance)
  • Create: internal/platform/inventory/posting_test.go

Interfaces:

  • Consumes: documentLines, stockLine, receivedValue, liveLineCost, Transition (Task 6); chart.PurchasePriceVariance, ControlKindGRNI (Task 2); posting.Effect (Task 1); the flows’ state keys (Task 5).
  • Produces: inventory.Rules() []accounting.PostingRule — (goods_receipt, accrual), (purchase_return, accrual), (delivery_order, effect), (sales_return, effect), (stock_adjustment, effect); errors ErrNothingReceived, ErrPostingMismatch, ErrItemAccountMissing. stocktest: the real Poster in New, EntryFor(t, instanceID, semantic) ledger.Entry, Entries(t, instanceID) []ledger.Entry, LinesByAccount(ledger.Entry) map[uuid.UUID]ledger.PostedLine, AccountBalance(t, account) money.Amount (base debit − credit).

Entries, all in base currency:

Document Semantic Entry
Goods receipt accrual (grni) Dr Inventory (stock) or the item’s purchase account (service, non-stock), each line at its receipt_valuation base / Cr GRNI, party the supplier
Purchase return accrual (grni) Dr GRNI at the receipt’s value pro rata / Cr Inventory at the moves’ cost (or the purchase account at the GRNI value); the difference to PPV (R-O2-15)
Delivery effect per line: Dr the item’s COGS / Cr Inventory at the moves’ cost
Sales return effect per line: Dr Inventory / Cr COGS at the moves’ cost
Stock adjustment effect per line: Inventory against the reason’s account, direction by the cost’s sign
Cancel after posting reversal the engine’s mirror; Transition reverses the moves first
  • Step 1: Write the failing tests

internal/platform/inventory/stocktest/stocktest.go — in New, replace Task 6’s e.nopPoster(t) with:

// The stock documents post through the real rules (plan global
// constraint: posting is process-global; never t.Parallel()).
e.UsePoster(t, slices.Concat(inventory.Rules(), payables.Rules())...)

delete nopPoster and its method, and append (imports: slices, github.com/mcom/erp/internal/platform/accounting/ledger, github.com/mcom/erp/internal/platform/payables):

// EntryFor is the instance's unreversed entry of semantic, with its lines.
func (e *Env) EntryFor(t *testing.T, instanceID uuid.UUID, semantic string) ledger.Entry {
t.Helper()
var out ledger.Entry
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
open, err := ledger.Unreversed(ctx, tx, instanceID)
if err != nil {
return err
}
for _, en := range open {
if en.Semantic == semantic {
out, err = ledger.Get(ctx, tx, en.ID)
return err
}
}
return nil
})
require.NotEqual(t, uuid.Nil, out.ID, "no unreversed %s entry for instance %s", semantic, instanceID)
return out
}
// Entries is the instance's entries with their lines, oldest first.
func (e *Env) Entries(t *testing.T, instanceID uuid.UUID) []ledger.Entry {
t.Helper()
var out []ledger.Entry
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
heads, err := ledger.List(ctx, tx, ledger.Filter{InstanceID: &instanceID})
if err != nil {
return err
}
for i := len(heads) - 1; i >= 0; i-- {
full, err := ledger.Get(ctx, tx, heads[i].ID)
if err != nil {
return err
}
out = append(out, full)
}
return nil
})
return out
}
// LinesByAccount indexes an entry's lines by account; a test using it
// posts at most one line per account.
func LinesByAccount(en ledger.Entry) map[uuid.UUID]ledger.PostedLine {
out := map[uuid.UUID]ledger.PostedLine{}
for _, l := range en.Lines {
out[l.AccountID] = l
}
return out
}
// AccountBalance is the account's base debit − credit over every line.
func (e *Env) AccountBalance(t *testing.T, account uuid.UUID) money.Amount {
t.Helper()
v := Amt("0")
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
SELECT coalesce(sum(base_debit - base_credit), 0) FROM journal_line WHERE org_id = $1 AND account_id = $2`,
e.Org, account).Scan(&v))
return v
}

internal/platform/inventory/posting_test.go:

package inventory_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/ledger"
"github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/inventory/stocktest"
)
func TestAReceiptPostsInventoryAndExpenseAgainstGRNI(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
labour := e.Item(t, "LABOUR", "service", "none")
sup := e.SupplierParty(t)
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR",
stocktest.Line(bolt, "10", "1.50"), stocktest.Line(labour, "2", "100.00"))
e.Post(t, r)
en := e.EntryFor(t, r.InstanceID, ledger.SemanticAccrual)
require.Equal(t, "MYR", en.Currency)
lines := stocktest.LinesByAccount(en)
stocktest.RequireAmount(t, "15", lines[e.InventoryAccount].BaseDebit)
stocktest.RequireAmount(t, "200", lines[e.PurchaseAccount].BaseDebit, "a service is expensed on receipt")
stocktest.RequireAmount(t, "215", lines[e.GRNI].BaseCredit)
require.Equal(t, &sup, lines[e.GRNI].PartyID)
q, v := e.ItemCost(t, bolt)
stocktest.RequireQty(t, "10", q)
stocktest.RequireAmount(t, "15", v)
}
func TestAForeignReceiptPostsInBaseAtItsDatesRate(t *testing.T) {
e := stocktest.New(t)
e.SeedRate(t, "USD", "2026-09-18", "4.2000")
bolt := e.Item(t, "BOLT", "stock", "none")
sup := e.SupplierParty(t)
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "USD", stocktest.Line(bolt, "10", "2.00"))
e.Post(t, r)
en := e.EntryFor(t, r.InstanceID, ledger.SemanticAccrual)
require.Equal(t, "MYR", en.Currency, "stock entries post in base (R-O2-3)")
stocktest.RequireAmount(t, "84", stocktest.LinesByAccount(en)[e.GRNI].BaseCredit)
}
func TestADeliveryPostsCostOfSalesAtCost(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50")))
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00"))
e.Post(t, d)
lines := stocktest.LinesByAccount(e.EntryFor(t, d.InstanceID, ledger.SemanticEffect))
stocktest.RequireAmount(t, "6", lines[e.COGSAccount].BaseDebit)
stocktest.RequireAmount(t, "6", lines[e.InventoryAccount].BaseCredit)
stocktest.RequireAmount(t, "9", e.AccountBalance(t, e.InventoryAccount))
}
func TestADeliveryOfServicesPostsNothingAndCancelsCleanly(t *testing.T) {
e := stocktest.New(t)
labour := e.Item(t, "LABOUR", "service", "none")
cust := e.CustomerParty(t)
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(labour, "1", "300.00"))
e.Post(t, d)
require.Empty(t, e.Entries(t, d.InstanceID), "no stock, no cost: nothing posts (R-O2-1)")
e.Cancel(t, d)
require.Empty(t, e.Entries(t, d.InstanceID))
}
func TestCancellingAReceiptReversesItsEntryAndItsStock(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
sup := e.SupplierParty(t)
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50"))
e.Post(t, r)
stocktest.RequireAmount(t, "-15", e.AccountBalance(t, e.GRNI), "PRESENCE: GRNI holds the receipt")
e.Cancel(t, r)
en := e.Entries(t, r.InstanceID)
require.Len(t, en, 2)
require.Equal(t, ledger.SemanticReversal, en[1].Semantic)
stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.GRNI))
stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.InventoryAccount))
q, v := e.ItemCost(t, bolt)
stocktest.RequireQty(t, "0", q)
stocktest.RequireAmount(t, "0", v)
stocktest.RequireQty(t, "0", e.Received(t, r.Lines[0]).Quantity)
e.RequireBalancesAddUp(t)
}
func TestAPurchaseReturnClearsGRNIAtReceiptValueWithTheDifferenceToPPV(t *testing.T) {
e := stocktest.New(t) // weighted average
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
a := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "2", "1.00"))
e.Post(t, a)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "2", "3.00")))
e.Post(t, e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "2", "9.00")))
// Two units left, worth 4.00; returning a's two empties the item, so
// they leave carrying the 4.00 (the residue rule) against a GRNI of 2.00.
ret := e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR",
stocktest.DocLine{Item: bolt, Qty: "2", Price: "1.00", Source: &a.Lines[0]})
e.Post(t, ret)
lines := stocktest.LinesByAccount(e.EntryFor(t, ret.InstanceID, ledger.SemanticAccrual))
stocktest.RequireAmount(t, "2", lines[e.GRNI].BaseDebit)
stocktest.RequireAmount(t, "4", lines[e.InventoryAccount].BaseCredit)
stocktest.RequireAmount(t, "2", lines[e.PPV].BaseDebit)
stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.InventoryAccount))
}
func TestAFIFOPurchaseReturnHasNoVariance(t *testing.T) {
e := stocktest.New(t)
e.UseCosting(t, inventory.CostingFIFO)
bolt := e.Item(t, "BOLT", "stock", "none")
sup := e.SupplierParty(t)
a := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00"))
e.Post(t, a)
ret := e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR",
stocktest.DocLine{Item: bolt, Qty: "4", Price: "1.00", Source: &a.Lines[0]})
e.Post(t, ret)
lines := stocktest.LinesByAccount(e.EntryFor(t, ret.InstanceID, ledger.SemanticAccrual))
stocktest.RequireAmount(t, "4", lines[e.GRNI].BaseDebit)
stocktest.RequireAmount(t, "4", lines[e.InventoryAccount].BaseCredit)
_, ppv := lines[e.PPV]
require.False(t, ppv, "the receipt's own layer leaves at the receipt's value")
require.ErrorIs(t, e.MoveErr(a, e.Owner, "received", "cancelled", "Entered in error"), inventory.ErrHasChildren,
"a receipt with a live return is not cancelled (O1-7)")
e.Cancel(t, ret)
e.Cancel(t, a)
}
func TestASalesReturnReversesCostOfSalesAtTheDeliveredCost(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00")))
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "3.00")))
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00"))
e.Post(t, d)
r := e.Doc(t, inventory.KindSalesReturn, &cust, "MYR",
stocktest.DocLine{Item: bolt, Qty: "2", Price: "9.00", Source: &d.Lines[0]})
e.Post(t, r)
lines := stocktest.LinesByAccount(e.EntryFor(t, r.InstanceID, ledger.SemanticEffect))
stocktest.RequireAmount(t, "4", lines[e.InventoryAccount].BaseDebit)
stocktest.RequireAmount(t, "4", lines[e.COGSAccount].BaseCredit)
}
  • Step 2: Run the tests to verify they fail

Run: go test ./internal/platform/inventory/ -run 'Receipt|Delivery|Return|Posts' -count=1 Expected: build failure undefined: inventory.Rules.

  • Step 3: posting.go

internal/platform/inventory/posting.go:

// The stock posting rules (spec O2.3). Each reads its document's lines,
// valuation and moves on the transition's own tx and returns a Draft in
// base currency; it never writes a journal line. Reversal is the engine's
// mirror (Ruling R7); Transition reverses the moves before it.
package inventory
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/accounting"
"github.com/mcom/erp/internal/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/accounting/ledger"
"github.com/mcom/erp/internal/platform/company"
"github.com/mcom/erp/internal/platform/posting"
)
var (
ErrNothingReceived = errors.New("inventory: the receipt has no value to post")
ErrPostingMismatch = errors.New("inventory: the event's instance or kind is not this document's")
ErrItemAccountMissing = errors.New("inventory: the item names no account for this posting")
)
// Rules is every posting rule this package owns. internal/modules/inventory
// registers them all in one loop.
func Rules() []accounting.PostingRule {
return []accounting.PostingRule{
grniRule{kind: KindGoodsReceipt}, grniRule{kind: KindPurchaseReturn},
effectRule{kind: KindDeliveryOrder}, effectRule{kind: KindSalesReturn}, effectRule{kind: KindStockAdjustment},
}
}
var docLabel = map[string]string{KindGoodsReceipt: "Goods receipt", KindPurchaseReturn: "Purchase return",
KindDeliveryOrder: "Delivery order", KindSalesReturn: "Sales return", KindStockAdjustment: "Stock adjustment"}
// docHead is what a rule needs of the document itself.
type docHead struct {
ID, InstanceID uuid.UUID
Kind, Number string
PartyID *uuid.UUID
ReasonAccountID *uuid.UUID
}
func readHead(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) (docHead, error) {
var h docHead
err := tx.QueryRow(ctx, `
SELECT d.id, d.instance_id, d.kind, d.number, coalesce(d.supplier_party_id, d.customer_party_id), r.account_id
FROM trade_document d
LEFT JOIN stock_adjustment_detail a ON a.org_id = d.org_id AND a.document_id = d.id
LEFT JOIN stock_adjustment_reason r ON r.org_id = a.org_id AND r.id = a.reason_id
WHERE d.org_id = $1 AND d.id = $2`, org, id).
Scan(&h.ID, &h.InstanceID, &h.Kind, &h.Number, &h.PartyID, &h.ReasonAccountID)
if errors.Is(err, pgx.ErrNoRows) {
return docHead{}, ErrDocumentNotFound
}
return h, err
}
// ruleStart is every rule's first half: the organization, the document
// (matching the event), its lines and the base currency.
func ruleStart(ctx context.Context, tx pgx.Tx, kind string, src accounting.Source) (uuid.UUID, docHead, []stockLine, string, error) {
org, err := stockOrg(ctx)
if err != nil {
return uuid.Nil, docHead{}, nil, "", err
}
h, err := readHead(ctx, tx, org, src.Event.SourceID)
if err != nil {
return uuid.Nil, docHead{}, nil, "", err
}
if h.InstanceID != src.Event.InstanceID || h.Kind != kind {
return uuid.Nil, docHead{}, nil, "", ErrPostingMismatch
}
lines, err := documentLines(ctx, tx, org, h.ID)
if err != nil {
return uuid.Nil, docHead{}, nil, "", err
}
p, err := company.Get(ctx, tx)
if err != nil {
return uuid.Nil, docHead{}, nil, "", err
}
return org, h, lines, p.BaseCurrency, nil
}
func memo(h docHead, l stockLine) string { return fmt.Sprintf("%s L%d", h.Number, l.LineNo) }
// stockSide is the account a line's goods sit in: Inventory for a stock
// item, the item's purchase account for a service or non-stock one.
func (l stockLine) stockSide() (uuid.UUID, error) {
a := l.PurchaseAccountID
if l.stock() {
a = l.InventoryAccountID
}
if a == nil {
return uuid.Nil, fmt.Errorf("%w: line %d", ErrItemAccountMissing, l.LineNo)
}
return *a, nil
}
func debit(account uuid.UUID, amt money.Amount, m string) ledger.Line {
return ledger.Line{AccountID: account, Debit: amt, Credit: money.Zero(), Memo: m}
}
func credit(account uuid.UUID, amt money.Amount, m string) ledger.Line {
return ledger.Line{AccountID: account, Debit: money.Zero(), Credit: amt, Memo: m}
}
// grniRule posts a goods receipt (Dr stock side / Cr GRNI) and a purchase
// return (Dr GRNI at the receipt's value / Cr stock side at cost, the
// difference to purchase price variance, R-O2-15).
type grniRule struct{ kind string }
var _ accounting.PostingRule = grniRule{}
func (r grniRule) Kind() string { return r.kind }
func (grniRule) Semantic() posting.Semantic { return posting.Accrual }
func (r grniRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) {
if src.Control == nil {
return accounting.Draft{}, fmt.Errorf("inventory: %s: %w", r.kind, chart.ErrNotFound)
}
org, h, lines, base, err := ruleStart(ctx, tx, r.kind, src)
if err != nil {
return accounting.Draft{}, err
}
var out []ledger.Line
grni := money.Zero()
for _, l := range lines {
if l.ItemID == nil {
continue
}
side, err := l.stockSide()
if err != nil {
return accounting.Draft{}, err
}
m := memo(h, l)
if r.kind == KindGoodsReceipt {
v, err := receivedValue(ctx, tx, org, l.ID)
if err != nil {
return accounting.Draft{}, err
}
if v.Base.IsZero() {
continue
}
line := debit(side, v.Base, m)
if !l.stock() {
line.Distribution = l.Shares
}
out = append(out, line)
grni = grni.Add(v.Base)
continue
}
if l.SourceLineID == nil {
return accounting.Draft{}, ErrNoSourceLine
}
rv, err := receivedValue(ctx, tx, org, *l.SourceLineID)
if err != nil {
return accounting.Draft{}, err
}
if !rv.Quantity.IsPositive() {
return accounting.Draft{}, ErrNoSourceLine
}
gr, err := rv.Base.MulDiv(l.BaseQuantity, rv.Quantity)
if err != nil {
return accounting.Draft{}, err
}
stock := gr
if l.stock() {
moved, err := liveLineCost(ctx, tx, org, l.ID)
if err != nil {
return accounting.Draft{}, err
}
stock = moved.Neg()
}
if !stock.IsZero() {
line := credit(side, stock, m)
if !l.stock() {
line.Distribution = l.Shares
}
out = append(out, line)
}
if diff := gr.Sub(stock); !diff.IsZero() {
ppv, err := chart.PurchasePriceVariance(ctx, tx)
if err != nil {
return accounting.Draft{}, err
}
if diff.IsPositive() {
out = append(out, credit(ppv.ID, diff, m+" price variance"))
} else {
out = append(out, debit(ppv.ID, diff.Neg(), m+" price variance"))
}
}
grni = grni.Add(gr)
}
if grni.IsZero() {
return accounting.Draft{}, ErrNothingReceived
}
control := ledger.Line{AccountID: src.Control.ID, PartyID: h.PartyID, Memo: h.Number,
Debit: money.Zero(), Credit: money.Zero()}
if r.kind == KindGoodsReceipt {
control.Credit = grni
} else {
control.Debit = grni
}
return accounting.Draft{JournalType: chart.JournalPurchase, Currency: base,
Description: docLabel[r.kind] + " " + h.Number, Lines: append(out, control)}, nil
}
// effectRule posts a delivery, a sales return or an adjustment: per stock
// line, Inventory against the counter account (the item's COGS, or the
// adjustment reason's account) at the line's moves' cost, direction by
// its sign. No stock lines, no lines: nothing posts (R-O2-1).
type effectRule struct{ kind string }
var _ accounting.PostingRule = effectRule{}
func (r effectRule) Kind() string { return r.kind }
func (effectRule) Semantic() posting.Semantic { return posting.Effect }
func (r effectRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) {
org, h, lines, base, err := ruleStart(ctx, tx, r.kind, src)
if err != nil {
return accounting.Draft{}, err
}
var out []ledger.Line
for _, l := range lines {
if !l.stock() {
continue
}
cost, err := liveLineCost(ctx, tx, org, l.ID)
if err != nil {
return accounting.Draft{}, err
}
if cost.IsZero() {
continue
}
counter := l.COGSAccountID
if r.kind == KindStockAdjustment {
counter = h.ReasonAccountID
}
if counter == nil || l.InventoryAccountID == nil {
return accounting.Draft{}, fmt.Errorf("%w: line %d", ErrItemAccountMissing, l.LineNo)
}
m := memo(h, l)
inv, other := debit(*l.InventoryAccountID, cost, m), credit(*counter, cost, m)
if cost.IsNegative() {
inv, other = credit(*l.InventoryAccountID, cost.Neg(), m), debit(*counter, cost.Neg(), m)
}
other.Distribution = l.Shares
out = append(out, inv, other)
}
return accounting.Draft{JournalType: chart.JournalMisc, Currency: base,
Description: docLabel[r.kind] + " " + h.Number, Lines: out}, nil
}
  • Step 4: Run the tests to verify they pass

Run: go test ./internal/platform/inventory/... -count=1 Expected: PASS.

  • Step 5: Sabotage

In grniRule.Build, drop the PPV block; run -run TestAPurchaseReturnClears; read the red (unbalanced from ledger.Post); restore. In Transition, skip OnTransition when to.Semantic == posting.Cancelled; run -run TestCancellingAReceipt; read the red (inventory still 15 / item cost 10); restore.

  • Step 6: Commit
Terminal window
git add internal/platform/inventory/
git commit -m "Phase 4 O2: stock posting rules (GRNI accrual, effect at cost, PPV on returns)
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Files:

  • Create: internal/platform/inventory/transfer.go, internal/platform/inventory/transfer_test.go

Interfaces:

  • Consumes: beginStock, insertMove, checkLots (Tasks 3, 6).

  • Produces: inventory.TransferInput{ItemID, From, To uuid.UUID; Quantity money.Rate; Lots []Lot; Note string; Actor uuid.UUID}, inventory.StockTransfer{ID, ItemID, From, To uuid.UUID; Quantity money.Rate; TransferredOn time.Time; Note string} (json snake_case), inventory.TransferStock(ctx, tx, c, in) (StockTransfer, error).

  • Step 1: Write the failing test

internal/platform/inventory/transfer_test.go:

package inventory_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"
)
func TestATransferMovesQuantityAndNoValue(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
phone := e.Item(t, "PHONE", "stock", "serial")
sup := e.SupplierParty(t)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50"),
stocktest.DocLine{Item: phone, Qty: "1", Price: "900.00",
Lots: []inventory.Lot{{SerialNo: "SN-1", Quantity: stocktest.Qty("1")}}}))
move := func(in inventory.TransferInput) error {
in.Actor = e.Staff
return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
_, err := inventory.TransferStock(ctx, tx, e.Clock, in)
return err
})
}
require.NoError(t, move(inventory.TransferInput{ItemID: bolt, From: e.WH1, To: e.WH2, Quantity: stocktest.Qty("4")}))
stocktest.RequireQty(t, "6", e.OnHand(t, bolt, e.WH1, ""))
stocktest.RequireQty(t, "4", e.OnHand(t, bolt, e.WH2, ""))
_, v := e.ItemCost(t, bolt)
stocktest.RequireAmount(t, "15", v, "cost is per item across the organization")
stocktest.RequireAmount(t, "915", e.AccountBalance(t, e.InventoryAccount), "no journal entry")
require.NoError(t, move(inventory.TransferInput{ItemID: phone, From: e.WH1, To: e.WH2, Quantity: stocktest.Qty("1"),
Lots: []inventory.Lot{{SerialNo: "SN-1", Quantity: stocktest.Qty("1")}}}))
stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH2, "SN-1"))
require.ErrorIs(t, move(inventory.TransferInput{ItemID: bolt, From: e.WH1, To: e.WH2, Quantity: stocktest.Qty("7")}),
inventory.ErrInsufficientStock)
require.ErrorIs(t, move(inventory.TransferInput{ItemID: bolt, From: e.WH1, To: e.WH1, Quantity: stocktest.Qty("1")}),
inventory.ErrSameLocation)
require.ErrorIs(t, move(inventory.TransferInput{ItemID: phone, From: e.WH2, To: e.WH1, Quantity: stocktest.Qty("1")}),
inventory.ErrLotsMismatch)
stocktest.RequireQty(t, "6", e.OnHand(t, bolt, e.WH1, ""), "a refused transfer moved nothing")
e.RequireBalancesAddUp(t)
e.RequireCostsAddUp(t)
}
  • Step 2: Run the test to verify it fails

Run: go test ./internal/platform/inventory/ -run TestATransfer -count=1 Expected: build failure undefined: inventory.TransferStock.

  • Step 3: transfer.go
package inventory
import (
"context"
"errors"
"fmt"
"time"
"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/kernel/recordtext"
)
// TransferInput moves one item between two locations (R-O2-5).
type TransferInput struct {
ItemID, From, To uuid.UUID
Quantity money.Rate
Lots []Lot
Note string
Actor uuid.UUID
}
type StockTransfer struct {
ID uuid.UUID `json:"id"`
ItemID uuid.UUID `json:"item_id"`
From uuid.UUID `json:"from_location_id"`
To uuid.UUID `json:"to_location_id"`
Quantity money.Rate `json:"quantity"`
TransferredOn time.Time `json:"-"`
Note string `json:"note"`
}
// TransferStock is two moves per lot, out of From and into To, at no cost:
// no workflow and no journal entry (spec O2.1). ORDER: company_profile FOR
// SHARE → the item's item_cost FOR UPDATE → stock_balance (the moves'
// trigger) → audit, last.
func TransferStock(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransferInput) (StockTransfer, error) {
org, err := stockOrg(ctx)
if err != nil {
return StockTransfer{}, err
}
if in.From == in.To {
return StockTransfer{}, ErrSameLocation
}
if !in.Quantity.IsPositive() {
return StockTransfer{}, ErrQuantityNotPositive
}
if err := recordtext.Check("note", in.Note); err != nil {
return StockTransfer{}, err
}
var typ, tracking string
err = tx.QueryRow(ctx, `SELECT type, tracking FROM item WHERE org_id = $1 AND id = $2`, org, in.ItemID).Scan(&typ, &tracking)
if errors.Is(err, pgx.ErrNoRows) {
return StockTransfer{}, ErrItemNotFound
}
if err != nil {
return StockTransfer{}, fmt.Errorf("inventory: transfer item: %w", err)
}
if typ != itemTypeStock {
return StockTransfer{}, ErrNotStockItem
}
lots, err := checkLots(tracking, in.Quantity, in.Lots)
if err != nil {
return StockTransfer{}, err
}
if _, err := beginStock(ctx, tx, org, []uuid.UUID{in.ItemID}); err != nil {
return StockTransfer{}, err
}
t := StockTransfer{ItemID: in.ItemID, From: in.From, To: in.To, Quantity: in.Quantity,
TransferredOn: clock.Today(ctx, c), Note: in.Note}
if err := tx.QueryRow(ctx, `
INSERT INTO stock_transfer (org_id, item_id, from_location_id, to_location_id, quantity, transferred_on, note, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
org, t.ItemID, t.From, t.To, t.Quantity, t.TransferredOn, t.Note, in.Actor, c.Now()).Scan(&t.ID); err != nil {
return StockTransfer{}, fmt.Errorf("inventory: transfer: %w", err)
}
for _, lot := range lots {
for _, leg := range []struct {
loc uuid.UUID
qty money.Rate
}{{t.From, lot.Quantity.Neg()}, {t.To, lot.Quantity}} {
id := t.ID
if _, err := insertMove(ctx, tx, c, moveInput{ItemID: t.ItemID, LocationID: leg.loc, Quantity: leg.qty,
Cost: money.Zero(), SerialNo: lot.SerialNo, BatchNo: lot.BatchNo, TransferID: &id}); err != nil {
return StockTransfer{}, err
}
}
}
actor := in.Actor
return t, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "stock_transfer", EntityID: t.ID, Action: "create",
ActorID: &actor, After: t})
}

(recordtext.Check(field, s string) error is the kernel check ledger uses for record text.)

  • Step 4: Run the test to verify it passes

Run: go test ./internal/platform/inventory/ -run TestATransfer -count=1 Expected: PASS.

  • Step 5: Commit
Terminal window
git add internal/platform/inventory/transfer.go internal/platform/inventory/transfer_test.go
git commit -m "Phase 4 O2: transfers between locations, no value moved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 9: Adjustment reasons, adjustments and stock-take

Section titled “Task 9: Adjustment reasons, adjustments and stock-take”

Files:

  • Create: db/migrations/00726_stock_count.sql
  • Create: internal/platform/inventory/reason.go
  • Create: internal/platform/inventory/adjustment/adjustment.go, internal/platform/inventory/adjustment/adjustment_test.go
  • Modify: internal/platform/inventory/stocktest/stocktest.go (Reason)
  • Modify: internal/kernel/db/retention_test.go

Interfaces:

  • Consumes: Transition, SetLineLots, CurrentUnitCost, the errors (Tasks 3, 6); the adjustment flow (Task 5); effectRule (Task 7); trade.Create (O1).

  • Produces (package inventory): DirectionIn = "in", DirectionOut = "out"; type AdjustmentReason{ID uuid.UUID; Code, Name string; AccountID uuid.UUID; Active bool} (json snake_case); type ReasonInput{Code string; AccountID uuid.UUID; Names map[string]string}; CreateAdjustmentReason(ctx, tx, c, ReasonInput) (AdjustmentReason, error); ListAdjustmentReasons(ctx, tx) ([]AdjustmentReason, error); ErrReasonCode, ErrReasonCodeTaken, ErrReasonAccount, ErrReasonName, ErrReasonNotFound, ErrReasonInactive; step inventory_adjustment_reasons (order 83).

  • Produces (package adjustment): type Input{LocationID, ReasonID, Reporter uuid.UUID; CountID *uuid.UUID; Lines []Line}, type Line{ItemID uuid.UUID; Direction string; Quantity money.Rate; UnitCost money.Amount; Lots []inventory.Lot}, Create(ctx, tx, c, Input) (uuid.UUID, error), type Adjustment, type LineView, Get(ctx, tx, id uuid.UUID) (Adjustment, error), type CountInput{LocationID, ReasonID, Reporter uuid.UUID; Lines []CountLine}, type CountLine{ItemID uuid.UUID; SerialNo, BatchNo string; Counted money.Rate}, type Count, type CountedLine, RecordCount(ctx, tx, c, CountInput) (Count, error); ErrNoLines, ErrUnitCostOnOut, ErrNegativeUnitCost, ErrDuplicateCountLine, ErrCountedNegative. stocktest: Reason(t, code) uuid.UUID.

  • Step 1: Write the failing tests

internal/platform/inventory/stocktest/stocktest.go — append:

// Reason is the id of the organization's adjustment reason with this code.
func (e *Env) Reason(t *testing.T, code string) uuid.UUID {
t.Helper()
var id uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT id FROM stock_adjustment_reason WHERE org_id = $1 AND code = $2`, e.Org, code).Scan(&id))
return id
}

internal/platform/inventory/adjustment/adjustment_test.go:

package adjustment_test
import (
"context"
"testing"
"github.com/google/uuid"
"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"
"github.com/mcom/erp/internal/platform/inventory/adjustment"
"github.com/mcom/erp/internal/platform/inventory/stocktest"
)
func TestProvisioningSeedsReasonsAndAReasonPostsToAnOrdinaryAccount(t *testing.T) {
e := stocktest.New(t)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
got, err := inventory.ListAdjustmentReasons(ctx, tx)
require.NoError(t, err)
codes := map[string]uuid.UUID{}
for _, r := range got {
codes[r.Code] = r.AccountID
}
require.Equal(t, e.AdjustAccount, codes["count"])
require.Equal(t, e.AdjustAccount, codes["damage"])
return nil
})
create := func(code string, account uuid.UUID) error {
return e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error {
_, err := inventory.CreateAdjustmentReason(ctx, tx, e.Clock, inventory.ReasonInput{Code: code, AccountID: account,
Names: map[string]string{"en": "Theft", "ms": "Kecurian", "zh": "盗窃"}})
return err
})
}
require.NoError(t, create("theft", e.Account(t, "6190")), "PRESENCE: an ordinary expense account")
require.ErrorIs(t, create("theft", e.Account(t, "6190")), inventory.ErrReasonCodeTaken)
require.ErrorIs(t, create("shrink", e.InventoryAccount), inventory.ErrReasonAccount, "inventory's own account")
require.ErrorIs(t, create("owed", e.GRNI), inventory.ErrReasonAccount, "a control account")
require.ErrorIs(t, create("Bad Code", e.Account(t, "6190")), inventory.ErrReasonCode)
}
// adjust creates a draft adjustment at WH1 as Staff.
func adjust(t *testing.T, e *stocktest.Env, reason uuid.UUID, lines ...adjustment.Line) stocktest.DocRef {
t.Helper()
ref := stocktest.DocRef{Kind: inventory.KindStockAdjustment}
require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
var err error
if ref.ID, err = adjustment.Create(ctx, tx, e.Clock, adjustment.Input{LocationID: e.WH1, ReasonID: reason,
Reporter: e.Staff, Lines: lines}); err != nil {
return err
}
return tx.QueryRow(ctx, `SELECT instance_id FROM trade_document WHERE org_id = $1 AND id = $2`,
e.Org, ref.ID).Scan(&ref.InstanceID)
}))
return ref
}
func approve(t *testing.T, e *stocktest.Env, d stocktest.DocRef) {
t.Helper()
require.NoError(t, e.MoveErr(d, e.Staff, "draft", "submitted", ""))
require.NoError(t, e.MoveErr(d, e.Owner, "submitted", "approved", ""))
}
func TestAnAdjustmentIsTheOwnersAndPostsAgainstItsReason(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
count := e.Reason(t, "count")
in := adjust(t, e, count, adjustment.Line{ItemID: bolt, Direction: inventory.DirectionIn,
Quantity: stocktest.Qty("5"), UnitCost: stocktest.Amt("2.00")})
require.NoError(t, e.MoveErr(in, e.Staff, "draft", "submitted", ""))
require.Error(t, e.MoveErr(in, e.Staff, "submitted", "approved", ""), "the preparer does not approve it")
require.NoError(t, e.MoveErr(in, e.Owner, "submitted", "approved", ""))
lines := stocktest.LinesByAccount(e.EntryFor(t, in.InstanceID, ledger.SemanticEffect))
stocktest.RequireAmount(t, "10", lines[e.InventoryAccount].BaseDebit)
stocktest.RequireAmount(t, "10", lines[e.AdjustAccount].BaseCredit)
stocktest.RequireQty(t, "5", e.OnHand(t, bolt, e.WH1, ""))
out := adjust(t, e, count, adjustment.Line{ItemID: bolt, Direction: inventory.DirectionOut, Quantity: stocktest.Qty("2")})
approve(t, e, out)
lines = stocktest.LinesByAccount(e.EntryFor(t, out.InstanceID, ledger.SemanticEffect))
stocktest.RequireAmount(t, "4", lines[e.AdjustAccount].BaseDebit)
stocktest.RequireAmount(t, "4", lines[e.InventoryAccount].BaseCredit)
require.NoError(t, e.MoveErr(out, e.Owner, "approved", "cancelled", "Found the two bolts"))
stocktest.RequireQty(t, "5", e.OnHand(t, bolt, e.WH1, ""), "cancelling reverses the moves")
e.RequireBalancesAddUp(t)
e.RequireCostsAddUp(t)
err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
_, err := adjustment.Create(ctx, tx, e.Clock, adjustment.Input{LocationID: e.WH1, ReasonID: count, Reporter: e.Staff,
Lines: []adjustment.Line{{ItemID: bolt, Direction: inventory.DirectionOut, Quantity: stocktest.Qty("1"),
UnitCost: stocktest.Amt("1")}}})
return err
})
require.ErrorIs(t, err, adjustment.ErrUnitCostOnOut)
}
func TestAStockCountAdjustsToWhatWasCounted(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
phone := e.Item(t, "PHONE", "stock", "serial")
sup := e.SupplierParty(t)
sn := func(s string) inventory.Lot { return inventory.Lot{SerialNo: s, Quantity: stocktest.Qty("1")} }
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "5", "1.00"),
stocktest.DocLine{Item: phone, Qty: "2", Price: "100.00", Lots: []inventory.Lot{sn("SN-1"), sn("SN-2")}}))
record := func(lines ...adjustment.CountLine) adjustment.Count {
var got adjustment.Count
require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
var err error
got, err = adjustment.RecordCount(ctx, tx, e.Clock, adjustment.CountInput{LocationID: e.WH1,
ReasonID: e.Reason(t, "count"), Reporter: e.Staff, Lines: lines})
return err
}))
return got
}
// PRESENCE of the absence: a count that matches the books adjusts nothing.
same := record(adjustment.CountLine{ItemID: bolt, Counted: stocktest.Qty("5")})
require.Nil(t, same.AdjustmentID)
c := record(
adjustment.CountLine{ItemID: bolt, Counted: stocktest.Qty("3")},
adjustment.CountLine{ItemID: phone, SerialNo: "SN-1", Counted: stocktest.Qty("1")},
adjustment.CountLine{ItemID: phone, SerialNo: "SN-2", Counted: stocktest.Qty("0")},
adjustment.CountLine{ItemID: phone, SerialNo: "SN-3", Counted: stocktest.Qty("1")},
)
require.NotNil(t, c.AdjustmentID)
stocktest.RequireQty(t, "5", c.Lines[0].System)
var adj adjustment.Adjustment
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
adj, err = adjustment.Get(ctx, tx, *c.AdjustmentID)
return err
})
require.Len(t, adj.Lines, 3, "bolt out 2, SN-2 out, SN-3 in")
require.Equal(t, &c.ID, adj.CountID)
require.Equal(t, "draft", adj.State)
for _, l := range adj.Lines {
if l.Direction == inventory.DirectionIn {
stocktest.RequireAmount(t, "100", l.UnitCost, "a found unit at the current average (R-O2-16)")
}
}
approve(t, e, stocktest.DocRef{ID: adj.ID, InstanceID: adj.InstanceID, Kind: inventory.KindStockAdjustment})
stocktest.RequireQty(t, "3", e.OnHand(t, bolt, e.WH1, ""))
stocktest.RequireQty(t, "0", e.OnHand(t, phone, e.WH1, "SN-2"))
stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH1, "SN-3"))
e.RequireBalancesAddUp(t)
e.RequireCostsAddUp(t)
}
  • Step 2: Run the tests to verify they fail

Run: go test ./internal/platform/inventory/adjustment/ -count=1 Expected: build failure (package adjustment does not exist).

  • Step 3: Migration

db/migrations/00726_stock_count.sql:

-- +goose Up
-- Phase 4 O2.4: stock-take. A count records, per item and lot at one
-- location, what the books held and what was counted, once; the
-- differences become a draft adjustment that names the count (R-O2-16).
CREATE TABLE stock_count (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
location_id UUID NOT NULL,
counted_on DATE NOT NULL,
counted_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT stock_count_tenant_id UNIQUE (org_id, id),
CONSTRAINT stock_count_location_fk FOREIGN KEY (org_id, location_id) REFERENCES location (org_id, id),
CONSTRAINT stock_count_counted_by_fk FOREIGN KEY (org_id, counted_by) REFERENCES organization_member (org_id, id)
);
CREATE TABLE stock_count_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
count_id UUID NOT NULL,
item_id UUID NOT NULL,
lot TEXT NOT NULL,
system_quantity NUMERIC(19,6) NOT NULL CONSTRAINT stock_count_line_system_nonnegative CHECK (system_quantity >= 0),
counted_quantity NUMERIC(19,6) NOT NULL CONSTRAINT stock_count_line_counted_nonnegative CHECK (counted_quantity >= 0),
CONSTRAINT stock_count_line_once UNIQUE (org_id, count_id, item_id, lot),
CONSTRAINT stock_count_line_count_fk FOREIGN KEY (org_id, count_id) REFERENCES stock_count (org_id, id),
CONSTRAINT stock_count_line_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id)
);
ALTER TABLE stock_adjustment_detail ADD COLUMN count_id UUID,
ADD CONSTRAINT stock_adjustment_detail_count_fk FOREIGN KEY (org_id, count_id) REFERENCES stock_count (org_id, id);
ALTER TABLE stock_count ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock_count FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON stock_count
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE stock_count_line ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock_count_line FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON stock_count_line
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 stock_count, stock_count_line FROM erp_app;
-- +goose Down
ALTER TABLE stock_adjustment_detail DROP COLUMN count_id;
DROP TABLE stock_count_line;
DROP TABLE stock_count;

internal/kernel/db/retention_test.go: appendOnly gains "stock_count", "stock_count_line" (// Phase 4 O2.4: a stock count, what the books held and what was counted; written once.).

  • Step 4: reason.go

internal/platform/inventory/reason.go:

package inventory
import (
"context"
"errors"
"fmt"
"regexp"
"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/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/i18n"
"github.com/mcom/erp/internal/platform/provisioning"
)
// Adjustment line directions: the API's words for a line's sign (R-O2-12).
const (
DirectionIn = "in"
DirectionOut = "out"
)
var (
ErrReasonCode = errors.New("inventory: a reason code is lower-case letters, digits and underscores, starting with a letter")
ErrReasonCodeTaken = errors.New("inventory: that reason code is already used")
ErrReasonAccount = errors.New("inventory: a reason posts to an active, postable account that is neither a control account nor a module's own")
ErrReasonName = errors.New("inventory: a reason is named in English at least")
ErrReasonNotFound = errors.New("inventory: no such adjustment reason in this organization")
ErrReasonInactive = errors.New("inventory: that adjustment reason is retired")
)
const reasonEntity = "stock_adjustment_reason"
var reasonCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`)
type AdjustmentReason struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
AccountID uuid.UUID `json:"account_id"`
Active bool `json:"active"`
}
type ReasonInput struct {
Code string
AccountID uuid.UUID
Names map[string]string // locale → name; "en" required
}
// CreateAdjustmentReason adds a reason and the account its adjustments post
// against (R-O2-12). The account is an ordinary one: never a control
// account nor a module's own (Inventory itself), so a reason cannot move
// a subledger.
func CreateAdjustmentReason(ctx context.Context, tx pgx.Tx, c clock.Clock, in ReasonInput) (AdjustmentReason, error) {
org, err := stockOrg(ctx)
if err != nil {
return AdjustmentReason{}, err
}
if !reasonCode.MatchString(in.Code) {
return AdjustmentReason{}, ErrReasonCode
}
if strings.TrimSpace(in.Names["en"]) == "" {
return AdjustmentReason{}, ErrReasonName
}
a, err := chart.GetAccount(ctx, tx, in.AccountID)
if errors.Is(err, chart.ErrNotFound) {
return AdjustmentReason{}, ErrReasonAccount
}
if err != nil {
return AdjustmentReason{}, err
}
if !a.IsPostable || !a.IsActive || a.OwnerModule != nil || a.ControlKind != nil {
return AdjustmentReason{}, ErrReasonAccount
}
r := AdjustmentReason{Code: in.Code, Name: in.Names["en"], AccountID: a.ID, Active: true}
err = tx.QueryRow(ctx, `
INSERT INTO stock_adjustment_reason (org_id, code, account_id, created_at) VALUES ($1, $2, $3, $4) RETURNING id`,
org, r.Code, r.AccountID, c.Now()).Scan(&r.ID)
var pg *pgconn.PgError
if errors.As(err, &pg) && pg.ConstraintName == "stock_adjustment_reason_code_once" {
return AdjustmentReason{}, ErrReasonCodeTaken
}
if err != nil {
return AdjustmentReason{}, fmt.Errorf("inventory: create reason: %w", err)
}
for locale, name := range in.Names {
if strings.TrimSpace(name) == "" {
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value)
VALUES ($1, $2, $3, 'name', $4, $5)`, org, reasonEntity, r.ID, locale, name); err != nil {
return AdjustmentReason{}, fmt.Errorf("inventory: reason name: %w", err)
}
}
return r, audit.Write(ctx, tx, c, audit.Entry{EntityKind: reasonEntity, EntityID: r.ID, Action: "create",
After: map[string]any{"code": r.Code, "account_id": r.AccountID, "names": in.Names}})
}
// ListAdjustmentReasons is every reason, named in the request's locale.
func ListAdjustmentReasons(ctx context.Context, tx pgx.Tx) ([]AdjustmentReason, error) {
org, err := stockOrg(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `
SELECT id, code, account_id, is_active FROM stock_adjustment_reason WHERE org_id = $1 ORDER BY code`, org)
if err != nil {
return nil, fmt.Errorf("inventory: reasons: %w", err)
}
var out []AdjustmentReason
var ids []uuid.UUID
for rows.Next() {
var r AdjustmentReason
if err := rows.Scan(&r.ID, &r.Code, &r.AccountID, &r.Active); err != nil {
rows.Close()
return nil, err
}
out = append(out, r)
ids = append(ids, r.ID)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
names, err := i18n.Load(ctx, tx, reasonEntity, ids, "name")
if err != nil {
return nil, err
}
for i := range out {
out[i].Name = out[i].Code
if n, ok := names[out[i].ID]; ok {
out[i].Name = n
}
}
return out, nil
}
// The adjustment reasons step, order 83 (R-O2-18): after the stock
// accounts (81), whose Stock adjustment account (5400) the two seeded
// reasons post to.
const (
StepKeyAdjustmentReasons = "inventory_adjustment_reasons"
StepOrderAdjustmentReasons = 83
adjustmentAccountCode = "5400"
)
func init() { provisioning.Register(reasonsStep{}) }
type reasonsStep struct{}
func (reasonsStep) Key() string { return StepKeyAdjustmentReasons }
func (reasonsStep) Order() int { return StepOrderAdjustmentReasons }
// Check is done once any reason exists; with no usable 5400 it is done with
// a warning, since re-running could not seed against it.
func (reasonsStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) {
org, err := stockOrg(ctx)
if err != nil {
return false, nil, err
}
var exists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM stock_adjustment_reason WHERE org_id = $1)`, org).Scan(&exists); err != nil {
return false, nil, fmt.Errorf("inventory: reasons check: %w", err)
}
if exists {
return true, nil, nil
}
a, err := chart.GetAccountByCode(ctx, tx, adjustmentAccountCode)
if errors.Is(err, chart.ErrNotFound) || (err == nil && (a.Type != chart.TypeExpense || !a.IsPostable)) {
return true, []provisioning.Warning{{Code: "adjustment_reason_account_missing",
Detail: "Account 5400 is not a postable expense account; add adjustment reasons by hand."}}, nil
}
return false, nil, err
}
func (reasonsStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error {
a, err := chart.GetAccountByCode(ctx, tx, adjustmentAccountCode)
if err != nil {
return fmt.Errorf("inventory: reasons: %w", err)
}
for _, in := range []ReasonInput{
{Code: "count", AccountID: a.ID, Names: map[string]string{"en": "Stock count difference", "ms": "Perbezaan kiraan stok", "zh": "盘点差异"}},
{Code: "damage", AccountID: a.ID, Names: map[string]string{"en": "Damaged or written off", "ms": "Rosak atau dihapus kira", "zh": "损坏或报废"}},
} {
if _, err := CreateAdjustmentReason(ctx, tx, c, in); err != nil {
return err
}
}
return nil
}

(The step writes the second reason’s rows after the first’s audit: INSERTs of new rows only, as Task 2’s step.)

  • Step 5: adjustment.go

internal/platform/inventory/adjustment/adjustment.go:

// Package adjustment creates stock adjustments and records stock counts
// (spec O2.3, O2.4). It is apart from package inventory because it creates
// trade documents through O1's trade.Create, and inventory does not import
// trade. Posting and moving stock are inventory's (Transition, the rules).
package adjustment
import (
"context"
"errors"
"fmt"
"time"
"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/kernel/tenancy"
"github.com/mcom/erp/internal/platform/company"
"github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/trade"
)
var (
ErrNoLines = errors.New("adjustment: an adjustment or a count has at least one line")
ErrUnitCostOnOut = errors.New("adjustment: stock going out is costed by FIFO or the average; give no unit cost")
ErrNegativeUnitCost = errors.New("adjustment: a unit cost is not negative")
ErrDuplicateCountLine = errors.New("adjustment: an item and lot is counted once per count")
ErrCountedNegative = errors.New("adjustment: a counted quantity is not negative")
)
func orgOf(ctx context.Context) (uuid.UUID, error) {
org, ok := tenancy.OrgFromContext(ctx)
if !ok {
return uuid.Nil, fmt.Errorf("adjustment: %w", tenancy.ErrNoOrg)
}
return org, nil
}
type Input struct {
LocationID uuid.UUID
ReasonID uuid.UUID
Reporter uuid.UUID
CountID *uuid.UUID
Lines []Line
}
// Line is in the item's base UOM; UnitCost (base currency) only on "in".
type Line struct {
ItemID uuid.UUID
Direction string
Quantity money.Rate
UnitCost money.Amount
Lots []inventory.Lot
}
// Create makes a draft stock adjustment (R-O2-12): a trade document in
// base currency at the location (O1's trade.Create, which numbers it and
// starts its flow), its reason, each line signed by its direction, and
// lots. Its flow (submit → the Owner seat approves, effect) moves and
// posts it through inventory.Transition.
//
// LOCK ORDER: trade.Create writes its audit row; the rows written after it
// (the detail, the lots) are INSERTs of new rows whose only locks are FOR
// KEY SHARE on rows this transaction wrote (the document, its lines) or
// that nothing locks FOR UPDATE (the reason) — so the audit-last rule's
// purpose holds.
func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, in Input) (uuid.UUID, error) {
org, err := orgOf(ctx)
if err != nil {
return uuid.Nil, err
}
if len(in.Lines) == 0 {
return uuid.Nil, ErrNoLines
}
var active bool
err = tx.QueryRow(ctx, `SELECT is_active FROM stock_adjustment_reason WHERE org_id = $1 AND id = $2`,
org, in.ReasonID).Scan(&active)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, inventory.ErrReasonNotFound
}
if err != nil {
return uuid.Nil, fmt.Errorf("adjustment: reason: %w", err)
}
if !active {
return uuid.Nil, inventory.ErrReasonInactive
}
lines := make([]trade.LineInput, 0, len(in.Lines))
directions := make([]string, 0, len(in.Lines))
for _, l := range in.Lines {
switch l.Direction {
case inventory.DirectionIn:
if l.UnitCost.IsNegative() {
return uuid.Nil, ErrNegativeUnitCost
}
case inventory.DirectionOut:
if !l.UnitCost.IsZero() {
return uuid.Nil, ErrUnitCostOnOut
}
default:
return uuid.Nil, inventory.ErrNoDirection
}
if !l.Quantity.IsPositive() {
return uuid.Nil, inventory.ErrQuantityNotPositive
}
var typ string
var uom uuid.UUID
err := tx.QueryRow(ctx, `SELECT type, base_uom_id FROM item WHERE org_id = $1 AND id = $2`, org, l.ItemID).Scan(&typ, &uom)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, inventory.ErrItemNotFound
}
if err != nil {
return uuid.Nil, fmt.Errorf("adjustment: item: %w", err)
}
if typ != "stock" {
return uuid.Nil, inventory.ErrNotStockItem
}
item, qty := l.ItemID, l.Quantity
if l.Direction == inventory.DirectionOut {
qty = qty.Neg() // O1's trade_line_quantity_sign: an adjustment's sign is its direction
}
lines = append(lines, trade.LineInput{ItemID: &item, Quantity: qty, UOMID: uom, UnitPrice: l.UnitCost})
directions = append(directions, l.Direction)
}
p, err := company.Get(ctx, tx)
if err != nil {
return uuid.Nil, err
}
loc := in.LocationID
doc, err := trade.Create(ctx, tx, c, in.Reporter, trade.DocumentInput{Kind: trade.KindAdjustment,
Currency: p.BaseCurrency, LocationID: &loc, Lines: lines})
if err != nil {
return uuid.Nil, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO stock_adjustment_detail (org_id, document_id, reason_id, count_id) VALUES ($1, $2, $3, $4)`,
org, doc.ID, in.ReasonID, in.CountID); err != nil {
return uuid.Nil, fmt.Errorf("adjustment: detail: %w", err)
}
for i, dl := range doc.Lines {
if len(in.Lines[i].Lots) > 0 {
if err := inventory.SetLineLots(ctx, tx, dl.ID, in.Lines[i].Lots); err != nil {
return uuid.Nil, err
}
}
}
return doc.ID, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "trade_document", EntityID: doc.ID,
Action: "adjustment_details", After: map[string]any{"reason_id": in.ReasonID, "count_id": in.CountID,
"directions": directions}})
}
type Adjustment struct {
ID uuid.UUID `json:"id"`
Number string `json:"number"`
State string `json:"state"`
LocationID uuid.UUID `json:"location_id"`
ReasonID uuid.UUID `json:"reason_id"`
CountID *uuid.UUID `json:"count_id"`
InstanceID uuid.UUID `json:"instance_id"`
Lines []LineView `json:"lines"`
}
type LineView struct {
ID uuid.UUID `json:"id"`
ItemID uuid.UUID `json:"item_id"`
Direction string `json:"direction"`
Quantity money.Rate `json:"quantity"`
UnitCost money.Amount `json:"unit_cost"`
Lots []inventory.Lot `json:"lots"`
}
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Adjustment, error) {
org, err := orgOf(ctx)
if err != nil {
return Adjustment{}, err
}
var a Adjustment
err = tx.QueryRow(ctx, `
SELECT d.id, d.number, w.state_key, d.location_id, s.reason_id, s.count_id, d.instance_id
FROM trade_document d
JOIN stock_adjustment_detail s ON s.org_id = d.org_id AND s.document_id = d.id
JOIN workflow_instance w ON w.org_id = d.org_id AND w.id = d.instance_id
WHERE d.org_id = $1 AND d.id = $2 AND d.kind = 'stock_adjustment'`, org, id).
Scan(&a.ID, &a.Number, &a.State, &a.LocationID, &a.ReasonID, &a.CountID, &a.InstanceID)
if errors.Is(err, pgx.ErrNoRows) {
return Adjustment{}, inventory.ErrDocumentNotFound
}
if err != nil {
return Adjustment{}, fmt.Errorf("adjustment: get: %w", err)
}
rows, err := tx.Query(ctx, `
SELECT l.id, l.item_id, CASE WHEN l.quantity_base < 0 THEN 'out' ELSE 'in' END, abs(l.quantity_base), l.unit_price
FROM trade_line l
WHERE l.org_id = $1 AND l.document_id = $2 AND l.withdrawn_at IS NULL ORDER BY l.line_no`, org, id)
if err != nil {
return Adjustment{}, fmt.Errorf("adjustment: lines: %w", err)
}
for rows.Next() {
var l LineView
if err := rows.Scan(&l.ID, &l.ItemID, &l.Direction, &l.Quantity, &l.UnitCost); err != nil {
rows.Close()
return Adjustment{}, err
}
a.Lines = append(a.Lines, l)
}
rows.Close()
if err := rows.Err(); err != nil {
return Adjustment{}, err
}
for i := range a.Lines {
lots, err := tx.Query(ctx, `
SELECT coalesce(serial_no, ''), coalesce(batch_no, ''), expires_on, quantity
FROM trade_line_lot WHERE org_id = $1 AND trade_line_id = $2
ORDER BY serial_no NULLS LAST, batch_no NULLS LAST`, org, a.Lines[i].ID)
if err != nil {
return Adjustment{}, err
}
a.Lines[i].Lots = []inventory.Lot{}
for lots.Next() {
var lot inventory.Lot
if err := lots.Scan(&lot.SerialNo, &lot.BatchNo, &lot.ExpiresOn, &lot.Quantity); err != nil {
lots.Close()
return Adjustment{}, err
}
a.Lines[i].Lots = append(a.Lines[i].Lots, lot)
}
lots.Close()
if err := lots.Err(); err != nil {
return Adjustment{}, err
}
}
return a, nil
}
type CountInput struct {
LocationID uuid.UUID
ReasonID uuid.UUID
Reporter uuid.UUID
Lines []CountLine
}
// CountLine counts one item (and serial or batch) at the location; a
// serial is counted 0 or 1.
type CountLine struct {
ItemID uuid.UUID
SerialNo, BatchNo string
Counted money.Rate
}
type Count struct {
ID uuid.UUID `json:"id"`
LocationID uuid.UUID `json:"location_id"`
CountedOn time.Time `json:"-"`
AdjustmentID *uuid.UUID `json:"adjustment_id"`
Lines []CountedLine `json:"lines"`
}
type CountedLine struct {
ItemID uuid.UUID `json:"item_id"`
Lot string `json:"lot"`
System money.Rate `json:"system"`
Counted money.Rate `json:"counted"`
}
// RecordCount records a stock count and, when the books differ, a draft
// adjustment for the differences: out at FIFO or average cost, in at the
// current unit cost (R-O2-16).
func RecordCount(ctx context.Context, tx pgx.Tx, c clock.Clock, in CountInput) (Count, error) {
org, err := orgOf(ctx)
if err != nil {
return Count{}, err
}
if len(in.Lines) == 0 {
return Count{}, ErrNoLines
}
count := Count{ID: uuid.New(), LocationID: in.LocationID, CountedOn: clock.Today(ctx, c)}
if _, err := tx.Exec(ctx, `
INSERT INTO stock_count (id, org_id, location_id, counted_on, counted_by, created_at) VALUES ($1, $2, $3, $4, $5, $6)`,
count.ID, org, count.LocationID, count.CountedOn, in.Reporter, c.Now()); err != nil {
return Count{}, fmt.Errorf("adjustment: count: %w", err)
}
seen := map[[2]string]bool{}
var diffs []Line
for _, l := range in.Lines {
if l.Counted.IsNegative() {
return Count{}, ErrCountedNegative
}
var typ, tracking string
err := tx.QueryRow(ctx, `SELECT type, tracking FROM item WHERE org_id = $1 AND id = $2`, org, l.ItemID).Scan(&typ, &tracking)
if errors.Is(err, pgx.ErrNoRows) {
return Count{}, inventory.ErrItemNotFound
}
if err != nil {
return Count{}, fmt.Errorf("adjustment: count item: %w", err)
}
if typ != "stock" {
return Count{}, inventory.ErrNotStockItem
}
lot := l.SerialNo + l.BatchNo
switch {
case tracking == "serial" && (l.SerialNo == "" || l.BatchNo != "" || l.Counted.Cmp(money.OneRate()) > 0):
return Count{}, inventory.ErrSerialRequired
case tracking == "batch" && (l.BatchNo == "" || l.SerialNo != ""):
return Count{}, inventory.ErrBatchRequired
case tracking == "none" && lot != "":
return Count{}, inventory.ErrUntrackedLot
}
key := [2]string{l.ItemID.String(), lot}
if seen[key] {
return Count{}, ErrDuplicateCountLine
}
seen[key] = true
system := money.MustParseRate("0")
err = tx.QueryRow(ctx, `
SELECT on_hand FROM stock_balance WHERE org_id = $1 AND item_id = $2 AND location_id = $3 AND lot = $4`,
org, l.ItemID, in.LocationID, lot).Scan(&system)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Count{}, fmt.Errorf("adjustment: on hand: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO stock_count_line (org_id, count_id, item_id, lot, system_quantity, counted_quantity)
VALUES ($1, $2, $3, $4, $5, $6)`, org, count.ID, l.ItemID, lot, system, l.Counted); err != nil {
return Count{}, fmt.Errorf("adjustment: count line: %w", err)
}
count.Lines = append(count.Lines, CountedLine{ItemID: l.ItemID, Lot: lot, System: system, Counted: l.Counted})
diff := l.Counted.Sub(system)
if diff.IsZero() {
continue
}
d := Line{ItemID: l.ItemID, Direction: inventory.DirectionIn, Quantity: diff}
if diff.IsNegative() {
d.Direction, d.Quantity = inventory.DirectionOut, diff.Neg()
} else if d.UnitCost, err = inventory.CurrentUnitCost(ctx, tx, l.ItemID); err != nil {
return Count{}, err
}
switch tracking {
case "serial":
d.Lots = []inventory.Lot{{SerialNo: l.SerialNo, Quantity: d.Quantity}}
case "batch":
d.Lots = []inventory.Lot{{BatchNo: l.BatchNo, Quantity: d.Quantity}}
}
diffs = append(diffs, d)
}
if len(diffs) > 0 {
id, err := Create(ctx, tx, c, Input{LocationID: in.LocationID, ReasonID: in.ReasonID, Reporter: in.Reporter,
CountID: &count.ID, Lines: diffs})
if err != nil {
return Count{}, err
}
count.AdjustmentID = &id
}
actor := in.Reporter
return count, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "stock_count", EntityID: count.ID, Action: "create",
ActorID: &actor, After: count})
}

Run: make generate && git status --short

  • Step 6: Run the tests to verify they pass

Run: go test ./internal/platform/inventory/... ./internal/kernel/db/ ./internal/kernel/layering/ ./internal/app/ -count=1 Expected: PASS.

  • Step 7: Sabotage

In CreateAdjustmentReason, drop a.OwnerModule != nil ||; run go test ./internal/platform/inventory/adjustment/ -run TestProvisioningSeedsReasons -count=1; read the red; restore.

  • Step 8: Commit
Terminal window
git add db/migrations/00726_stock_count.sql internal/
git commit -m "Phase 4 O2: adjustment reasons, adjustments approved by the Owner, stock-take
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 10: Valuation equals the ledger — the integrity check

Section titled “Task 10: Valuation equals the ledger — the integrity check”

Files:

  • Create: internal/platform/inventory/integrity.go, internal/platform/inventory/integrity_test.go
  • Modify: internal/operator/orgviews.go (runbook), docs/RUNBOOK.md (§7.3)

Interfaces:

  • Consumes: item_cost (Task 6), the stock postings (Task 7), stocktest.ItemOn (Task 3).

  • Produces: inventory.KeyStockValuation = "stock_valuation" registered in reconcile.Default(); inventory.AccountValuation{AccountID uuid.UUID; Code string; Stock, Ledger money.Amount}; inventory.ValuationByAccount(ctx, tx) ([]AccountValuation, error).

  • Step 1: Write the failing tests

internal/platform/inventory/integrity_test.go:

package inventory_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/accounting/ledger"
"github.com/mcom/erp/internal/platform/accounting/reconcile"
"github.com/mcom/erp/internal/platform/expenses/expensestest"
"github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/inventory/stocktest"
)
// valuationResult runs the whole reconciliation set and returns
// stock_valuation's result.
func valuationResult(t *testing.T, e *stocktest.Env) reconcile.CheckResult {
t.Helper()
var out reconcile.CheckResult
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
res, err := reconcile.Default().Run(ctx, tx)
if err != nil {
return err
}
for _, c := range res.Checks {
if c.Key == inventory.KeyStockValuation {
out = c
}
}
return nil
})
require.Equal(t, inventory.KeyStockValuation, out.Key, "the check is registered")
return out
}
func TestStockValuationEqualsTheInventoryLedger(t *testing.T) {
for _, method := range []string{inventory.CostingWeightedAverage, inventory.CostingFIFO} {
t.Run(method, func(t *testing.T) {
e := stocktest.New(t)
e.UseCosting(t, method)
bolt := e.Item(t, "BOLT", "stock", "none")
phone := e.Item(t, "PHONE", "stock", "serial")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
sn := func(s string) inventory.Lot { return inventory.Lot{SerialNo: s, Quantity: stocktest.Qty("1")} }
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.35"),
stocktest.DocLine{Item: phone, Qty: "2", Price: "333.33", Lots: []inventory.Lot{sn("SN-1"), sn("SN-2")}})
e.Post(t, r)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "7", "2.10")))
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "6", "9.00"),
stocktest.DocLine{Item: phone, Qty: "1", Price: "999.00", Lots: []inventory.Lot{sn("SN-1")}})
e.Post(t, d)
e.Post(t, e.Doc(t, inventory.KindSalesReturn, &cust, "MYR",
stocktest.DocLine{Item: bolt, Qty: "1", Price: "9.00", Source: &d.Lines[0]}))
e.Post(t, e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR",
stocktest.DocLine{Item: bolt, Qty: "2", Price: "1.35", Source: &r.Lines[0]}))
cancelled := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "3", "9.00"))
e.Post(t, cancelled)
e.Cancel(t, cancelled)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
rows, err := inventory.ValuationByAccount(ctx, tx)
require.NoError(t, err)
require.NotEmpty(t, rows, "PRESENCE: the check compares at least the Inventory account")
for _, a := range rows {
if a.AccountID == e.InventoryAccount {
require.True(t, a.Stock.IsPositive(), "PRESENCE: stock is held")
}
require.Zero(t, a.Stock.Cmp(a.Ledger), "account %s: stock %s, ledger %s", a.Code, a.Stock, a.Ledger)
}
return nil
})
require.True(t, valuationResult(t, e).Passed)
e.RequireBalancesAddUp(t)
e.RequireCostsAddUp(t)
})
}
}
func TestAManualJournalOnAnInventoryAccountFailsTheCheck(t *testing.T) {
e := stocktest.New(t)
prepaid := e.Account(t, "1570") // an ordinary asset: manual lines may move it
bolt := e.ItemOn(t, "BOLT", "stock", "none", prepaid)
sup := e.SupplierParty(t)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00")))
require.True(t, valuationResult(t, e).Passed, "PRESENCE: the books agree before the journal")
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
capital, err := chart.GetAccountByCode(ctx, tx, "3100")
if err != nil {
return err
}
owner := e.Owner
_, err = ledger.Post(ctx, tx, e.Clock, ledger.EntryInput{JournalType: chart.JournalMisc,
EntryDate: expensestest.Today, Description: "Stock found", Semantic: ledger.SemanticManual, PostedBy: &owner,
Lines: []ledger.Line{{AccountID: prepaid, Debit: stocktest.Amt("5")},
{AccountID: capital.ID, Credit: stocktest.Amt("5")}}})
return err
})
got := valuationResult(t, e)
require.False(t, got.Passed)
require.Len(t, got.Findings, 1)
require.Equal(t, prepaid, got.Findings[0].Ref)
require.Contains(t, got.Findings[0].Detail, "10.0000")
require.Contains(t, got.Findings[0].Detail, "15.0000")
}
  • Step 2: Run the tests to verify they fail

Run: go test ./internal/platform/inventory/ -run 'Valuation|ManualJournal' -count=1 Expected: build failure undefined: inventory.KeyStockValuation.

  • Step 3: integrity.go
package inventory
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/accounting/reconcile"
)
// KeyStockValuation is spec O2.5: stock valuation equals the GL balance of
// the inventory accounts. Nightly, and on the operator console's "Run
// checks now" (opsjobs records its pass/fail and finding count).
const KeyStockValuation = "stock_valuation"
func init() { reconcile.Register(valuationCheck{}) }
// AccountValuation is one inventory account: the value of the items that
// name it (item_cost) beside its ledger balance in base.
type AccountValuation struct {
AccountID uuid.UUID
Code string
Stock, Ledger money.Amount
}
// ValuationByAccount compares every account an item names as its inventory
// account, and every account module inventory owns.
// ponytail: sums journal_line per account on every run; add a per-account
// running balance if the nightly check gets slow.
func ValuationByAccount(ctx context.Context, tx pgx.Tx) ([]AccountValuation, error) {
org, err := stockOrg(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `
WITH valued AS (
SELECT i.inventory_account_id AS account_id, sum(c.value) AS value
FROM item_cost c
JOIN item i ON i.org_id = c.org_id AND i.id = c.item_id
WHERE c.org_id = $1 AND i.inventory_account_id IS NOT NULL
GROUP BY 1),
booked AS (
SELECT l.account_id, sum(l.base_debit - l.base_credit) AS balance
FROM journal_line l
WHERE l.org_id = $1
GROUP BY 1)
SELECT a.id, a.code, coalesce(v.value, 0), coalesce(b.balance, 0)
FROM account a
LEFT JOIN valued v ON v.account_id = a.id
LEFT JOIN booked b ON b.account_id = a.id
WHERE a.org_id = $1
AND (a.owner_module = 'inventory'
OR EXISTS (SELECT 1 FROM item i2 WHERE i2.org_id = a.org_id AND i2.inventory_account_id = a.id))
ORDER BY a.code`, org)
if err != nil {
return nil, fmt.Errorf("inventory: valuation: %w", err)
}
defer rows.Close()
var out []AccountValuation
for rows.Next() {
var a AccountValuation
if err := rows.Scan(&a.AccountID, &a.Code, &a.Stock, &a.Ledger); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
type valuationCheck struct{}
func (valuationCheck) Key() string { return KeyStockValuation }
func (valuationCheck) Run(ctx context.Context, tx pgx.Tx) ([]reconcile.Finding, error) {
rows, err := ValuationByAccount(ctx, tx)
if err != nil {
return nil, err
}
var findings []reconcile.Finding
for _, a := range rows {
if a.Stock.Cmp(a.Ledger) != 0 {
findings = append(findings, reconcile.Finding{Check: KeyStockValuation, Ref: a.AccountID, Detail: fmt.Sprintf(
"account %s: stock is valued at %s, the ledger holds %s", a.Code, a.Stock, a.Ledger)})
}
}
return findings, nil
}

internal/operator/orgviews.go, in runbook, after "run_failed": "7.3", "not_checked": "7.3",:

"stock_valuation": "7.3", // internal/platform/inventory.KeyStockValuation (a literal: the console imports no module)

docs/RUNBOOK.md §7.3, after the audit_chain bullet:

- `stock_valuation`: an inventory account's ledger balance differs from the value of the stock that
names it. Usually a manual journal on an inventory account the organization chose itself (the
provisioned Inventory account refuses manual lines). The organization's accountant reverses that
journal, or posts a stock adjustment for the difference.
  • Step 4: Run the tests to verify they pass

Run: go test ./internal/platform/inventory/ ./internal/operator/ -count=1 Expected: PASS (TestEveryDetailCodeHasARunbookEntry included).

  • Step 5: Sabotage

In effectRule.Build, post cost with money.MustParse("0.0001") added to the Inventory side’s debit and the counter’s credit on every positive line (a drift both sides still balance); run -run TestStockValuationEquals; read the red; restore.

  • Step 6: Commit
Terminal window
git add internal/platform/inventory/integrity.go internal/platform/inventory/integrity_test.go internal/operator/orgviews.go docs/RUNBOOK.md
git commit -m "Phase 4 O2: stock valuation equals the inventory ledger (spec O2.5)
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 11: Stock reports — on hand, movements, valuation

Section titled “Task 11: Stock reports — on hand, movements, valuation”

Files:

  • Create: internal/platform/inventory/report.go, internal/platform/inventory/report_test.go

Interfaces:

  • Consumes: stock_balance, stock_move (Task 3), TransferStock (Task 8).

  • Produces: inventory.OnHandRow, inventory.OnHand(ctx, tx, locationID, itemID *uuid.UUID) ([]OnHandRow, error); inventory.MovementFilter{From, To time.Time; ItemID, LocationID *uuid.UUID}, inventory.MovementRow, inventory.Movements(ctx, tx, MovementFilter) ([]MovementRow, error), inventory.MaxMovementRows = 5000, inventory.ErrTooManyRows; inventory.ValuationRow, inventory.Valuation{AsOf time.Time; Currency string; Rows []ValuationRow; Total money.Amount}, inventory.StockValuation(ctx, tx, asOf time.Time) (Valuation, error).

  • Step 1: Write the failing test

internal/platform/inventory/report_test.go:

package inventory_test
import (
"context"
"testing"
"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"
"github.com/mcom/erp/internal/platform/inventory/stocktest"
)
func TestStockReportsReadTheLedger(t *testing.T) {
e := stocktest.New(t)
bolt := e.Item(t, "BOLT", "stock", "none")
sup, cust := e.SupplierParty(t), e.CustomerParty(t)
e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50")))
require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error {
_, err := inventory.TransferStock(ctx, tx, e.Clock, inventory.TransferInput{ItemID: bolt, From: e.WH1, To: e.WH2,
Quantity: stocktest.Qty("3"), Actor: e.Staff})
return err
}))
d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00"))
e.Post(t, d)
today, yesterday := expensestest.Today, expensestest.Today.AddDate(0, 0, -1)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
all, err := inventory.OnHand(ctx, tx, nil, nil)
require.NoError(t, err)
require.Len(t, all, 2)
stocktest.RequireQty(t, "3", all[0].OnHand, "WH1: 10 − 3 − 4")
stocktest.RequireQty(t, "3", all[1].OnHand, "WH2")
wh2, err := inventory.OnHand(ctx, tx, &e.WH2, nil)
require.NoError(t, err)
require.Len(t, wh2, 1)
moves, err := inventory.Movements(ctx, tx, inventory.MovementFilter{From: today, To: today, ItemID: &bolt})
require.NoError(t, err)
require.Len(t, moves, 4, "receipt in, transfer out and in, delivery out")
last := moves[3]
require.Equal(t, inventory.KindDeliveryOrder, last.DocumentKind)
require.NotEmpty(t, last.DocumentNumber)
stocktest.RequireAmount(t, "-6", last.TotalCost)
require.NotNil(t, moves[1].TransferID)
v, err := inventory.StockValuation(ctx, tx, today)
require.NoError(t, err)
require.Len(t, v.Rows, 1)
stocktest.RequireQty(t, "6", v.Rows[0].Quantity)
stocktest.RequireAmount(t, "9", v.Rows[0].Value)
stocktest.RequireAmount(t, "1.5", v.Rows[0].UnitCost)
stocktest.RequireAmount(t, "9", v.Total)
require.Equal(t, "MYR", v.Currency)
before, err := inventory.StockValuation(ctx, tx, yesterday)
require.NoError(t, err)
require.Empty(t, before.Rows, "nothing was held the day before")
return nil
})
_, v := e.ItemCost(t, bolt)
stocktest.RequireAmount(t, "9", v, "the report agrees with item_cost")
}
  • Step 2: Run the test to verify it fails

Run: go test ./internal/platform/inventory/ -run TestStockReports -count=1 Expected: build failure undefined: inventory.OnHand.

  • Step 3: report.go
package inventory
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/company"
"github.com/mcom/erp/internal/platform/i18n"
)
// MaxMovementRows caps one movements request (R-O2-14).
const MaxMovementRows = 5000
var ErrTooManyRows = errors.New("inventory: more than 5,000 movements; narrow the dates, the item or the location")
// itemNames is each item's name in the request's locale, else its code.
func itemNames(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return i18n.Load(ctx, tx, "item", ids, "name")
}
func nameOr(names map[uuid.UUID]string, id uuid.UUID, code string) string {
if n, ok := names[id]; ok {
return n
}
return code
}
type OnHandRow struct {
ItemID uuid.UUID `json:"item_id"`
ItemCode string `json:"item_code"`
ItemName string `json:"item_name"`
LocationID uuid.UUID `json:"location_id"`
LocationCode string `json:"location_code"`
Lot string `json:"lot"`
OnHand money.Rate `json:"on_hand"`
}
// OnHand is every positive balance, by item code, location code and lot,
// optionally narrowed to one location or item.
func OnHand(ctx context.Context, tx pgx.Tx, locationID, itemID *uuid.UUID) ([]OnHandRow, error) {
org, err := stockOrg(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `
SELECT b.item_id, i.code, b.location_id, l.code, b.lot, b.on_hand
FROM stock_balance b
JOIN item i ON i.org_id = b.org_id AND i.id = b.item_id
JOIN location l ON l.org_id = b.org_id AND l.id = b.location_id
WHERE b.org_id = $1 AND b.on_hand > 0
AND ($2::uuid IS NULL OR b.location_id = $2) AND ($3::uuid IS NULL OR b.item_id = $3)
ORDER BY i.code, l.code, b.lot`, org, locationID, itemID)
if err != nil {
return nil, fmt.Errorf("inventory: on hand: %w", err)
}
out := []OnHandRow{}
var ids []uuid.UUID
for rows.Next() {
var r OnHandRow
if err := rows.Scan(&r.ItemID, &r.ItemCode, &r.LocationID, &r.LocationCode, &r.Lot, &r.OnHand); err != nil {
rows.Close()
return nil, err
}
out = append(out, r)
ids = append(ids, r.ItemID)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
names, err := itemNames(ctx, tx, ids)
if err != nil {
return nil, err
}
for i := range out {
out[i].ItemName = nameOr(names, out[i].ItemID, out[i].ItemCode)
}
return out, nil
}
type MovementFilter struct {
From, To time.Time
ItemID, LocationID *uuid.UUID
}
type MovementRow struct {
MoveID uuid.UUID
MoveDate time.Time
ItemID uuid.UUID
ItemCode string
LocationID uuid.UUID
LocationCode string
Quantity money.Rate
UnitCost money.Amount
TotalCost money.Amount
SerialNo string
BatchNo string
DocumentID *uuid.UUID
DocumentKind string
DocumentNumber string
TransferID *uuid.UUID
Reversal bool
}
// Movements is every move dated From..To, in posting order, with the
// document or transfer behind it.
func Movements(ctx context.Context, tx pgx.Tx, f MovementFilter) ([]MovementRow, error) {
org, err := stockOrg(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `
SELECT m.id, m.move_date, m.item_id, i.code, m.location_id, l.code, m.quantity, m.unit_cost, m.total_cost,
coalesce(m.serial_no, ''), coalesce(m.batch_no, ''), d.id, coalesce(d.kind, ''), coalesce(d.number, ''),
m.transfer_id, m.reverses_move_id IS NOT NULL
FROM stock_move m
JOIN item i ON i.org_id = m.org_id AND i.id = m.item_id
JOIN location l ON l.org_id = m.org_id AND l.id = m.location_id
LEFT JOIN trade_line t ON t.org_id = m.org_id AND t.id = m.trade_line_id
LEFT JOIN trade_document d ON d.org_id = t.org_id AND d.id = t.document_id
WHERE m.org_id = $1 AND m.move_date BETWEEN $2 AND $3
AND ($4::uuid IS NULL OR m.item_id = $4) AND ($5::uuid IS NULL OR m.location_id = $5)
ORDER BY m.move_date, m.seq
LIMIT $6`, org, f.From, f.To, f.ItemID, f.LocationID, MaxMovementRows+1)
if err != nil {
return nil, fmt.Errorf("inventory: movements: %w", err)
}
defer rows.Close()
out := []MovementRow{}
for rows.Next() {
var r MovementRow
if err := rows.Scan(&r.MoveID, &r.MoveDate, &r.ItemID, &r.ItemCode, &r.LocationID, &r.LocationCode,
&r.Quantity, &r.UnitCost, &r.TotalCost, &r.SerialNo, &r.BatchNo, &r.DocumentID, &r.DocumentKind,
&r.DocumentNumber, &r.TransferID, &r.Reversal); err != nil {
return nil, err
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) > MaxMovementRows {
return nil, ErrTooManyRows
}
return out, nil
}
type ValuationRow struct {
ItemID uuid.UUID
ItemCode string
ItemName string
AccountID *uuid.UUID
Quantity money.Rate
UnitCost money.Amount
Value money.Amount
}
type Valuation struct {
AsOf time.Time
Currency string
Rows []ValuationRow
Total money.Amount
}
// StockValuation is each item's quantity and value at the end of asOf, the
// sum of its moves to then (moves are never back-dated, so a past date's
// figure never changes).
func StockValuation(ctx context.Context, tx pgx.Tx, asOf time.Time) (Valuation, error) {
org, err := stockOrg(ctx)
if err != nil {
return Valuation{}, err
}
p, err := company.Get(ctx, tx)
if err != nil {
return Valuation{}, err
}
v := Valuation{AsOf: asOf, Currency: p.BaseCurrency, Rows: []ValuationRow{}, Total: money.Zero()}
rows, err := tx.Query(ctx, `
SELECT m.item_id, i.code, i.inventory_account_id, sum(m.quantity), sum(m.total_cost)
FROM stock_move m
JOIN item i ON i.org_id = m.org_id AND i.id = m.item_id
WHERE m.org_id = $1 AND m.move_date <= $2
GROUP BY m.item_id, i.code, i.inventory_account_id
HAVING sum(m.quantity) <> 0 OR sum(m.total_cost) <> 0
ORDER BY i.code`, org, asOf)
if err != nil {
return Valuation{}, fmt.Errorf("inventory: valuation: %w", err)
}
var ids []uuid.UUID
for rows.Next() {
var r ValuationRow
if err := rows.Scan(&r.ItemID, &r.ItemCode, &r.AccountID, &r.Quantity, &r.Value); err != nil {
rows.Close()
return Valuation{}, err
}
r.UnitCost = money.Zero()
if r.Quantity.IsPositive() {
if r.UnitCost, err = r.Value.MulDiv(money.OneRate(), r.Quantity); err != nil {
rows.Close()
return Valuation{}, err
}
}
v.Rows = append(v.Rows, r)
v.Total = v.Total.Add(r.Value)
ids = append(ids, r.ItemID)
}
rows.Close()
if err := rows.Err(); err != nil {
return Valuation{}, err
}
names, err := itemNames(ctx, tx, ids)
if err != nil {
return Valuation{}, err
}
for i := range v.Rows {
v.Rows[i].ItemName = nameOr(names, v.Rows[i].ItemID, v.Rows[i].ItemCode)
}
return v, nil
}
  • Step 4: Run the test to verify it passes

Run: go test ./internal/platform/inventory/ -run TestStockReports -count=1 Expected: PASS.

  • Step 5: Commit
Terminal window
git add internal/platform/inventory/report.go internal/platform/inventory/report_test.go
git commit -m "Phase 4 O2: stock on hand, movements and valuation reports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 12: The HTTP surface — permissions, routes, contract, grants

Section titled “Task 12: The HTTP surface — permissions, routes, contract, grants”

Files:

  • Create: db/migrations/00727_inventory_stock_grants.sql
  • Create: internal/modules/inventory/stock_permissions.go, internal/modules/inventory/stock_boot.go, internal/modules/inventory/stock_handlers.go, internal/modules/inventory/stock_routes_test.go
  • Modify: internal/modules/inventory/routes.go (O1’s Register calls registerStock), internal/modules/inventory/errors.go (O1’s mapErr calls stockProblem first)
  • Modify: api/openapi.yaml (twelve operations, schemas, responses)

Interfaces:

  • Consumes: every exported function of Tasks 4, 6, 8, 9, 11; O1’s module (handlers, Deps, permissionLoader, decodeStrict, mapErr, PermissionItemRead); *trade.FieldError (with Field), trade.ErrNotFound.
  • Produces: permissions inventory.adjustment.read, inventory.adjustment.create, inventory.adjustment.transition, inventory.transfer.create; routes:
Route Permission
GET /inventory/stock/on-hand inventory.item.read
GET /inventory/stock/movements inventory.item.read
GET /inventory/stock/valuation inventory.item.read
POST /inventory/transfers inventory.transfer.create
GET /inventory/costing-method inventory.item.read
PUT /inventory/costing-method org.profile.manage
GET /inventory/adjustment-reasons inventory.adjustment.read
POST /inventory/adjustment-reasons accounting.account.manage
POST /inventory/adjustments inventory.adjustment.create
GET /inventory/adjustments/{id} inventory.adjustment.read
POST /inventory/adjustments/{id}/transitions inventory.adjustment.transition
POST /inventory/stock-counts inventory.adjustment.create
  • Step 1: Write the failing test

internal/modules/inventory/stock_routes_test.go:

package inventory_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/inventory"
"github.com/mcom/erp/internal/platform/accounting"
platforminventory "github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/posting"
)
// TestTheStockRoutesRequireTheirExactPermission pins O2's twelve routes
// (O1's module has no route-pin test of its own; internal/app's
// TestEveryRouteIsAuthorized covers the whole router).
func TestTheStockRoutesRequireTheirExactPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
httpx.ResetAuthorizedRoutes()
_, api := httpx.NewRouter(httpx.Deps{})
inventory.Register(api, inventory.Deps{})
read := inventory.PermissionItemRead
want := map[string]string{
"GET /v1/inventory/stock/on-hand": read,
"GET /v1/inventory/stock/movements": read,
"GET /v1/inventory/stock/valuation": read,
"POST /v1/inventory/transfers": inventory.PermissionTransferCreate,
"GET /v1/inventory/costing-method": read,
"PUT /v1/inventory/costing-method": "org.profile.manage",
"GET /v1/inventory/adjustment-reasons": inventory.PermissionAdjustmentRead,
"POST /v1/inventory/adjustment-reasons": "accounting.account.manage",
"POST /v1/inventory/adjustments": inventory.PermissionAdjustmentCreate,
"GET /v1/inventory/adjustments/:id": inventory.PermissionAdjustmentRead,
"POST /v1/inventory/adjustments/:id/transitions": inventory.PermissionAdjustmentTransition,
"POST /v1/inventory/stock-counts": inventory.PermissionAdjustmentCreate,
}
got := httpx.AuthorizedRoutes()
for route, perm := range want {
require.Equal(t, perm, got[route], route)
}
require.Equal(t, "inventory.adjustment.read", inventory.PermissionAdjustmentRead)
require.Equal(t, "inventory.adjustment.create", inventory.PermissionAdjustmentCreate)
require.Equal(t, "inventory.adjustment.transition", inventory.PermissionAdjustmentTransition)
require.Equal(t, "inventory.transfer.create", inventory.PermissionTransferCreate)
}
// TestTheStockRulesAreRegisteredAtBoot: importing the module registers
// every rule of platform inventory into the process's rule set.
func TestTheStockRulesAreRegisteredAtBoot(t *testing.T) {
for _, r := range platforminventory.Rules() {
_, ok := accounting.DefaultRules().Lookup(r.Kind(), r.Semantic())
require.True(t, ok, "%s on %s", r.Kind(), r.Semantic())
}
_, ok := accounting.DefaultRules().Lookup(platforminventory.KindDeliveryOrder, posting.Effect)
require.True(t, ok)
}
  • Step 2: Run the test to verify it fails

Run: go test ./internal/modules/inventory/ -count=1 Expected: build failure undefined: inventory.PermissionTransferCreate.

  • Step 3: Permissions, boot, grants

internal/modules/inventory/stock_permissions.go:

package inventory
import "github.com/mcom/erp/internal/platform/identity/member"
// O2's keys (spec O3.3's inventory.adjustment.* and inventory.transfer.*).
// Stock reads are inventory.item.read (O1's key); the costing method is
// org.profile.manage and a reason's account accounting.account.manage,
// both registered by their own modules.
const (
PermissionAdjustmentRead = "inventory.adjustment.read"
PermissionAdjustmentCreate = "inventory.adjustment.create"
PermissionAdjustmentTransition = "inventory.adjustment.transition"
PermissionTransferCreate = "inventory.transfer.create"
permissionProfileManage = "org.profile.manage"
permissionAccountManage = "accounting.account.manage"
)
func init() {
member.RegisterPermission(PermissionAdjustmentRead, "Read stock adjustments and adjustment reasons")
member.RegisterPermission(PermissionAdjustmentCreate, "Create stock adjustments and record stock counts")
member.RegisterPermission(PermissionAdjustmentTransition, "Submit, approve, reject and cancel stock adjustments, as their workflow grants")
member.RegisterPermission(PermissionTransferCreate, "Transfer stock between locations")
}

(A new Owner is seeded with every registered key by provisioning; no role gets them by RegisterRolePermissions — who approves an adjustment is the workflow’s grant, not the key.)

internal/modules/inventory/stock_boot.go:

package inventory
import (
"github.com/mcom/erp/internal/platform/accounting"
platforminventory "github.com/mcom/erp/internal/platform/inventory"
)
// One loop over Rules(): registering only some of them is a transition
// that commits a state change it cannot post.
func init() {
for _, r := range platforminventory.Rules() {
accounting.RegisterPostingRule(r)
}
}

db/migrations/00727_inventory_stock_grants.sql:

-- +goose Up
-- Phase 4 O2: an existing organization's Owner gets the four stock keys a
-- new Owner is seeded with (the pattern of 00676). 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['inventory.adjustment.read', 'inventory.adjustment.create',
'inventory.adjustment.transition', 'inventory.transfer.create']) AS k
WHERE r.system AND r.name = 'Owner'
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 = 'Owner');
-- +goose Down
DELETE 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 = 'Owner'
AND rp.permission_key IN ('inventory.adjustment.read', 'inventory.adjustment.create',
'inventory.adjustment.transition', 'inventory.transfer.create');
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 = 'Owner');
  • Step 4: Handlers and routes

internal/modules/inventory/stock_handlers.go:

package inventory
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
apperr "github.com/mcom/erp/internal/kernel/errors"
"github.com/mcom/erp/internal/kernel/httpx"
"github.com/mcom/erp/internal/kernel/money"
"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/accounting/ledger"
"github.com/mcom/erp/internal/platform/company"
p "github.com/mcom/erp/internal/platform/inventory"
"github.com/mcom/erp/internal/platform/inventory/adjustment"
"github.com/mcom/erp/internal/platform/trade"
"github.com/mcom/erp/internal/platform/workflow"
)
// registerStock hangs O2's routes off the group; Register calls it.
func registerStock(g *gin.RouterGroup, pl httpx.PermissionLoader, h handlers) {
read := PermissionItemRead
httpx.Protected(g, pl, "GET", "/inventory/stock/on-hand", read, h.getStockOnHand)
httpx.Protected(g, pl, "GET", "/inventory/stock/movements", read, h.getStockMovements)
httpx.Protected(g, pl, "GET", "/inventory/stock/valuation", read, h.getStockValuation)
httpx.Protected(g, pl, "POST", "/inventory/transfers", PermissionTransferCreate, h.createTransfer)
httpx.Protected(g, pl, "GET", "/inventory/costing-method", read, h.getCostingMethod)
httpx.Protected(g, pl, "PUT", "/inventory/costing-method", permissionProfileManage, h.putCostingMethod)
httpx.Protected(g, pl, "GET", "/inventory/adjustment-reasons", PermissionAdjustmentRead, h.listAdjustmentReasons)
httpx.Protected(g, pl, "POST", "/inventory/adjustment-reasons", permissionAccountManage, h.createAdjustmentReason)
httpx.Protected(g, pl, "POST", "/inventory/adjustments", PermissionAdjustmentCreate, h.createAdjustment)
httpx.Protected(g, pl, "GET", "/inventory/adjustments/:id", PermissionAdjustmentRead, h.getAdjustment)
httpx.Protected(g, pl, "POST", "/inventory/adjustments/:id/transitions", PermissionAdjustmentTransition, h.transitionAdjustment)
httpx.Protected(g, pl, "POST", "/inventory/stock-counts", PermissionAdjustmentCreate, h.createStockCount)
}
const stockDate = "2006-01-02"
func stockActor(c *gin.Context) (uuid.UUID, error) {
a, ok := audit.ActorFromContext(c.Request.Context())
if !ok {
return uuid.Nil, apperr.New(apperr.KindUnauthorized, "no authenticated member")
}
return a.MemberID, nil
}
func stockUUIDQuery(c *gin.Context, name string) (*uuid.UUID, error) {
s := c.Query(name)
if s == "" {
return nil, nil
}
id, err := uuid.Parse(s)
if err != nil {
return nil, apperr.New(apperr.KindValidation, fmt.Sprintf("field=%s rule=uuid: not an id", name))
}
return &id, nil
}
func stockDateQuery(c *gin.Context, name string, fallback time.Time) (time.Time, error) {
s := c.Query(name)
if s == "" {
return fallback, nil
}
d, err := time.Parse(stockDate, s)
if err != nil {
return time.Time{}, apperr.New(apperr.KindValidation, fmt.Sprintf("field=%s rule=date: YYYY-MM-DD", name))
}
return d, nil
}
func stockPathID(c *gin.Context) (uuid.UUID, error) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
return uuid.Nil, apperr.New(apperr.KindNotFound, "no such record in this organization")
}
return id, nil
}
// lotBody is a Lot on the wire: the expiry is a date.
type lotBody struct {
SerialNo string `json:"serial_no"`
BatchNo string `json:"batch_no"`
ExpiresOn *string `json:"expires_on"`
Quantity money.Rate `json:"quantity"`
}
func lotsFrom(in []lotBody) ([]p.Lot, error) {
out := make([]p.Lot, 0, len(in))
for _, b := range in {
lot := p.Lot{SerialNo: b.SerialNo, BatchNo: b.BatchNo, Quantity: b.Quantity}
if b.ExpiresOn != nil {
d, err := time.Parse(stockDate, *b.ExpiresOn)
if err != nil {
return nil, apperr.New(apperr.KindValidation, "field=expires_on rule=date: YYYY-MM-DD")
}
lot.ExpiresOn = &d
}
out = append(out, lot)
}
return out, nil
}
func lotBodies(in []p.Lot) []lotBody {
out := make([]lotBody, 0, len(in))
for _, l := range in {
b := lotBody{SerialNo: l.SerialNo, BatchNo: l.BatchNo, Quantity: l.Quantity}
if l.ExpiresOn != nil {
s := l.ExpiresOn.Format(stockDate)
b.ExpiresOn = &s
}
out = append(out, b)
}
return out
}
func (h handlers) getStockOnHand(c *gin.Context) {
loc, err := stockUUIDQuery(c, "location_id")
if err != nil {
_ = c.Error(err)
return
}
item, err := stockUUIDQuery(c, "item_id")
if err != nil {
_ = c.Error(err)
return
}
var rows []p.OnHandRow
err = h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
rows, err = p.OnHand(ctx, tx, loc, item)
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, gin.H{"rows": rows})
}
type movementDTO struct {
MoveID uuid.UUID `json:"move_id"`
MoveDate string `json:"move_date"`
ItemID uuid.UUID `json:"item_id"`
ItemCode string `json:"item_code"`
LocationID uuid.UUID `json:"location_id"`
LocationCode string `json:"location_code"`
Quantity money.Rate `json:"quantity"`
UnitCost money.Amount `json:"unit_cost"`
TotalCost money.Amount `json:"total_cost"`
SerialNo string `json:"serial_no"`
BatchNo string `json:"batch_no"`
DocumentID *uuid.UUID `json:"document_id"`
DocumentKind string `json:"document_kind"`
DocumentNumber string `json:"document_number"`
TransferID *uuid.UUID `json:"transfer_id"`
Reversal bool `json:"reversal"`
}
func (h handlers) getStockMovements(c *gin.Context) {
var f p.MovementFilter
var err error
if f.ItemID, err = stockUUIDQuery(c, "item_id"); err != nil {
_ = c.Error(err)
return
}
if f.LocationID, err = stockUUIDQuery(c, "location_id"); err != nil {
_ = c.Error(err)
return
}
var rows []p.MovementRow
err = h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
today := clock.Today(ctx, h.deps.Clock)
var err error
if f.From, err = stockDateQuery(c, "from", today.AddDate(0, -1, 0)); err != nil {
return err
}
if f.To, err = stockDateQuery(c, "to", today); err != nil {
return err
}
rows, err = p.Movements(ctx, tx, f)
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
out := make([]movementDTO, 0, len(rows))
for _, r := range rows {
out = append(out, movementDTO{r.MoveID, r.MoveDate.Format(stockDate), r.ItemID, r.ItemCode, r.LocationID,
r.LocationCode, r.Quantity, r.UnitCost, r.TotalCost, r.SerialNo, r.BatchNo, r.DocumentID, r.DocumentKind,
r.DocumentNumber, r.TransferID, r.Reversal})
}
c.JSON(http.StatusOK, gin.H{"from": f.From.Format(stockDate), "to": f.To.Format(stockDate), "rows": out})
}
type valuationRowDTO struct {
ItemID uuid.UUID `json:"item_id"`
ItemCode string `json:"item_code"`
ItemName string `json:"item_name"`
AccountID *uuid.UUID `json:"account_id"`
Quantity money.Rate `json:"quantity"`
UnitCost money.Amount `json:"unit_cost"`
Value money.Amount `json:"value"`
}
func (h handlers) getStockValuation(c *gin.Context) {
var v p.Valuation
err := h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
asOf, err := stockDateQuery(c, "as_of", clock.Today(ctx, h.deps.Clock))
if err != nil {
return err
}
v, err = p.StockValuation(ctx, tx, asOf)
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
rows := make([]valuationRowDTO, 0, len(v.Rows))
for _, r := range v.Rows {
rows = append(rows, valuationRowDTO{r.ItemID, r.ItemCode, r.ItemName, r.AccountID, r.Quantity, r.UnitCost, r.Value})
}
c.JSON(http.StatusOK, gin.H{"as_of": v.AsOf.Format(stockDate), "currency": v.Currency, "total": v.Total, "rows": rows})
}
type transferBody struct {
ItemID uuid.UUID `json:"item_id"`
From uuid.UUID `json:"from_location_id"`
To uuid.UUID `json:"to_location_id"`
Quantity money.Rate `json:"quantity"`
Lots []lotBody `json:"lots"`
Note string `json:"note"`
}
func (h handlers) createTransfer(c *gin.Context) {
var b transferBody
if err := decodeStrict(c, &b); err != nil {
_ = c.Error(err)
return
}
actor, err := stockActor(c)
if err != nil {
_ = c.Error(err)
return
}
lots, err := lotsFrom(b.Lots)
if err != nil {
_ = c.Error(err)
return
}
var t p.StockTransfer
err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
t, err = p.TransferStock(ctx, tx, h.deps.Clock, p.TransferInput{ItemID: b.ItemID, From: b.From, To: b.To,
Quantity: b.Quantity, Lots: lots, Note: b.Note, Actor: actor})
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusCreated, gin.H{"id": t.ID, "item_id": t.ItemID, "from_location_id": t.From,
"to_location_id": t.To, "quantity": t.Quantity, "transferred_on": t.TransferredOn.Format(stockDate), "note": t.Note})
}
func (h handlers) getCostingMethod(c *gin.Context) {
var method string
var locked bool
err := h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
prof, err := company.Get(ctx, tx)
if err != nil {
return err
}
method = prof.CostingMethod
locked, err = p.CostingLocked(ctx, tx)
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, gin.H{"costing_method": method, "locked": locked})
}
func (h handlers) putCostingMethod(c *gin.Context) {
var b struct {
CostingMethod string `json:"costing_method"`
}
if err := decodeStrict(c, &b); err != nil {
_ = c.Error(err)
return
}
var method string
err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
prof, err := company.SetCostingMethod(ctx, tx, h.deps.Clock, b.CostingMethod)
method = prof.CostingMethod
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, gin.H{"costing_method": method, "locked": false})
}
func (h handlers) listAdjustmentReasons(c *gin.Context) {
var rows []p.AdjustmentReason
err := h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
rows, err = p.ListAdjustmentReasons(ctx, tx)
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
if rows == nil {
rows = []p.AdjustmentReason{}
}
c.JSON(http.StatusOK, gin.H{"reasons": rows})
}
func (h handlers) createAdjustmentReason(c *gin.Context) {
var b struct {
Code string `json:"code"`
AccountID uuid.UUID `json:"account_id"`
Names map[string]string `json:"names"`
}
if err := decodeStrict(c, &b); err != nil {
_ = c.Error(err)
return
}
var r p.AdjustmentReason
err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
r, err = p.CreateAdjustmentReason(ctx, tx, h.deps.Clock, p.ReasonInput{Code: b.Code, AccountID: b.AccountID, Names: b.Names})
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusCreated, r)
}
type adjustmentLineBody struct {
ItemID uuid.UUID `json:"item_id"`
Direction string `json:"direction"`
Quantity money.Rate `json:"quantity"`
UnitCost *money.Amount `json:"unit_cost"`
Lots []lotBody `json:"lots"`
}
func (h handlers) createAdjustment(c *gin.Context) {
var b struct {
LocationID uuid.UUID `json:"location_id"`
ReasonID uuid.UUID `json:"reason_id"`
Lines []adjustmentLineBody `json:"lines"`
}
if err := decodeStrict(c, &b); err != nil {
_ = c.Error(err)
return
}
actor, err := stockActor(c)
if err != nil {
_ = c.Error(err)
return
}
in := adjustment.Input{LocationID: b.LocationID, ReasonID: b.ReasonID, Reporter: actor}
for _, l := range b.Lines {
lots, err := lotsFrom(l.Lots)
if err != nil {
_ = c.Error(err)
return
}
cost := money.Zero()
if l.UnitCost != nil {
cost = *l.UnitCost
}
in.Lines = append(in.Lines, adjustment.Line{ItemID: l.ItemID, Direction: l.Direction, Quantity: l.Quantity,
UnitCost: cost, Lots: lots})
}
h.respondAdjustment(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx) (uuid.UUID, error) {
return adjustment.Create(ctx, tx, h.deps.Clock, in)
})
}
func (h handlers) getAdjustment(c *gin.Context) {
id, err := stockPathID(c)
if err != nil {
_ = c.Error(err)
return
}
var a adjustment.Adjustment
err = h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
a, err = adjustment.Get(ctx, tx, id)
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, adjustmentDTO(a))
}
func (h handlers) transitionAdjustment(c *gin.Context) {
id, err := stockPathID(c)
if err != nil {
_ = c.Error(err)
return
}
var b struct {
From string `json:"from"`
To string `json:"to"`
Reason string `json:"reason"`
}
if err := decodeStrict(c, &b); err != nil {
_ = c.Error(err)
return
}
actor, err := stockActor(c)
if err != nil {
_ = c.Error(err)
return
}
h.respondAdjustment(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (uuid.UUID, error) {
if _, err := adjustment.Get(ctx, tx, id); err != nil { // a stock adjustment, not another stock document
return uuid.Nil, err
}
_, err := p.Transition(ctx, tx, h.deps.Clock, p.TransitionInput{DocumentID: id, From: b.From, To: b.To,
Actor: actor, Reason: b.Reason})
return id, err
})
}
// respondAdjustment runs write in one transaction, reads the adjustment it
// names back in the same transaction, and answers it. A transition posts,
// so the error goes through ledger.MapError before mapErr.
func (h handlers) respondAdjustment(c *gin.Context, status int, write func(ctx context.Context, tx pgx.Tx) (uuid.UUID, error)) {
var a adjustment.Adjustment
err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
id, err := write(ctx, tx)
if err != nil {
return err
}
a, err = adjustment.Get(ctx, tx, id)
return err
})
if err != nil {
_ = c.Error(mapErr(ledger.MapError(err)))
return
}
c.JSON(status, adjustmentDTO(a))
}
func adjustmentDTO(a adjustment.Adjustment) gin.H {
lines := make([]gin.H, 0, len(a.Lines))
for _, l := range a.Lines {
lines = append(lines, gin.H{"id": l.ID, "item_id": l.ItemID, "direction": l.Direction, "quantity": l.Quantity,
"unit_cost": l.UnitCost, "lots": lotBodies(l.Lots)})
}
return gin.H{"id": a.ID, "number": a.Number, "state": a.State, "location_id": a.LocationID, "reason_id": a.ReasonID,
"count_id": a.CountID, "instance_id": a.InstanceID, "lines": lines}
}
func (h handlers) createStockCount(c *gin.Context) {
var b struct {
LocationID uuid.UUID `json:"location_id"`
ReasonID uuid.UUID `json:"reason_id"`
Lines []struct {
ItemID uuid.UUID `json:"item_id"`
SerialNo string `json:"serial_no"`
BatchNo string `json:"batch_no"`
Counted money.Rate `json:"counted"`
} `json:"lines"`
}
if err := decodeStrict(c, &b); err != nil {
_ = c.Error(err)
return
}
actor, err := stockActor(c)
if err != nil {
_ = c.Error(err)
return
}
in := adjustment.CountInput{LocationID: b.LocationID, ReasonID: b.ReasonID, Reporter: actor}
for _, l := range b.Lines {
in.Lines = append(in.Lines, adjustment.CountLine{ItemID: l.ItemID, SerialNo: l.SerialNo, BatchNo: l.BatchNo, Counted: l.Counted})
}
var count adjustment.Count
err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
count, err = adjustment.RecordCount(ctx, tx, h.deps.Clock, in)
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusCreated, gin.H{"id": count.ID, "location_id": count.LocationID,
"counted_on": count.CountedOn.Format(stockDate), "adjustment_id": count.AdjustmentID, "lines": count.Lines})
}
// stockProblem maps O2's refusals, and the workflow, ledger and trade
// refusals O2's routes can meet (O1's mapErr maps item-master errors only);
// mapErr calls it first. nil: not one of these.
func stockProblem(err error) error {
var (
refused *workflow.RefusedError
unroutable *workflow.SubmissionRefusedError
period *fiscal.PeriodNotOpenError
control *chart.NoControlAccountError
noRule *accounting.NoPostingRuleError
tradeField *trade.FieldError
)
switch {
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, new(*workflow.ReasonError)):
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")
case errors.As(err, &tradeField):
return apperr.New(apperr.KindValidation, tradeField.Error()).With("field", tradeField.Field)
case errors.Is(err, trade.ErrNotFound):
return apperr.New(apperr.KindNotFound, "no such record in this organization")
}
conflict := func(code string) error { return apperr.New(apperr.KindConflict, err.Error()).With("code", code) }
notFound := func(code string) error { return apperr.New(apperr.KindNotFound, err.Error()).With("code", code) }
invalid := func(field, rule string) error {
return apperr.New(apperr.KindValidation, fmt.Sprintf("field=%s rule=%s: %s", field, rule, err.Error()))
}
for _, m := range []struct {
target error
out func() error
}{
{p.ErrInsufficientStock, func() error { return conflict("insufficient_stock") }},
{p.ErrSerialHeld, func() error { return conflict("serial_held") }},
{p.ErrLayerConsumed, func() error { return conflict("layer_consumed") }},
{p.ErrValueExhausted, func() error { return conflict("value_exhausted") }},
{p.ErrAlreadyApplied, func() error { return conflict("already_applied") }},
{p.ErrItemFixed, func() error { return conflict("item_fixed") }},
{p.ErrHasChildren, func() error { return conflict("has_children") }},
{p.ErrNothingReceived, func() error { return conflict("nothing_received") }},
{p.ErrItemAccountMissing, func() error { return conflict("item_account_missing") }},
{p.ErrReasonCodeTaken, func() error { return conflict("reason_code_taken") }},
{p.ErrReasonInactive, func() error { return conflict("reason_inactive") }},
{company.ErrCostingMethodLocked, func() error { return conflict("costing_method_locked") }},
{p.ErrDocumentNotFound, func() error { return notFound("document_not_found") }},
{p.ErrWrongKind, func() error { return notFound("document_not_found") }},
{p.ErrItemNotFound, func() error { return notFound("item_not_found") }},
{p.ErrReasonNotFound, func() error { return notFound("reason_not_found") }},
{p.ErrSerialRequired, func() error { return invalid("lots", "serial_required") }},
{p.ErrBatchRequired, func() error { return invalid("lots", "batch_required") }},
{p.ErrUntrackedLot, func() error { return invalid("lots", "untracked_lot") }},
{p.ErrLotsMismatch, func() error { return invalid("lots", "lots_mismatch") }},
{p.ErrBatchExpiryMismatch, func() error { return invalid("lots", "batch_expiry_mismatch") }},
{p.ErrNotStockItem, func() error { return invalid("item_id", "stock_item") }},
{p.ErrSameLocation, func() error { return invalid("to_location_id", "other_location") }},
{p.ErrQuantityNotPositive, func() error { return invalid("quantity", "positive") }},
{p.ErrNoDirection, func() error { return invalid("direction", "in_or_out") }},
{p.ErrNoLocation, func() error { return invalid("location_id", "required") }},
{p.ErrNoSourceLine, func() error { return invalid("source_line_id", "required") }},
{p.ErrLineNeedsItem, func() error { return invalid("item_id", "required") }},
{p.ErrReasonCode, func() error { return invalid("code", "shape") }},
{p.ErrReasonName, func() error { return invalid("names", "en_required") }},
{p.ErrReasonAccount, func() error { return invalid("account_id", "ordinary_account") }},
{p.ErrTooManyRows, func() error { return invalid("to", "too_many_rows") }},
{company.ErrCostingMethodUnknown, func() error { return invalid("costing_method", "known") }},
{adjustment.ErrNoLines, func() error { return invalid("lines", "required") }},
{adjustment.ErrUnitCostOnOut, func() error { return invalid("unit_cost", "none_on_out") }},
{adjustment.ErrNegativeUnitCost, func() error { return invalid("unit_cost", "nonnegative") }},
{adjustment.ErrDuplicateCountLine, func() error { return invalid("lines", "counted_once") }},
{adjustment.ErrCountedNegative, func() error { return invalid("counted", "nonnegative") }},
} {
if errors.Is(err, m.target) {
return m.out()
}
}
return nil
}

internal/modules/inventory/routes.go — last line of O1’s Register (its h and pl):

registerStock(g, pl, h)

internal/modules/inventory/errors.go — first statement of O1’s mapErr:

if problem := stockProblem(err); problem != nil {
return problem
}
  • Step 5: The contract

api/openapi.yaml — under paths (tag inventory, like O1’s routes; every operation inherits bearerAuth):

/inventory/stock/on-hand:
get:
operationId: getStockOnHand
summary: Stock on hand by item, location and lot
description: Every positive balance, optionally narrowed to one location or item. Requires inventory.item.read.
parameters:
- { name: location_id, in: query, schema: { type: string, format: uuid } }
- { name: item_id, in: query, schema: { type: string, format: uuid } }
responses:
'200':
description: Stock on hand
content:
application/json:
schema:
type: object
required: [rows]
properties:
rows: { type: array, items: { $ref: '#/components/schemas/StockOnHandRow' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'422': { $ref: '#/components/responses/StockInvalid' }
/inventory/stock/movements:
get:
operationId: getStockMovements
summary: Stock movements between two dates
description: >
Every move dated from..to (default: a month back to today), in posting order, with the
document or transfer behind it. At most 5,000 rows (422 too_many_rows). Requires
inventory.item.read.
parameters:
- { name: from, in: query, schema: { type: string, format: date } }
- { name: to, in: query, schema: { type: string, format: date } }
- { name: item_id, in: query, schema: { type: string, format: uuid } }
- { name: location_id, in: query, schema: { type: string, format: uuid } }
responses:
'200':
description: The movements
content:
application/json:
schema:
type: object
required: [from, to, rows]
properties:
from: { $ref: '#/components/schemas/Date' }
to: { $ref: '#/components/schemas/Date' }
rows: { type: array, items: { $ref: '#/components/schemas/StockMovement' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'422': { $ref: '#/components/responses/StockInvalid' }
/inventory/stock/valuation:
get:
operationId: getStockValuation
summary: Stock valuation as of a date
description: Each item's quantity and value at the end of as_of (default today), in base currency. Requires inventory.item.read.
parameters:
- { name: as_of, in: query, schema: { type: string, format: date } }
responses:
'200':
description: The valuation
content:
application/json:
schema:
type: object
required: [as_of, currency, total, rows]
properties:
as_of: { $ref: '#/components/schemas/Date' }
currency: { type: string }
total: { $ref: '#/components/schemas/Money' }
rows: { type: array, items: { $ref: '#/components/schemas/StockValuationRow' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'422': { $ref: '#/components/responses/StockInvalid' }
/inventory/transfers:
post:
operationId: createStockTransfer
summary: Transfer one item between two locations
description: Two moves, no workflow, no journal entry, no value moved (spec O2.1). Requires inventory.transfer.create.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/StockTransferInput' } } }
responses:
'201': { description: The transfer, content: { application/json: { schema: { $ref: '#/components/schemas/StockTransfer' } } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'404': { $ref: '#/components/responses/StockNotFound' }
'409': { $ref: '#/components/responses/StockConflict' }
'422': { $ref: '#/components/responses/StockInvalid' }
/inventory/costing-method:
get:
operationId: getCostingMethod
summary: The organization's costing method
description: fifo or weighted_average; locked once stock has moved (OD-1). Requires inventory.item.read.
responses:
'200': { description: The method, content: { application/json: { schema: { $ref: '#/components/schemas/CostingMethod' } } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
put:
operationId: setCostingMethod
summary: Choose the costing method before stock first moves
description: Refused (409 costing_method_locked) once any stock has moved. Requires org.profile.manage.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [costing_method]
additionalProperties: false
properties:
costing_method: { type: string, enum: [fifo, weighted_average] }
responses:
'200': { description: The method, content: { application/json: { schema: { $ref: '#/components/schemas/CostingMethod' } } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'409': { $ref: '#/components/responses/StockConflict' }
'422': { $ref: '#/components/responses/StockInvalid' }
/inventory/adjustment-reasons:
get:
operationId: listAdjustmentReasons
summary: Stock adjustment reasons
description: Requires inventory.adjustment.read.
responses:
'200':
description: The reasons
content:
application/json:
schema:
type: object
required: [reasons]
properties:
reasons: { type: array, items: { $ref: '#/components/schemas/AdjustmentReason' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
post:
operationId: createAdjustmentReason
summary: Add a stock adjustment reason and the account it posts to
description: >
The account is postable, active, no control account and no module's own. Requires
accounting.account.manage (it chooses a GL account).
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/AdjustmentReasonInput' } } }
responses:
'201': { description: The reason, content: { application/json: { schema: { $ref: '#/components/schemas/AdjustmentReason' } } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'409': { $ref: '#/components/responses/StockConflict' }
'422': { $ref: '#/components/responses/StockInvalid' }
/inventory/adjustments:
post:
operationId: createStockAdjustment
summary: Draft a stock adjustment
description: >
Base UOM and base currency; "in" lines carry a unit cost, "out" lines none (FIFO or the
average decides). Submitted by its preparer and approved by the Owner seat, when it moves
and posts (effect). Requires inventory.adjustment.create.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/StockAdjustmentInput' } } }
responses:
'201': { description: The draft, content: { application/json: { schema: { $ref: '#/components/schemas/StockAdjustment' } } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'404': { $ref: '#/components/responses/StockNotFound' }
'409': { $ref: '#/components/responses/StockConflict' }
'422': { $ref: '#/components/responses/StockInvalid' }
/inventory/adjustments/{id}:
get:
operationId: getStockAdjustment
summary: One stock adjustment
description: Requires inventory.adjustment.read.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200': { description: The adjustment, content: { application/json: { schema: { $ref: '#/components/schemas/StockAdjustment' } } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'404': { $ref: '#/components/responses/StockNotFound' }
/inventory/adjustments/{id}/transitions:
post:
operationId: transitionStockAdjustment
summary: Move a stock adjustment along its workflow
description: >
Approving moves the stock and posts; cancelling after approval reverses both. Authority
is the workflow's grants. Requires inventory.adjustment.transition.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/StockTransitionInput' } } }
responses:
'200': { description: The adjustment after the move, content: { application/json: { schema: { $ref: '#/components/schemas/StockAdjustment' } } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'404': { $ref: '#/components/responses/StockNotFound' }
'409': { $ref: '#/components/responses/StockConflict' }
'422': { $ref: '#/components/responses/StockInvalid' }
/inventory/stock-counts:
post:
operationId: recordStockCount
summary: Record a stock count
description: >
Records counted and book quantities at one location and drafts an adjustment for the
differences (adjustment_id; null when the count matches). Requires inventory.adjustment.create.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/StockCountInput' } } }
responses:
'201': { description: The count, content: { application/json: { schema: { $ref: '#/components/schemas/StockCount' } } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/StockForbidden' }
'404': { $ref: '#/components/responses/StockNotFound' }
'409': { $ref: '#/components/responses/StockConflict' }
'422': { $ref: '#/components/responses/StockInvalid' }

Under components.responses:

StockForbidden:
description: The caller does not hold the permission this operation requires.
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
StockNotFound:
description: code document_not_found, item_not_found or reason_not_found.
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
StockConflict:
description: >
code insufficient_stock, serial_held, layer_consumed, value_exhausted, already_applied,
item_fixed, has_children, nothing_received, item_account_missing, reason_code_taken, reason_inactive,
costing_method_locked; or a workflow or ledger refusal.
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
StockInvalid:
description: A field refusal, "field=<name> rule=<code>".
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }

Under components.schemas:

StockQuantity:
type: string
description: A quantity in the item's base unit as a decimal string, up to 6 places. Never a JSON number.
example: "12.500000"
x-go-type: money.Rate
x-go-type-import:
path: github.com/mcom/erp/internal/kernel/money
StockLot:
type: object
required: [quantity]
additionalProperties: false
properties:
serial_no: { type: string, description: A serial item's serial; quantity is then 1. }
batch_no: { type: string }
expires_on: { type: string, format: date, nullable: true, description: A batch's expiry. }
quantity: { $ref: '#/components/schemas/StockQuantity' }
StockOnHandRow:
type: object
required: [item_id, item_code, item_name, location_id, location_code, lot, on_hand]
properties:
item_id: { type: string, format: uuid }
item_code: { type: string }
item_name: { type: string }
location_id: { type: string, format: uuid }
location_code: { type: string }
lot: { type: string, description: The serial or batch; empty for an untracked item. }
on_hand: { $ref: '#/components/schemas/StockQuantity' }
StockMovement:
type: object
required: [move_id, move_date, item_id, item_code, location_id, location_code, quantity, unit_cost, total_cost,
serial_no, batch_no, document_id, document_kind, document_number, transfer_id, reversal]
properties:
move_id: { type: string, format: uuid }
move_date: { $ref: '#/components/schemas/Date' }
item_id: { type: string, format: uuid }
item_code: { type: string }
location_id: { type: string, format: uuid }
location_code: { type: string }
quantity: { $ref: '#/components/schemas/StockQuantity' }
unit_cost: { $ref: '#/components/schemas/Money' }
total_cost: { $ref: '#/components/schemas/Money' }
serial_no: { type: string }
batch_no: { type: string }
document_id: { type: string, format: uuid, nullable: true }
document_kind: { type: string }
document_number: { type: string }
transfer_id: { type: string, format: uuid, nullable: true }
reversal: { type: boolean }
StockValuationRow:
type: object
required: [item_id, item_code, item_name, account_id, quantity, unit_cost, value]
properties:
item_id: { type: string, format: uuid }
item_code: { type: string }
item_name: { type: string }
account_id: { type: string, format: uuid, nullable: true }
quantity: { $ref: '#/components/schemas/StockQuantity' }
unit_cost: { $ref: '#/components/schemas/Money' }
value: { $ref: '#/components/schemas/Money' }
StockTransferInput:
type: object
required: [item_id, from_location_id, to_location_id, quantity]
additionalProperties: false
properties:
item_id: { type: string, format: uuid }
from_location_id: { type: string, format: uuid }
to_location_id: { type: string, format: uuid }
quantity: { $ref: '#/components/schemas/StockQuantity' }
lots: { type: array, items: { $ref: '#/components/schemas/StockLot' } }
note: { type: string, maxLength: 500 }
StockTransfer:
type: object
required: [id, item_id, from_location_id, to_location_id, quantity, transferred_on, note]
properties:
id: { type: string, format: uuid }
item_id: { type: string, format: uuid }
from_location_id: { type: string, format: uuid }
to_location_id: { type: string, format: uuid }
quantity: { $ref: '#/components/schemas/StockQuantity' }
transferred_on: { $ref: '#/components/schemas/Date' }
note: { type: string }
CostingMethod:
type: object
required: [costing_method, locked]
properties:
costing_method: { type: string, enum: [fifo, weighted_average] }
locked: { type: boolean, description: True once any stock has moved. }
AdjustmentReason:
type: object
required: [id, code, name, account_id, active]
properties:
id: { type: string, format: uuid }
code: { type: string }
name: { type: string, description: In the request's locale. }
account_id: { type: string, format: uuid }
active: { type: boolean }
AdjustmentReasonInput:
type: object
required: [code, account_id, names]
additionalProperties: false
properties:
code: { type: string, pattern: '^[a-z][a-z0-9_]{0,31}$' }
account_id: { type: string, format: uuid }
names:
type: object
required: [en]
additionalProperties: { type: string, maxLength: 200 }
StockAdjustmentInput:
type: object
required: [location_id, reason_id, lines]
additionalProperties: false
properties:
location_id: { type: string, format: uuid }
reason_id: { type: string, format: uuid }
lines:
type: array
minItems: 1
items:
type: object
required: [item_id, direction, quantity]
additionalProperties: false
properties:
item_id: { type: string, format: uuid }
direction: { type: string, enum: [in, out] }
quantity: { $ref: '#/components/schemas/StockQuantity' }
unit_cost: { $ref: '#/components/schemas/Money' }
lots: { type: array, items: { $ref: '#/components/schemas/StockLot' } }
StockAdjustment:
type: object
required: [id, number, state, location_id, reason_id, count_id, instance_id, lines]
properties:
id: { type: string, format: uuid }
number: { type: string }
state: { type: string, description: The workflow state key. }
location_id: { type: string, format: uuid }
reason_id: { type: string, format: uuid }
count_id: { type: string, format: uuid, nullable: true }
instance_id: { type: string, format: uuid }
lines:
type: array
items:
type: object
required: [id, item_id, direction, quantity, unit_cost, lots]
properties:
id: { type: string, format: uuid }
item_id: { type: string, format: uuid }
direction: { type: string, enum: [in, out] }
quantity: { $ref: '#/components/schemas/StockQuantity' }
unit_cost: { $ref: '#/components/schemas/Money' }
lots: { type: array, items: { $ref: '#/components/schemas/StockLot' } }
StockTransitionInput:
type: object
required: [from, to]
additionalProperties: false
properties:
from: { type: string }
to: { type: string }
reason: { type: string, maxLength: 1000 }
StockCountInput:
type: object
required: [location_id, reason_id, lines]
additionalProperties: false
properties:
location_id: { type: string, format: uuid }
reason_id: { type: string, format: uuid }
lines:
type: array
minItems: 1
items:
type: object
required: [item_id, counted]
additionalProperties: false
properties:
item_id: { type: string, format: uuid }
serial_no: { type: string }
batch_no: { type: string }
counted: { $ref: '#/components/schemas/StockQuantity' }
StockCount:
type: object
required: [id, location_id, counted_on, adjustment_id, lines]
properties:
id: { type: string, format: uuid }
location_id: { type: string, format: uuid }
counted_on: { $ref: '#/components/schemas/Date' }
adjustment_id: { type: string, format: uuid, nullable: true }
lines:
type: array
items:
type: object
required: [item_id, lot, system, counted]
properties:
item_id: { type: string, format: uuid }
lot: { type: string }
system: { $ref: '#/components/schemas/StockQuantity' }
counted: { $ref: '#/components/schemas/StockQuantity' }

(If O1 already defines a quantity schema, use it instead of StockQuantity. BadRequestBody is the shared 400 response the e-Invoice routes use.)

Run: make generate && git status --short

  • Step 6: Run the tests to verify they pass

Run: go test ./internal/modules/inventory/... ./internal/app/ ./internal/gen/ ./internal/kernel/db/ -count=1 Expected: PASS (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, the generated-code drift tests included).

  • Step 7: Verify at runtime

Start the lane’s stack (make dev-stack or the E2E lane ports the phase uses), provision an organization, sign in as its Owner, and with curl -H "Authorization: Bearer $TOKEN":

  1. GET /v1/inventory/costing-method → {"costing_method":"weighted_average","locked":false}.
  2. POST /v1/inventory/adjustments with an item O1’s POST /v1/inventory/items created, direction: "in", quantity: "5", unit_cost: "2.00", reason count (from GET /v1/inventory/adjustment-reasons) → 201, state: "draft".
  3. As a second member, POST …/transitions {"from":"draft","to":"submitted"}; as the Owner {"from":"submitted","to":"approved"} → 200, state: "approved".
  4. GET /v1/inventory/stock/on-hand → the 5; GET /v1/inventory/stock/valuation → value 10.0000; GET /v1/inventory/costing-method → locked: true; PUT it to fifo → 409 costing_method_locked.

Record the responses in the commit body.

  • Step 8: Commit
Terminal window
git add db/migrations/00727_inventory_stock_grants.sql internal/modules/inventory/ api/openapi.yaml internal/gen/ web/src/lib/api/
git commit -m "Phase 4 O2: stock routes, permissions and contract
Runtime check: <the four responses>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 13: Lock order, roadmap, the full suite

Section titled “Task 13: Lock order, roadmap, the full suite”

Files:

  • Modify: docs/LOCK_ORDER.md, docs/ROADMAP.md

Interfaces:

  • Consumes: everything above.

  • Step 1: docs/LOCK_ORDER.md

Add to the Per module table:

| Inventory stock documents (receipts, deliveries, returns, adjustments) | `trade_document` `FOR UPDATE` → on a cancel, its live lines `FOR UPDATE` in `id` order → `company_profile` `FOR SHARE` (the costing method; `company.SetCostingMethod` takes it `FOR UPDATE`) → `item_cost` rows `FOR UPDATE` in item id order, created if missing → `cost_layer` rows `FOR UPDATE` in `seq` order (FIFO) → `stock_balance` (upserted by `stock_move`'s trigger) → `workflow.Transition` (the Poster reads the moves) → audit, last. `adjustment.Create` writes its detail, direction and lot rows after `trade.Create`'s audit: INSERTs whose only locks are `FOR KEY SHARE` on rows the transaction wrote or that nothing locks `FOR UPDATE` | `inventory/apply.go` `Transition`, `inventory/costing.go` `beginStock`, `inventory/adjustment/adjustment.go` `Create` |
| Inventory transfers | `company_profile` `FOR SHARE` → the item's `item_cost` `FOR UPDATE` → `stock_balance` (trigger) → audit | `inventory/transfer.go` `TransferStock` |
  • Step 2: docs/ROADMAP.md

Under Phase 4 — Operations, tick the inventory item once O1 has landed (items, multi-UOM and locations are O1’s; transfers, FIFO and weighted average costing, serial/batch and posting through the engine are this plan’s):

- [x] Inventory: items, multi-UOM, multi-location, transfers, FIFO and weighted average
costing, serial/batch. **Costing posts through the posting engine.** (O1 + O2: `effect` semantic,
`grni`, `stock_valuation` integrity check; web UI for stock is a later lane, R-O2-19)
  • Step 3: The full suite

Run: make generate && git status --short && go vet ./... && go test -p 2 -count=1 ./... && pnpm -C web typecheck && pnpm -C web test Expected: tree clean after generate; every package PASS.

  • Step 4: Commit
Terminal window
git add docs/LOCK_ORDER.md docs/ROADMAP.md
git commit -m "Phase 4 O2: lock order and roadmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Spec Where
O2.1 stock_move append-only (no UPDATE/DELETE for the app role) Task 3 (00722 REVOKE, test TestOnlyAMoveWritesTheBalanceAndNoMoveIsEdited)
O2.1 item, location, signed base qty, unit and total cost NUMERIC(19,4), move date, serial/batch, source Task 3 schema; source is the trade line or transfer (R-O2-2: no journal_entry_id)
O2.1 move date is clock.Today, no back-dating, correction by reversing move insertMove; reverse (Task 6)
O2.1 stock_balance in the same transaction under a row lock, CHECK (on_hand >= 0) 00722 trigger upsert; TestStockCannotGoBelowZero
O2.1 transfers: two moves, no workflow, no entry, cost unchanged Task 8
O2.2 company.costing_method, trigger once a move exists Task 4
O2.2 cost per item across the organization item_cost (Task 6), zero-cost transfers
O2.2 FIFO layers, oldest first consumeLayers; TestFIFOIssuesTheOldestLayersFirst
O2.2 weighted average, rounded to 4, residue to the emptying issue costOut; TestWeightedAverageIssuesAtTheAverage…
O2.3 receipt: accrual, grni, Dr Inventory / Expense, Cr GRNI Tasks 2, 5, 7
O2.3 purchase invoice on receipt lines (GRNI/AP, PPV, FX) O3 (R-O2-8); O2 provides grni, PPV role, ReceivedValue
O2.3 delivery: effect, Dr COGS / Cr Inventory at cost Tasks 1, 7
O2.3 adjustment: effect, Inventory against the reason’s account Tasks 7, 9
O2.3 returns and cancellations: reversal path, reversing moves Task 6 reverse, Task 7 cancel test; returns post their own entries (R-O2-15)
O2.3 provisioning: GRNI, Inventory, COGS, PPV, Stock adjustment Task 2 step
O2.4 serial unique per item, batch with expiry, refused by the database 00722 guard and partial unique index; stock_batch; Task 3 tests
O2.4 stock-take → stock_adjustment Task 9 RecordCount
O1’s hand-offs: item_fixed_once_used() extended to stock_move; stock kinds’ graphs (O1-14); live-children rule on cancel Task 3 (00722), Task 5, Task 6 Transition
O2.5 valuation = GL: a test and an operator console check Task 10 (reconcile check → opsjobs → console; RUNBOOK §7.3)
O3.3 stock on hand, movement, valuation reports Task 11, Task 12 routes (R-O2-14)
Drift tests: balance = Σ moves; negative refused; costing lock; valuation = GL; new tables tenant-isolated RequireBalancesAddUp, Task 3, Task 4, Task 10, the existing RLS/FK/constraint-name tests

Gaps deliberately left, each a line: no web UI (R-O2-19); no upgrade step for O1 placeholder graphs on already-provisioned organizations (add one, like receivables’ sales_graphs_v2, if O1’s placeholders reached a real organization); FIFO residue is exercised through the weighted-average test and RequireCostsAddUp rather than a dedicated FIFO rounding case; one item per transfer; adjustments carry no analytic shares (an organization that requires a cost centre on expense accounts must add shares when O3 or a later lane exposes them).