Arc Forge Labs Docs v2026.10.25

Pricing engine v2

The target data model for Field's price book, quotes and invoices, and the order in which we get there. It replaces the earlier catalogue and quote design notes. The schema comes first; the UI is rebuilt on it, on Field's design system (DESIGN.md).

Status: in progress. Phase 0b (tenancy) and phase 3 step 2 (issued documents, see "Phase 3, step by step") are built, and so are legacy codes as aliases (item_code_aliases, code retirement step 2) and the first part of phase 1 (kit_components, items.type, price() and the price-equality gate; see "Phases"), and quote rows carry the uid next to every code (code retirement step 3, a boot data migration); nothing else here is built yet unless it says so. Pricing configuration names items by uid with the code as fallback (step 4). The price book clean-up (step 5) is a boot data migration behind the price-equality gate, and MCP answers carry no item codes (step 6; HTTP on request, by default once the app stops reading them). Phase 3 step 3 parts one and two are built too: every quote version is also written as quotes, quote_sections and quote_lines rows and checked against what Field shows, and the quote PDF, the quote email's totals, the job's totals and the API and MCP customer pricing are read from those rows, while the files are still written alongside. Phase 3 step 4a is built too (#27): every job's payment schedule, milestone invoices, payments and receipts are also written as rows and checked against what was issued. Step 4b parts one and two are built too (#28): invoices are issued from the issue service, with INV-YYYY-M-N numbers allocated in the issue transaction, and an issued invoice is corrected by a credit note and a new invoice (see "Phase 3, step by step"). Step 4c is built too (#29): milestone defaults and caps come from the organisation's milestone rules, as rows ("Invoices and payments").

The owner's six decisions of 2026-10-12 (from the document audit) are recorded where they change the design: credit notes ("Invoices and payments"), invoice numbers ("Identity"), document bytes and issue versus delivery ("document_events and issued_documents"), claim-line invoices and milestone rules ("Invoices and payments"), and the order of work ("Phase 3, step by step").

Principles

  1. The database is the engine. Prices, kits, quotes and invoices are PostgreSQL rows with keys, constraints and history. No pricing lives in files (quote-catalogs/*.json5, quote.json, rate-card.json) or in code (rule modes, hardcoded recipes, rate cards).
  2. Standard trade-estimating model, nothing clever. Price book → kits → quote lines → invoices, the way Fergus, simPRO, Tradify, ServiceM8 and Xero do it. Field drops everything that existed to turn a voice recording into a quote: rules, layers, compiled line items, attestation, transcripts and evidence packs.
  3. One way to compute a price. Every number on screen, in a PDF or over the API comes from the same function over the same rows.
  4. History is append-only. A price change is a new row, never an edit. What a quote said when it was sent is never recomputed.
  5. Freeze by value, link by id. A quote line copies the price it used and keeps the id of the item and price row it came from, so a draft can show what changed and a sent quote never moves.
  6. Fail closed. A missing, archived or unpriced item makes the line unpriced. It never becomes zero and never falls back to a default.

Conventions

These apply to every table in this document.

Names

  • Tables are snake_case plural nouns: items, item_prices, quote_lines. A join or child table is <parent>_<child>: kit_components.
  • Columns are snake_case. A foreign key is <table singular>_uid (item_uid, quote_uid). Booleans read as statements: is_sold, is_default. Timestamps end in _at and are timestamptz. Dates with no time end in _on (valid_until_on).
  • Enumerations are text with a CHECK constraint, not Postgres enums, so adding a value is a plain migration.
  • Money columns say what they are: unit_cost, unit_sell, line_total, subtotal, tax_total, total. Never price on its own.

Tenancy: one database, many organisations

Field is built to serve more than one business from one PostgreSQL database. Every tenant-owned table carries org_uid (FK organizations, org_…), and isolation is enforced by the database, not by remembering a WHERE:

  • Row-level security on every tenant table: USING (org_uid = (select field_org_uid())) and the same WITH CHECK, with FORCE ROW LEVEL SECURITY so the table owner is filtered too. field_org_uid() returns current_setting('field.org_uid') and raises when it is unset, so a query outside an organisation transaction fails instead of quietly seeing nothing; it is also the org_uid default. Each request runs in a transaction that starts with SET LOCAL ROLE field_app (a role without BYPASSRLS or DDL rights) and SET LOCAL field.org_uid from the signed-in user's membership. A query that forgets its filter sees nothing from other organisations, and an insert into the wrong one is refused. Boot refuses a database role on which this would not hold (server/db/README.md "Tenancy and row-level security").
  • Uniques are per organisation: (org_uid, lower(code)), (org_uid, number, version). Two businesses can both have MAT-GRAVEL-20MM and quote EQ-2501-01.
  • Foreign keys stay inside an organisation. Child tables carry org_uid too, and composite keys ((org_uid, item_uid)) make a cross-tenant link impossible.
  • Shared reference data has no org_uid: units, unit_aliases. tax_codes is per organisation, because tax depends on the country.
  • organizations (uid, name, slug, clerk_org_id, status, settings) is the directory and has no RLS. users is the person; org_memberships (org_uid, user_uid, role: owner, staff; status: active, pending, disabled) is their place in an organisation, so one person can belong to several. business_settings becomes one row per organisation. Clerk Organizations map one-to-one onto organizations.
  • Blobs (photos, PDFs) are scoped the same way: job_files and issued_documents carry org_uid, so a document is only reachable through its owner's rows.
  • The default organisation (DEFAULT_ORG_UID, created by migration 0005_tenancy) is the first organisation. Its slug is default on a database created after 0020_neutral_default_org, and stays as it was on an older one. Existing rows were backfilled with its org_uid in phase 0b (migration 0005_tenancy).
  • Until jobs are rows (phase 3b), a deployment serves one organisation (FIELD_ORG, else the default organisation): the data directory is not per organisation, so a signed-in person from another organisation is refused. The database layer is already multi-organisation.
  • Not schema-per-tenant or database-per-tenant: those multiply every migration, backup and cross-tenant admin task, for isolation that RLS already gives at this scale.

Identity: uid, code, number

Every row that something else points at has a uid: an opaque, immutable, prefixed ULID (itm_01JB8Z…), generated by the server, never shown as a label, never reused. It is the primary key and the only thing foreign keys reference.

Prefix Entity
itm_ item (material, labour, equipment, subcontract, fee, kit)
prc_ item price row
cat_ category
sup_ supplier
imp_ supplier price import
qte_ quote
qsc_ quote section
qln_ quote line
inv_ invoice
iln_ invoice line

Existing item uids are kept as they are; only new rows get prefixed ULIDs.

Direction change, 2026-10-12: items have a name the operator chooses and a uid the system keeps; there is no second naming scheme. The price book no longer shows or asks for codes. Existing codes are legacy aliases (item_code_aliases, migration 0006): every code an item has had resolves to it, and codes no longer change. They stay until everything that looks items up by code resolves by uid (code retirement, steps 2 to 6 in "Status" above). The TYPE-THING-SPEC format below is superseded and kept only as the record of what was proposed.

A code is the human SKU an estimator types and reads: unique (case-insensitive), editable, and shown everywhere an item is listed. Renaming a code never breaks anything, because nothing links by code.

  • Format: upper case letters, digits and single hyphens, ^[A-Z0-9]+(-[A-Z0-9]+)*$, 3–32 characters. Enforced by a CHECK.

  • Shape: <TYPE>-<THING>[-<SPEC>], where the type prefix comes from the item's type:

    Type Prefix Examples
    material MAT MAT-GRAVEL-20MM, MAT-SLEEPER-KENSINGTON-200
    labour LAB LAB-GENERAL, LAB-OPERATOR
    equipment EQP EQP-EXCAVATOR-5T, EQP-COMPACTOR-500KG
    subcontract SUB SUB-PLUMBER, SUB-CONCRETE-PUMP
    fee FEE FEE-ADMIN, FEE-DISPOSAL-MIN
    kit KIT KIT-WALL-SLEEPER-1000, KIT-GRAVEL-SUPPLY-INSTALL
  • The unit is not part of the code (EQP-EXCAVATOR-5T, not EXCAVATOR-5T-HOUR). The item's unit says how it is sold.

  • No generated codes (cat_143d45ed…) and no -copy codes. A variant is its own item with its own code and a variant_of_uid link.

A number is a customer-facing document identifier, allocated once, never reused, from a per-type sequence: quotes EQ-YYMM-NN (as today). Revisions of a quote keep its number and add a version (EQ-2501-03 v2).

Decision, 2026-10-12: invoice numbers are INV-YYYY-M-N, from one sequence per organisation, allocated when the invoice is issued (document_number_counters). An invoice connected to a job carries the job number as a required reference. Numbers already issued (EI-…) stay as they are. Decided 2026-10-13: the month is not zero-padded: January 2026 is INV-2026-1-1, October INV-2026-10-2. Numbers are ordered by their period and sequence, never as text (compareDocumentNumbers, server/invoices/numbers.js). Built (#28, step 4b part one): see "How step 4b issues invoices".

Numbers, money and rounding

What Column type Notes
Unit cost numeric(12,4) Costs per small unit (per m, per kg) need more than cents.
Unit sell on a quote line numeric(12,4) Four decimals, so a historical line keeps the rate it was priced at. A line added from now on takes the rate rounded half-up to cents, so qty × rate always gives its total.
Line total numeric(12,2) The line's official amount. Round(qty × unit sell, 2), or for a line frozen from a document already issued, the amount it printed (see below).
Subtotal, tax, total numeric(12,2) Subtotal = sum of line totals. Tax = round(subtotal × rate, 2), once per tax rate, not per line. Total = subtotal + tax.
Quantity numeric(14,4) Displayed trimmed (12, 2.5, 0.125).
Markup, margin numeric(7,3) percent 25 means 25 %.
Conversion factor numeric(14,6)

Decisions, 2026-10-12:

  • GST is worked out once on the subtotal, not per line. Every quote Field has sent did it this way, so the rows reproduce what customers received. With one rate (all GST at 10 % today) that is round(subtotal × 10 %, 2); with several, once per rate group. Per-line GST could differ by a cent or more (three lines of $10.05: $3.02 on the subtotal, $3.03 per line).
  • The line total frozen as printed is the official amount. Quote lines carry their rate to four decimals, and a historical version reproduces exactly. A line whose qty × rate does not give the total it printed (a three-decimal catalogue sell the PDF rounded differently, a fixed price no whole-cent rate reaches) keeps that total, marked line_total_source = 'printed' with the reason in line_total_reason. Every other line is computed, and the database checks that it equals round(qty × rate, 2). Rounding catalogue sells with more than two decimals is a separate catalogue decision; catalogue data is not changed.
  • One currency (AUD), held on the quote and invoice row, so the model does not have to change if another is ever needed.
  • All amounts are stored excluding GST. Tax comes from the line's tax code.
  • Display rule for every screen and PDF: money shows 2 decimals; unit cost may show 4 only in the item editor's cost field. The grid shows $1,654.56, never $1,654.5632.
  • Markup is (sell − cost) ÷ cost. Margin is (sell − cost) ÷ sell. The grid labels them exactly that way and never mixes them.

Lifecycle and history

  • Rows that something can point at are never hard-deleted once used. They have status (active, archived, and draft where it makes sense) and archived_at. Unused rows can be deleted.
  • Every table has created_at and created_by. Mutable tables also have updated_at, updated_by and an integer version for optimistic concurrency (expectedVersion / If-Match).
  • audit_log records field-level changes to every catalogue and document table (who, when, old → new, reason). It replaces catalog_history.

Catalogue

erDiagram
  categories ||--o{ items : groups
  categories ||--o{ categories : "parent of"
  units ||--o{ items : "sold in"
  tax_codes ||--o{ items : "taxed as"
  items ||--o{ item_prices : "priced by"
  items ||--o{ kit_components : "kit has"
  items ||--o{ kit_components : "used in"
  items ||--o{ item_unit_conversions : converts
  items ||--o{ item_suppliers : "bought from"
  suppliers ||--o{ item_suppliers : supplies
  suppliers ||--o{ price_imports : sends
  price_imports ||--o{ price_import_rows : contains
  items |o--o{ items : "variant of"

items

One row per thing the business prices.

Column Type Notes
uid text PK itm_…
code text, unique lower() See Codes
name text What estimators call it: "20mm gravel"
description text null Customer-facing wording used on quotes when set
notes text null Internal notes for estimators; never printed (built, migration 0007)
type text material, labour, equipment, subcontract, fee, kit
category_uid FK categories The trade or work section: required
unit_code FK units How it is sold
tax_code FK tax_codes Default GST
pricing_method text markup, fixed or rollup (kits only). See Pricing
is_sold boolean Shown in the quote picker. Internal components are false
variant_of_uid FK items null Replaces -copy
status text active, draft, archived
version, timestamps, *_by

Dropped from today's table: kind (becomes type, 0 of 174 set today), visibility (becomes is_sold), free-text category (becomes category_uid), tags (0 used), price_reference, unit_label (alias text moves to unit_aliases display), cost_mode and sell_mode (become pricing_method), pricing_basis (already an item_unit_conversions row).

categories

The work sections a quote is grouped by, not what an item is made of. What it is (material, labour, equipment) is items.type, so the two never compete for the same field the way "Materials" and "Walls" do today.

Column Type Notes
uid text PK cat_…
name text, unique lower() "Retaining walls"
parent_uid FK null At most two levels
sort_order int Order on quotes and in the grid
status text active, archived

Proposed starting set (for the operator to confirm): Site & admin, Earthworks, Retaining walls, Drainage, Concrete & paving, Landscaping (Lawn, Irrigation, Planting), Demolition & disposal, Haulage.

units, unit_aliases, item_unit_conversions, tax_codes

Kept as built: typed units with a dimension and base factor, aliases for input, item-specific conversions (1 m³ gravel = 1.6 t), and tax codes (GST 10 %, GST_FREE, INPUT_TAXED).

item_prices (price history)

Append-only and effective-dated: kept as built, with names tightened.

Column Type Notes
uid text PK prc_… (today an identity id; quote lines record it)
item_uid FK items
unit_cost numeric(12,4) null Null for a rollup kit
markup_pct numeric(7,3) null Used when pricing_method = markup
unit_sell numeric(12,2) null Used when pricing_method = fixed
effective_from timestamptz
source text manual, import, bulk, migration
source_ref text null Import or bulk batch uid
reason text null
created_at, created_by
  • The current price of an item is its latest row with effective_from <= now() (ties: latest created_at). A future-dated row is a scheduled price.
  • Rows are never updated or deleted by the app. A grant-level rule (REVOKE UPDATE, DELETE) or trigger enforces it.
  • The price at any past moment is a query, not a reconstruction: price_at(item_uid, at). A kit's past price is its components' prices at that moment.

Pricing

One function computes every price: price(item, at) → { unit_cost, unit_sell, markup_pct, margin_pct, price_uid, problems }.

pricing_method Cost Sell
markup the price row's unit_cost round(cost × (1 + markup_pct/100), 2)
fixed the price row's unit_cost the price row's unit_sell
rollup (kits) Σ component qty × component cost Σ component qty × component sell, rounded to cents

A kit may also be markup (sell = rolled-up cost × markup) or fixed (a set sell price, cost still rolled up). Its cost always rolls up.

Each component counts in its own unit, converted to the component item's unit through units or item_unit_conversions. A conversion that does not exist is a save-time error (UNIT_MISMATCH), not a pricing-time surprise. An archived, missing or unpriced component makes the kit unpriced, never zero.

kit_components

One table for kits. It replaces item_components and the five empty assembly_* tables, and recipes move here out of quote-catalogs/default.json5.

Column Type Notes
kit_uid FK items (type = kit)
component_uid FK items, restrict Any type, including another kit
quantity numeric(14,4) Per one unit of the kit: a kit sold per m² lists what one m² needs
unit_code FK units The unit the quantity is in
waste_pct numeric(7,3) default 0
show_on_quote boolean Whether the component appears under the kit line on the customer document
sort_order int
  • No cycles (checked on save), no self-reference, and at most one row per component per kit.
  • Measured assemblies with formulas (ceil(length / 2.4)) are not part of v1. Kits priced per unit cover hand-built quotes, and the expression language stays available if a real case needs it. The retaining-wall engine is covered under Quotes.

Suppliers and imports

Kept as built: suppliers, item_suppliers (supplier code, pack quantity, default supplier) and staged price_imports / price_import_rows with a diff before apply. They are empty today, so the UI hides them until a supplier exists rather than showing empty columns.

Quotes and invoices

Today a quote is about a dozen files per job. quote.json is built by the voice-recording engine, even for a hand-built quote: createManualJob writes a stand-in transcript and evidence pack, and every save reruns the full build. Prices on a row can come from five override layers. Totals and GST are computed in seven places, and GST comes from a json5 file rather than the item's tax code. v2 makes quotes and invoices ordinary rows.

erDiagram
  quotes ||--o{ quote_sections : "grouped into"
  quotes ||--o{ quote_lines : contains
  quote_sections ||--o{ quote_lines : holds
  quote_lines ||--o{ quote_lines : "kit shows"
  items |o--o{ quote_lines : "priced from"
  item_prices |o--o{ quote_lines : "frozen from"
  quotes ||--o{ payment_milestones : "paid by"
  quotes ||--o{ invoices : "billed by"
  invoices ||--o{ invoice_lines : contains
  invoices ||--o{ payments : "settled by"
  quotes ||--o{ document_events : "history"
  invoices ||--o{ document_events : "history"
  quotes ||--o{ issued_documents : "sent as"
  invoices ||--o{ issued_documents : "sent as"

quotes

One row per quote version. A revision is a new row with the same number and the next version. The previous row becomes superseded.

Column Type Notes
uid text PK qte_…
job_id text The job (jobs stay in the job store for now)
number, version text, int EQ-2501-03, 2. Unique together
status text draft, sent, accepted, declined, superseded, void
title, scope_text, terms_text text Customer-facing wording
customer jsonb Name, address, email and phone, frozen at send
currency text AUD
valid_until_on date
subtotal, tax_total, total numeric(12,2) Computed by the one totals function. Stored when sent
tax_total_source, tax_total_reason text computed, or printed with the reason (below)
catalog_version bigint The catalogue version the lines were last priced against
sent_at, accepted_at timestamptz
version_lock int Optimistic concurrency (named so it doesn't clash with version)

Built (#6, migration 0010_quote_rows), with these differences:

  • A version is unique per job, (org_uid, job_id, version), not per number. The audit found quote numbers that collide across jobs, so (number, version) is only indexed until numbers come from document_number_counters. The backfill lists any number two jobs share.
  • issued_document_uid links the issued document the version was sent as. source_digest is a hash of the files the rows were mapped from, so an unchanged version is not rewritten.
  • terms_text and catalog_version are null for rows mapped from files: terms print from the business settings, and quote.json does not record the catalogue version.
  • Totals are stored on every version, not only when sent, and are always quoteTotals over the lines being written.
  • GST as printed, in one case only. Until GST was rounded exactly (#2), the renderer worked it out in binary floating point. When the exact tax falls on half a cent ($21.15 at 10 % is $2.115), a PDF could print a cent less than exact rounding ($2.11, not $2.12): 3,277 of the first 2,000,000 possible subtotals. Field now shows $2.12 for such a quote. A sent version whose sent copy printed the lower GST keeps it, with tax_total_source = 'printed' and the reason; its subtotal must still equal what Field shows. The service refuses a printed GST in any other case, and new quotes always compute it.
  • Sent never changes, in the database. A quote is frozen once it is sent, accepted or declined, or has a sent_at. Triggers then refuse inserting, updating or deleting its sections and lines, changing its number, money, customer or wording, setting it back to draft, replacing its issued document, and deleting it, for the table owner too. Its status may still move on (superseded, accepted, void).

quote_sections

The groups the customer sees ("Retaining wall", "Drainage"). This replaces organizer group nodes. Sections are flat, ordered and optionally subtotalled.

uid (qsc_…), quote_uid, title, sort_order, show_subtotal (built, with source_node_id, the organizer group it came from).

quote_lines

Every line stores the values it prints, whether or not it came from the catalogue.

Column Type Notes
uid text PK qln_…
quote_uid, section_uid FK
parent_line_uid FK null A kit component shown under its kit line
sort_order int
line_type text item (from the catalogue), custom (typed in), component (under a kit), note (text only)
item_uid FK items null The catalogue item, if any
price_uid FK item_prices null The price row used. Null for a rollup kit (see price_key)
price_key text null Changes when anything behind a kit's price changes, so a draft can tell the kit moved
code, name, description text Frozen copies. The description prints
quantity numeric(14,4)
unit_code FK units
unit_cost, unit_sell numeric(12,4), (12,4) Copied from the catalogue when added (the sell rounded to cents), or typed
is_price_override boolean The estimator changed the catalogue's sell. override_reason text
tax_code, tax_rate text, numeric From the item. Frozen
line_total numeric(12,2) The official amount: round(qty × unit_sell, 2), or as printed
line_total_source, line_total_reason text computed (checked by the database), or printed with the reason
is_optional, is_selected boolean For options the customer can choose. Unused today, so it can come later

Rules:

  • Add from catalogue: the server copies the item's current price into the line and records item_uid, price_uid / price_key. A kit can go on as one line, or as one line with component children for display. The kit line carries the price either way, so component lines never add to the total twice.
  • Draft refresh: the server compares each line's price_uid / price_key with the item's current price and lists the lines that changed (old → new). The estimator applies all, some or none. Lines with is_price_override are listed but never changed silently. This replaces the organizer's catalogue diff, and closes the gap where a kit's frozen price went stale, by construction.
  • Totals: one function, quoteTotals(lines). It sums line_total over priced, selected lines for the subtotal, works GST out once on that subtotal (round(subtotal × rate, 2) per tax rate, not per line), and adds them for total. The PDF, the email, the list view and the API all read it. There is no per-line tax: it would not add up to the GST charged.
  • New lines round their rate. A line added from now on takes the rate rounded to cents, so qty × rate always gives its total and the line is computed. Only lines frozen from documents already issued are printed.
  • Send freezes. Sending sets status = sent and stores the totals. After that, a trigger refuses any UPDATE or DELETE on the quote's lines and sections. To change a sent quote, create a new version, which copies its lines into a new draft. Sending needs no lineage or attestation check: every line already holds its own frozen price, so a quote can no longer be refused for missing line-item lineage.

Built (#6), with these differences:

  • section_uid is null for a line outside any section (a top-level row). parent_line_uid also puts a row under a note heading, not only a component under its kit.
  • is_optional and is_selected are built, because today's "listed only" rows are exactly that: printed with a price, not charged.
  • item_uid, price_uid and price_key are not foreign keys. The catalogue can still delete an item a quote used, and a sent line must keep naming it. price_uid holds today's item_prices id.
  • unit_code is text, not a foreign key: quote rows carry units the units table may not have.
  • tax_code is GST at the quote's rate: today one rate (the catalogue file's gstRate) applies to the whole quote.
  • line_tax is not built: GST is worked out on the subtotal (decided 2026-10-12), so a per-line tax would not add up to what was charged.
  • line_total_source and line_total_reason record how a line's total was set (see "Numbers, money and rounding").

Invoices and payments

Milestone invoicing is used on 51 of 65 jobs, so it gets proper tables.

Decisions, 2026-10-12:

  • An issued invoice is never revised in place. It is corrected with a credit (adjustment) note against it, then a new invoice. Today's in-place revisions (issuedRevision, revisionHistory, the -R2 and -R3 numbers, reviseJobMilestoneInvoice) migrate as a credit note plus a new invoice (step 4b).
  • Numbers: INV-YYYY-M-N, one sequence per organisation, allocated in the issue transaction, with the job number as a required reference (see "Identity"). Numbers already issued (EI-…-M<n>, -R<k>, ER-… receipts) stay exactly as issued.
  • Bytes go in document_blobs, with the R2 replica (#12).
  • A document is issued when it is finalised. Email is delivery, tracked separately (document_events).
  • Milestone invoices show claim lines, one per milestone, each referencing the quote ("Deposit, 50 % of EQ-2501-03 v2"), not the full quote schedule.
  • Milestone defaults come from account rules by contract value, entered as data and never hard-coded, with per-job overrides that are visibly marked. A rule can cap a milestone, and each cap either warns or blocks issuing. An example rule set for a South Australian builder: a deposit of at most $1,000 under a $20,000 contract value, and at most 5 % from $20,000.
  • GST is worked out once on the subtotal per tax rate, in integer cents, half up (shared/money.js taxOnSubtotal). Money columns follow "Numbers, money and rounding".
  • payment_milestones: uid, quote_uid, label ("Deposit", "On completion"), percent or amount, sort_order. Defaults come from the account's rules.
  • invoices: uid (inv_…), number (INV-YYYY-M-N), quote_uid, job_id (the required reference), milestone_uid null, kind (deposit, progress, final, variation), status (draft, issued, paid, void), issued_on, due_on, subtotal, tax_total, total, amount_paid. An issued invoice is frozen the same way a sent quote is, and is corrected only by a credit note.
  • credit_notes: an adjustment against one issued invoice, with its own number, lines and issued document (document_type credit_note).
  • invoice_lines: the same shape as quote_lines (iln_…). A milestone invoice has claim lines, one per milestone ("40 % deposit on EQ-2501-03 v2"), each pointing at the quote.
  • payments: uid, invoice_uid, amount, paid_on, method, reference. Receipts are generated from payments.

Built (step 4a, #27, migration 0013_invoice_rows), with these differences:

  • payment_milestones belongs to the job (job_id, sort_order = today's milestone index) and points at the quote version it bills (quote_uid, null while that version has no rows). It keeps due_days, and is_default_schedule is ready for step 4c. Nothing today is known to come from account rules, so it is false.

  • invoices keep their numbers as issued (EI-…-M<n>, EI-…-M<n>-R<k>). INV-YYYY-M-N arrives with step 4b. status also has credited: a revision that was superseded in place is its own row, credited, and its successor names it in supersedes_invoice_uid, until step 4b adds the credit note that corrects it. A revision returned to draft (unsent, or marked unpaid) is void; re-issuing the same content restores it under the same number. revision, quote_version (with quote_version_inferred for an invoice from before revisions were tracked), issued_at, issued_document_uid and reference (the job number; required from step 4b) are columns. tax_total_source works as on quotes: a milestone's GST is its share of the quote's GST, so where that differs from GST on its own subtotal the invoice keeps the GST it was issued with, printed, with the reason.

  • invoice_lines hold what the invoice PDF claims: one claim line, the milestone's share of the quote ex GST, against the quote. Since step 4b that is all the PDF claims; invoices issued earlier also printed the quote's schedule, which is the quote's rows.

  • credit_notes and credit_note_lines exist with their checks and triggers. Step 4b writes them (see "How step 4b credits an invoice").

  • payments belong to the invoice they were recorded against (sourceInvoiceNumber), keep voided_at, void_reason and voided_by, and are never deleted. A payment the files drop ("mark unpaid" clears them) is voided. An invoice's amount_paid is the sum of its active payments.

  • receipts are one per payment. Today a receipt is issued for a milestone once it is paid in full. Its row is for the payment that cleared it, with the amount it acknowledges, and is voided if that payment is. Built (step 4c, #29, migration 0022_milestone_rules): milestone rules. The owner's decisions (2026-10-13): rules are settings, never code, so Field gives no legal or business advice; a rule set for a region is the organisation's and only an owner changes it, in Settings; a job's own schedule is an override; each cap is hard (blocks issuing) or soft (alerts).

  • milestone_rules: one row per band of contract value in one region: country (ISO 3166-1, null for every region) and region (a state or province, null for the whole country), min_value (inclusive) and max_value (exclusive, null for no upper end), value_basis (inc-gst, the default, or ex-gst), name, note (why, shown in cap messages) and sort_order. Two bands in one region may not overlap. milestone_rule_milestones are its default milestones (label, percent or amount, due days); none means Field's default schedule. milestone_rule_caps are at most one per milestone kind (deposit, progress, final, variation, as invoiceKind names an invoice): max_percent of the contract value and/or max_amount, the lower applying, and mode hard or soft. business_settings gains country and region.

  • Which rule. A job's region is its site address when that names a state (shared/regions.js), else the business region (Settings, else the business address). Of the rules whose band holds the contract value (the quote total with or without GST, as the rule says), the most specific region's applies: the state, then the country, then every region.

  • When it applies. The first time a job's milestones are created (the job lands, or "Set up invoices") with no schedule of its own, the rule's milestones become its schedule, marked source.kind: "rule" beside it (and payment_milestones.is_default_schedule). A schedule set on the job (the quote editor, generate, MCP patch.paymentSchedule) is an override, source.kind: "override", naming the rule it departs from; applyAccountRule puts the rule's schedule back. A schedule from before rules, or with none, has no source and is never re-marked.

  • Caps at issue. Before an invoice is issued (authorised, sent, credited and reissued), the caps of the rule that applies to the job now are checked against what it issues. A hard cap refuses the issue, before any number is allocated, with a message that names the rule and what it allows (409 MILESTONE_CAP_EXCEEDED, with findings). A soft cap issues it, and the alert is kept with the invoice: on its revision in the files (milestoneRule, with whether the schedule was the rule's or an override) and in its issued document's details. GET /api/jobs/:id/milestone-rules shows all of it before issue. An invoice already issued keeps what it was issued with: rules that change later never touch it.

  • Issued never changes, in the database. Once an invoice or credit note has an issue time, triggers refuse any change to its number, money, lines or issue time, setting it back to draft, and deleting it, for the table owner too. Its status, amount paid, due date, kind and note may change, and links missing when it was written are filled once. Payments may only be voided, once; receipts may only be voided. TRUNCATE is always refused.

document_events and issued_documents

Built (migration 0009_issued_documents). Every document a customer was issued has its own uid, its exact bytes and an append-only history. In this step the job files are still the source of truth: the tables are filled alongside them and read only by the by-uid routes.

  • document_blobs: the bytes. sha256 (primary key per organisation), byte_length, content_type, bytes (bytea), created_at. The database checks that the hash matches the bytes. This is not durable storage's field_blobs: that table mirrors the data directory, has no organisation and is retired in phase 3b.
  • issued_documents: one row per document as issued. uid (doc_…), job_id, document_type (quote, invoice, receipt, credit_note), number as printed (EQ-2501-01 v1, EI-2501-01-M1, EI-2501-01-M1-R2, ER-2501-01-M1; a quote's number carries its version, because the quote number alone is shared by every version), quote_version, revision, issued_at, issued_by, pdf_sha256 (a foreign key into document_blobs), source (live, ledger-backfill, invoice-revision-backfill, legacy-sent-attachment, legacy-stored-version) and details (frozen business, customer and money details where they were known). (org_uid, document_type, number) is unique.
  • document_events: uid (dev_…), issued_document_uid (null for an event with no issued document, such as an email that failed before its document was issued), job_id, document_type, event (created, issued, emailed, email_failed, paid, voided, credited), at, by, data (recipients, Gmail message and thread ids, the delivery attempt) and an idempotency_key, so a retried write adds nothing.
  • document_number_counters: (org_uid, document_type, period) and last_value. The invoice issue service advances the invoice counter (period YYYY-MM, zero-padded so periods compare in order; the printed number drops the zero) inside the issue transaction (step 4b). Quotes and receipts do not use it yet.

Rules:

  • Issued once, never changed. Triggers refuse UPDATE, DELETE and TRUNCATE on blobs and events, and on every issued_documents column except details, for the table owner too. field_app is granted no more.
  • A document is issued when it is finalised; email is delivery (decided 2026-10-12). The issued event and the emailed event are separate facts. Step 2 records both and does not change when today's code considers something issued: a quote is issued when it is emailed, an invoice revision when it is frozen. Since step 4b an invoice is issued at finalisation by the issue service, and its emails are emailed or email_failed events that carry the hash of the bytes sent. Quotes follow with documents built from rows.
  • Same number, same bytes, or refused. Recording a number again with the same bytes is a no-op. A resend that renders the PDF again keeps the issued document as it was and stores the bytes that were sent with its emailed event (bytesMatchIssued: false).
  • The byte store is an interface. server/documents/blob-store.js is the only code that touches document_blobs, through put(bytes) → sha256 and get(sha256) → bytes.
  • A locked R2 replica (#12, built, migration 0012_document_replicas). PostgreSQL stays the store of record. With FIELD_DOCUMENT_REPLICA=r2, each blob is copied after its issue transaction commits to a Cloudflare R2 bucket with a bucket lock, at <org_uid>/<sha256> (server/documents/replicator.js). The copy is create-only: HEAD first, an object with the same size and hash counts as done, different bytes are an integrity alarm, never an overwrite. A scheduler tick retries with backoff and backfills older blobs a few a minute. document_blob_replicas records r2_replicated_at, attempts and the last error code per blob. Reads stay on PostgreSQL and fall back to R2, hash-checked, only when PostgreSQL cannot give the bytes. npm run documents:verify-r2 checks every issued document is in the bucket with its hash. Operating it: docs/REPLIT.md "Issued documents in R2".

These tables replace quote-issuance.json and its issued-documents/ folder, the issued-PDF fields on milestone-invoices/state.json, the legacy sent-quote lookup, and later quote-lifecycle.json, sent-emails.jsonl and the quote_snapshots table. How they are filled today, and what moves next, is under Phases.

Business settings

business_settings (one row) holds the business name, ABN, bank details, default terms, quote validity days and numbering prefixes. Built so far (migration 0016_business_settings, #28): one row per organisation with credit_note_on_reissue (see "How step 4b credits an invoice"); the rest is still config. All of these come out of quote-catalogs/default.json5, whose gstRate is replaced by item tax codes.

Everything else moves to tables too

Quotes are not the only thing kept as files. Jobs, invoices, logs, the email outbox and tokens live as JSON files in the data directory, with a SQLite field.db as the job index and notes store. Since the Replit cutover all of it is mirrored into PostgreSQL as opaque blobs (field_files, field_sqlite_images). That makes the files durable, but they still can't be queried, have no foreign keys, are saved several files at a time rather than in one transaction, and have no field-level history. The end state is every record a row, and files only for documents.

Today (file) v2 table
meta.json (job, customer and contact fields, stage, cached totals) jobs, customers, contacts. Totals are read from quotes, never cached on the job
field.db jobs index dropped: jobs is the index
field.db notes_ledger, structured-notes.json, notes.json job_notes
job-state/activity.jsonl, events.jsonl, details.json, gaps.json, tasks.json job_events (activity), with the remaining fields on jobs
quote*.json, quote-versions/, quote-lifecycle.json, quote-issuance.json quotes, quote_sections, quote_lines, document_events, issued_documents (above)
milestone-invoices/state.json payment_milestones, invoices, invoice_lines, payments (above)
sent-emails.jsonl, quote-delivery-outbox.json, scheduled emails email_messages (one row per email: status scheduled, sending, sent, failed; recipients, Gmail ids), linked to document_events
email-templates.json email_templates
gmail-oauth-users.json oauth_tokens, with the refresh token encrypted at rest
users.json users (done, migration 0002_field_users)
config.json business fields business_settings (above). Secrets stay in Replit Secrets
Capture-era files (transcript*.json, evidence-pack.json, evidence-index.json, ingestion-ledger.json, quote-inputs.json, quote-context.json, site-conditions.json, wall and Moasure files) not migrated. Kept read-only with the job's archive

What stays a file: photos, uploaded attachments and every PDF a customer received. Each is stored once by SHA-256 and referenced by a row (job_files, issued_documents). Issued PDFs are in document_blobs, a table of their own behind a two-call interface, not in durable storage's field_blobs (see document_events and issued_documents). Writes go through one service per aggregate (job, quote, invoice, email) in a single database transaction, and every change is recorded in audit_log. Once nothing reads the data directory, durable storage's file mirror (server/storage/) and the per-job generation locks are retired.

What goes away

Hand-built quotes run through all of this today, and none of it is needed once quotes are rows. The figures are approximate lines of code when this plan was written.

Remove What it is Size
Capture stand-ins createManualJob writing a fake transcript.json, evidence-pack.json and quote-inputs.json, and the rebuild-on-every-save they trigger –
Evidence and ingestion stack evidence pack, ingestion ledger, evidence index, stale detection, clarification notes, baseline-scope reconcile, breakdown source, version context, review state and projection, job state and gaps, quote proof and trace ~4.5k
Rules engine generate-evidence-quote, quote-quantity-rules, quote-commercial-compiler, execution estimator, internal breakdown, quote catalogs, catalogue overlay (catalog-pricing.js authority pricing), commercial adjustments, recipe pricing, assembly modes ~10.5k
Rule files the rules, quantityRules, assemblies and execution profile in quote-catalogs/default.json5. The business profile moves to business_settings 1k lines of json5
Pricing attestation quote-pricing-attestation.js, quote-pricing-visibility.js. This is what refuses quotes for missing line-item lineage ~0.6k
Retaining-wall engine bay model, rate card, steel picker, wall zones and render. Walls are already priced as kits per bay in the price book (37 "Walls" items). 2 historical jobs used zones, and their sent PDFs are kept ~4.9k
Moasure import used on 6 historical jobs. Measurement entry becomes plain quantities ~0.6k
Quote files quote.json, quote.md, quote-overrides.json, quote-breakdown-source.json, quote-versions/, the organizer tree, quote_snapshots –
Dead code generate-landscaping-quote.js ~0.35k
Legacy job-folder backfill scripts/backfill-db.js and server/db/job-backfill.js, which map job folders to the older server/db/migrations/0001_initial.sql tables (server/db/README.md). It runs, but nothing reads those tables. It goes when nothing reads the data directory ~1.4k
Client evidence player, gap, assumption, execution and soil sheets, breakdown view, review sheet, measurement panel, models overlay, health bar. The second catalogue editor (QuoteCatalogPanel and CatalogItemAdminForm, inside the 14k-line quote-presentation-sheet.tsx) ~7.5k + most of 14k
MCP field_quote_recalculate, field_quote_catalog_list, raw field_quote_draft_update. Replaced by typed quote-line tools –

That is roughly 30k lines removed, against perhaps 5k of new tables, services and tests.

Moving today's data

Production on 2026-10-11 has 174 active catalogue items and 57 jobs (65 job folders) with 541 catalogue-linked rows, 611 manual rows, 84 groups, and 51 jobs with milestone state (6 issued invoices). The catalogue data problems behind the price-book noise:

Problem Count v2 fix
Generated codes (cat_143d45ed…) 102 of 174 Proposed codes from type + name. The operator reviews the list before it is applied
-copy codes 14 Renamed, with variant_of_uid set where the copy is a variant
Lower-case or __ codes 27 Upper-cased into the code format
kind / type not set 174 of 174 Proposed from the current category and name, reviewed
Free-text category mixing type and trade 13 values, 19 blank Mapped to the new category set. "Materials", "Labour", "Machinery" and "Goods" become types
Sells with more than 2 decimals 84 price rows Rounded to cents in a new migration price row, so the old value stays in history
Tags, descriptions, suppliers 0 used Hidden until used

Rules for the migration:

  • No price values change except rounding sells to cents, which is recorded. Codes, types and categories are applied from a proposal file that the operator approves first: proposed changes are written to a CSV, reviewed, then applied in one transaction behind a point-in-time restore point.

  • Uids never change, so every existing link survives.

  • Quotes: every job's current quote and each version becomes quotes, quote_sections and quote_lines rows:

    • Organizer groups become sections.
    • Organizer rows become lines, with their frozen values as printed.
    • Catalogue links become item_uid and price_uid.
    • Engine lines (10 jobs) become custom lines.
    • Sent versions arrive as sent with their stored PDF in issued_documents.

    The migration verifies, per quote, that the new totals equal the totals on the PDF the customer received, to the cent. Any mismatch is reported and refuses the migration. Built as scripts/backfill-quote-rows.js (#6; see "Phase 3, step by step"), which refuses per job, and run at boot as a data migration (#36).

  • Milestone state becomes payment_milestones, and issued revisions become issued invoices with their stored PDFs. Built as scripts/backfill-invoice-rows.js (#27), which verifies every issued invoice against its PDF and refuses per job, and run at boot as a data migration (#36).

  • The old files stay read-only in the job folder until the watch period ends.

Price book UI

Built on Field's design system (DESIGN.md), not inline styles. The price book at /pricing (client/src/features/catalogue/) uses the fx- primitives; this plan was written against the older kit in client/src/components/field/, whose parts it names:

  • the grid is DataTable, HeaderCell, Row and Cell (numeric, mono), with formatMoney (always 2 decimals) and formatPercent;
  • the item pane is a docked SideDrawer;
  • the filters are SegmentedControl, Select, FilterChip and SearchField;
  • statuses are StatusBadge.

Light and dark mode both come from the kit's tokens. Filled controls use --t-primary and --t-on-primary, and screens never hard-code a colour.

What is wrong today, from the price book at /pricing:

  • Engine noise in every row: generated codes, kit-6 badges, + tags under every name, mode words (fixed, kit) in the Markup column, and empty Supplier and Variant columns.
  • Inconsistent numbers: $1,654.5632 next to $132.00, and margin without a clear basis.
  • Categories: a strip of free-text chips that mixes types and trades, plus an "Uncategorised" bucket.
  • Two catalogue editors: the Price book tab, and an Item editor tab that embeds the quote sheet's catalogue panel.

The v2 price book:

  1. One screen, two panes. The grid on the left; selecting a row opens the item in a right-hand drawer. There is no separate editor tab, and the search box and filters stay put while you drill into a kit.
  2. Grid columns (default): Name · Type · Unit · Cost · Sell · Markup % · Margin %. Optional: Category, Supplier, Last changed, Status. Money is right-aligned, monospaced, always 2 decimals. Kits show a small kit icon after the name, not a text badge. Empty optional columns are hidden, not filled with dashes.
  3. Filters: a segmented Type control (All · Materials · Labour · Equipment · Subcontract · Fees · Kits), a Category select (the trade hierarchy) and Status (Active · Draft · Archived), plus one search box over name, category, tags and supplier code (legacy codes still match, unseen). The category chip strip goes.
  4. Item drawer:
    • Header: name, type, unit, category.
    • Pricing: cost, method (markup or fixed), sell, markup and margin. Editing shows the before → after.
    • Kit components: an inline table with quantity and unit, the component's sell and the extended amount, plus a search to add components without leaving the drawer.
    • "Used in": kits, and draft and sent quotes, so an operator sees what depends on an item before deleting it.
    • Price history: a dated list, and the point-in-time price.
  5. Editing keeps your place: renaming does not jump the row (it re-sorts on blur, then scrolls the row into view), and a kit's numbers refresh as soon as a component changes.
  6. Bulk actions on a selection: set markup, adjust cost or sell by %, change category, archive. Each shows a preview diff before applying (built).
  7. Quote builder picker: the same search component, dockable instead of a fixed popup. Adding a line shows its frozen price, and a draft shows a "N prices changed" banner with the diff.

Phases

Each phase is one or more PRs. Each ends with production working and the offline quote and invoice proof passing (npm run qa:quote-invoice-proof).

Phase What Done when
0. Watch the attestation On 2026-10-11, 8 of the 9 live drafts are sendable; the ninth is an empty test job. Fix the attestation only if a real quote is refused for missing line-item lineage before phase 4 removes it No real quote refused
0b. Tenancy foundation organizations, org_memberships, org_uid and RLS on the existing catalogue, user and snapshot tables; a request-scoped transaction helper that sets field.org_uid; an RLS test that proves cross-tenant reads and writes fail Two-org test suite passes
1. Catalogue schema Migration: type, category_uid, pricing_method, is_sold, code CHECK, money precision, kit_components (from item_components), recipes out of json5, drop the empty assembly_* tables and unused columns, audit_log, business_settings. One price() function Catalogue tests green. Every active item prices identically before and after, except cents rounding, which is recorded
2. Catalogue clean-up Generate the code, type and category proposal CSV. The operator reviews it. Apply it 0 generated or -copy codes, 0 untyped, 0 uncategorised
3. Quote tables quotes, quote_sections, quote_lines, invoices, payments, events, issued documents (events, issued documents, quote rows and invoice rows are built, and invoices are issued and credited from rows: steps 2, 3, 4a and 4b below). The quote service and one quoteTotals. Migrate all jobs, verified against sent PDFs to the cent Every quote's totals match its PDF. New quotes are built only from rows
3b. Jobs and the rest jobs, customers, contacts, job_notes, job_events, email_messages, email_templates, oauth_tokens, job_files; migrate and verify every job; retire field.db and the file mirror Nothing reads DATA_DIR except blobs
4. Strip the engine Delete the removals list. Quote builder, PDF and email read only the new tables ~30k lines gone; the proof passes
5. UI Price book and drawer, then the quote builder, on Field's design system The editing behaviour in "Price book UI" works; one catalogue editor
6. API and MCP Typed catalogue and quote-line operations for external AI Contract tests

Phases 1–2 and 3–4 can overlap. 5 builds on Field's design system (DESIGN.md).

Phase 1 as built (part one)

Built (migration 0007, server/catalog/price.js):

  • kit_components (was item_components; kit_uid, component_uid).
  • items.type with its check; kits typed from their components. Item rows carry type, is_sold and pricing_method.
  • items.notes: internal notes, separate from the customer-facing description.
  • The empty assembly_* tables dropped.
  • One identity: legacy codes are aliases and never change (migration 0006). This replaces the code format CHECK, which the 2026-10-12 direction change retired with codes.
  • price() and the price-equality gate (npm run catalog:price-gate).

Not built yet, and why:

  • category_uid: the category name is unique per organisation and already the key the price book, bulk edits and imports use; the category tree lands with the clean-up's categories, as rows with parent_id.
  • Money precision (numeric(12,2) sells): 84 production sell rows carry more than 2 decimals, so narrowing the column would change prices. That needs its own decision; until then price() returns sells as stored. Quote lines carry rates to four decimals (decided 2026-10-12), so quote rows do not wait for it, and catalogue data is not changed.
  • pricing_method as a column, and dropping kind, visibility, tags, price_reference, cost_mode and sell_mode: the price book redesign still reads them. The API serves the new names alongside; the columns go once nothing reads the old ones.
  • waste_pct and show_on_quote on kit components: the roll-up does not apply waste and quotes do not show components from rows yet (phase 3), so the columns would be promises nothing keeps.
  • Recipes out of json5: the lawn, irrigation and drainage recipes are measured formulas, which kit_components deliberately cannot express (see "kit_components"); they leave with the engine in phase 4.
  • audit_log (replaces catalog_history, which works) and business_settings (read by quote documents): they arrive with phases 3 and 3b, which are their first readers.

Phase 3, step by step

Phase 3 is several PRs. Each one is additive and leaves the quote and invoice proof byte-identical. The steps follow the rebuild order the owner set on 2026-10-12: document store, quotes as rows, invoices as rows, then jobs and the rest. Work starts now, beside the catalogue work.

Step What State
1 The PDF routes stop inferring the document type from the job stage Built
2 The document store and issue service: document_blobs, issued_documents, document_events, document_number_counters; dual-write from today's send path; backfill; GET /api/documents/:uid/pdf and GET /api/jobs/:id/documents Built
2b The old PDF routes (/api/jobs/:id/quote-pdf, /milestones/:index/pdf) serve issued documents from the tables; the issuance ledger and the legacy sent-quote lookup are no longer read Next
3, part one Quotes as rows (#6): quotes, quote_sections, quote_lines (migration 0010), one quoteTotals, the quote service, dual-write after every committed quote change, and a backfill that verifies every version to the cent Built
3, part two Quote reads from rows (#10): display columns (migration 0011); the quote PDF, the quote email's totals, the job's cached totals and the job detail's customer pricing (client and MCP) come from the rows; editor saves carry If-Match Built
3, part three The editor edits rows (with the engine gone, phase 4); quote numbers from document_number_counters; the quote files go Next
4a Invoices, payments and receipts as rows (#27): payment_milestones, invoices, invoice_lines, payments, receipts, and the shape of credit_notes (migration 0013); dual-write after every committed milestone change; a backfill verified against what was issued Built
4b The issue service for invoices (#28): INV-YYYY-M-N numbers from document_number_counters at issue, with the job reference; credit notes replace in-place revisions (revisions made in place before them stay as issued); issue separate from email; claim-line invoice documents Built (issue service, migration 0014; credit notes, migrations 0015 and 0016; claim-line documents)
4c Milestone defaults from account rules by contract value (#29), with visibly marked per-job overrides and caps that warn or block Built (migration 0022)
5 Jobs and the rest (phase 3b) Later

How step 2 fills the tables:

  • Live. After a quote, invoice or receipt email is committed (server/job-actions.js, finalizeAndProjectDelivery), the issue service records the document with the exact bytes that were attached and an emailed event with the Gmail message id. An invoice revision is also recorded when the generation that froze it commits, whether or not it is emailed. A failed email is an email_failed event. None of this can fail a send: an error is logged and counted in GET /api/storage/status (issuedDocuments). Without a database it is skipped with a log line.
  • Backfill. node scripts/backfill-issued-documents.js [--dry-run] walks every job and records the issuance ledger, every issued invoice revision (superseded ones too) and quotes sent before the ledger, with their original bytes. Each file's hash is checked against its record; a mismatch is reported and skipped, never repaired. A second run records nothing new. It is not yet a boot step of the data-migration runner (server/data-migrations/); it becomes one after its first production dry run.

How step 3 part one fills the quote tables (server/quotes/). The job files stay the source of truth, and nothing reads the rows yet.

  • Mapping (from-artifact.js). A version's rows are its customer schedule as the renderer resolves it, each row with the values it prints. A top-level organizer group is a section; a nested group is a note heading. A catalogue-linked row is an item line with the row's item uid, price id and price key. Manual and engine rows are custom lines. Rows under a priced row (kit components, engine breakdowns, rows under a fixed price) are component lines, shown and never counted. A group with a fixed price is one custom line at that price. Every line keeps the total it printed and the row's own rate to four decimals; the total is computed when qty × rate gives it and printed, with the reason, otherwise (an amount today's renderer rounded in floating point, a fixed price no whole-cent rate reaches). A fixed price on a row keeps the row's quantity and takes the whole-cent rate it works out to, when there is one; the rate the PDF printed goes into override_reason. "Listed only" rows are optional and not selected. Rows excluded from the price are left out, because the PDF does not print them.
  • Status (job-quotes.js), from the evidence the rest of Field uses. The newest version that was emailed (the issuance ledger, or before it the send fields) is the customer's: accepted when the job is landed, declined when lost, otherwise sent. Other emailed versions are superseded, older unsent ones superseded, the newest unsent one draft. A version only moved to Sent by hand is not sent (it is accepted or declined when the job is landed or lost).
  • Checks (sync.js). quoteTotals over the rows must equal the totals Field shows to the cent (the renderer's subtotal, GST and total, which the PDF prints); lines kept as printed are listed as warnings. A sent version must also equal the totals on the PDF the customer was sent: the issued document's bytes, else the issuance ledger's PDF (or the quote markdown stored with it when the PDF cannot be read), else for a quote sent before the ledger the copy legacy-sent-quote.js finds. Totals are read from the PDF's own text (issued-totals.js), never re-rendered.
  • Live. After every committed job generation, the recorder writes the versions that changed. A version that fails a check is not written, its draft rows are removed, and it is counted with both numbers in GET /api/storage/status (quoteRows). After a quote email is recorded, the send path syncs again so the sent version links its issued document. Nothing here can fail a save or a send. Without a database it is skipped with a log line.
  • Backfill. node scripts/backfill-quote-rows.js [--dry-run] [--json] checks every job's versions as above and writes a job only when all of its versions pass. A failing job is reported per version with both numbers, nothing is written for it, and the run exits 2. A second run writes nothing new. Without a database, --dry-run checks the files alone. Since #36 production runs it at boot as the data migration 2026-10-13-quote-rows-backfill: every job that verifies is written, each refused job is logged by uid and mismatch kind (the dry run shows the numbers), and the migration is recorded only when nothing is refused, so a later boot retries (docs/REPLIT.md, "The row backfills").
  • Every sent PDF's totals reproduce. With the 2026-10-12 decisions (GST on the subtotal, line totals as printed, four-decimal rates), the three cases the first version of this step could not reproduce (GST per line, a rate with more than two decimals, a fixed price no whole-cent rate reaches) all reproduce. What still fails a check is a version whose files disagree with the PDF that was sent, which the backfill reports and never writes.

How step 3 part two reads the quote tables (server/quotes/read-model.js, schedule.js, concurrency.js). The files are still written alongside.

  • Display columns (migration 0011). Lines and sections also keep what the documents print besides money: excluded from the price (kept, never printed or counted), amount hidden or blank, collapsed, row style and notes; lines also how the PDF styles them (row_kind), the rate printed beside a fixed price (display_unit_sell) and the cost behind the amount (line_cost, internal). Sections and lines share one sort_order sequence, so the printed order of sections and top-level lines is kept.
  • Printed only when exact. The dual-write and the backfill now also check that a version's rows print exactly the schedule its files print (every printed row's label, depth, quantity, unit, rate, amount, style and visibility, and the cost); a version that would not is not written. A sent version written before 0011 keeps its rows and is never printed from them (its PDF is its issued copy anyway).
  • Where a read comes from. The version's rows in the database when they were written from the same files under the current mapping; otherwise the rows the files map to (the same rows the dual-write stores; a quote being built renders before its rows are committed); otherwise the files. FIELD_QUOTE_READS=files sends every read to the files, for rollback. Reads are counted by source under quoteReads in GET /api/storage/status.
  • What reads them. The quote PDF (built, previewed and sent; receipts still print the quote's schedule from the files, and milestone invoices print claim lines since step 4b), the quote email's totals, the job's cached totals (the job list), and the job detail's customerPricing, which the client and the MCP quote tool read.
  • Saves. GET /api/jobs/:id and every quote save answer with X-Field-Quote-Rows-Version ("q<version>-<lock>"). The editor sends it back as If-Match on POST /api/jobs/:id/quote-update; a save against rows that moved on since is refused with 412 (QUOTE_ROWS_CHANGED) inside the job's mutation lock. A request without If-Match (CLI, MCP) is not checked. The save is written to the rows through the quote service before the response, which says so in X-Field-Quote-Rows.
  • Not yet. The editor still edits the organizer tree in the browser: its engine rows point at engine line items that rows do not carry until the engine goes (phase 4). It reads its saved totals from the rows through the job detail. Editing rows directly is part three.

How step 4a fills the invoice tables (server/invoices/, #27). The job files (milestone-invoices/state.json) stay the source of truth, and nothing reads the rows yet. No customer document changes.

  • Mapping (from-milestones.js). Each milestone is a payment_milestones row. Each issued revision is an issued invoice under its number exactly as issued: the current one issued (or paid), one superseded by a revision credited, one returned to draft void. An invoice issued before revisions were frozen maps from its stored record. A draft milestone is a draft invoice under its provisional number. Totals are as issued and never recomputed. Payments keep their dates, method, reference and void details. A paid milestone's receipt keeps the number it was issued with (ER-…, or RCT-… since step 4b).
  • Checks (sync.js). Each issued invoice's subtotal, GST and total must equal its stored revision and, where its PDF can be read (the issued document's bytes, else the revision's stored PDF, hash-checked), the totals the PDF printed (issued-totals.js reads the "This claim" box). Each milestone's active payments must add up to the amount paid Field shows. Issued invoices must not claim more than the quote. Where the drafts were built against an earlier quote, the difference is a warning: Field rebuilds them on the next milestone action. A job that fails is reported with both numbers, and nothing is written for it.
  • What was sent decides (#60). Before 2026-08-05, an invoice's milestone record and its PDF could come from different quote versions. Where an issued invoice disagrees with one of its PDFs:
    • Field may hold hash-verified copies of the emailed PDF (an issued document with an emailed event, or an issuance-ledger delivery; sent-copies.js). If they agree, the rows take the emailed amounts, with tax_total_source = printed and a reason that names both versions. That is a warning, not a refusal. Every later sync of an unsettled legacy invoice does the same, so the rows never conflict. A stored PDF that differs from the emailed copy is reported as not the copy that was sent.
    • Without such a copy, the job is refused. Its entry lists the archived emails of the invoice: when it was sent, and the quote version it was built against.
    • An owner settles the invoice through GET/POST /api/admin/invoices/amount-disagreements (amount-evidence.js). A settled invoice's rows carry the settled amounts, as printed, with the decision.
  • Live. After every committed job generation, after the quote rows, the recorder writes the job's invoice rows. After an invoice or receipt email the send path syncs again, so the rows link the issued document. A failure is logged and counted under invoiceRows in GET /api/storage/status, and never fails the action. Without a database it is skipped with a log line.
  • Backfill. node scripts/backfill-invoice-rows.js [--dry-run] [--json] [--data-dir <dir> | --from-storage] writes every job that passes and refuses the rest (exit 2). A second run writes nothing new. Run the quote-row backfill first, so invoices link their quote versions. Production runs it at boot as the data migration 2026-10-13-invoice-rows-backfill, after the quote rows (#36; docs/REPLIT.md, "The row backfills").
  • What does not reproduce exactly. A payment recorded against a revision that was later superseded stays on that revision (that is what the payment record says), so the new revision's amount_paid can be lower than the milestone's amount paid in the files; step 4b's credit note carries it across. A receipt today acknowledges everything paid on the milestone, so its row's amount can be more than the one payment it points at.

How step 4b issues invoices (server/invoices/issue.js, #28, part one). The job files stay the source of truth for everything else; an invoice's number and bytes now come from PostgreSQL.

  • When. An invoice is issued when it is finalised: authorised ("Mark as sent"), sent (before the email is attempted), or issued as a revision. persistMilestoneInvoiceState (server/job-actions.js) finds every milestone whose issued revision has no document yet, and issues each one through the service before the job generation commits.
  • One transaction. Under the organisation's issue lock (pg_advisory_xact_lock), the service allocates INV-YYYY-M-N from document_number_counters (period: the issue time's month in the server's local time, the date the PDF prints), renders the PDF once with that number and the job reference, stores the bytes in document_blobs with an issued_documents row and an issued event, and writes the issued invoices row and its claim line (migration 0014_invoice_issue: issue_key, a check that such an invoice has its reference and a sequence number, and a sequence number unique in the organisation). After it commits, the R2 replica copies the bytes.
  • No gaps, never reused. The counter only moves in that transaction: an issue that fails (the render throws, the job has no job number) rolls it back. The milestone takes the number and the stored bytes are written at its PDF path, so the file and the issued document are the same bytes.
  • Idempotent. The issue key names the job, the milestone, the revision and a hash of the money. A retried issue (say the job generation failed after the transaction committed) returns the same invoice, number and bytes and allocates nothing.
  • Reference. The job number (the quote's, else the job's, else the job part of the quote number) is the invoice's required reference, stored on the row. With none, issuing is refused. Since #93 the issue also records the line the invoice prints with the quote it bills, "Reference: Job 2610-07 · Quote EQ-2610-07 v2" (no "v1"; the quote number from the quote row), stored with the issued document and sent as its Xero invoice's Reference. Invoices issued before print "Job reference", and so do their reconstructed copies.
  • Drafts. A draft is tracked under its provisional EI-…-M<n> and its preview prints "Draft, not yet issued" and no payment reference. After an unsend it goes back to the provisional number; issuing the same content again restores the invoice it was, under its number.
  • Email is delivery. The emailed event carries the hash of the bytes sent; a resend sends the stored bytes. A failed email is an email_failed event and never undoes the issue.
  • Receipts have their own series, RCT-YYYY-M-N (the month not zero-padded: RCT-2026-1-1), from the same allocator as invoices and credit notes (owner's decision, 2026-10-13). When a milestone that was not paid becomes paid, issueReceipt allocates the number, renders the receipt once with it and the INV-… invoice it pays ("For invoice:" in its details), and stores it as an issued document with its issued event; the milestone carries the number (receiptNumber, issuedReceipt), and sending the receipt sends those stored bytes. The receipt row is written by the dual-write, which names the payment. A milestone that stops being paid (marked unpaid, a payment voided) keeps the issued receipt in its receiptHistory, marked voided, and its row becomes void; paying it again issues a new receipt. Receipts issued before keep their ER-…-M<n> numbers, and a milestone paid before this release keeps its receipt. Until it is paid, a milestone's receipt is tracked under the provisional ER-…-M<n>. A stored receipt that was lost is drawn again with its number, marked "Reconstructed copy".
  • Without a database the server installs an issuer that refuses, so authorising or sending an invoice answers 503; invoice and quote sends already need the database for their snapshots.
  • Invoice rows. The row the issue writes is mapped exactly as the job's files map it afterwards (mapInvoiceForIssue), so the dual-write finds it unchanged and only fills its links.

How step 4b credits an invoice (#28, part two; migration 0015_credit_notes). An issued invoice is never revised in place.

  • Credit and reissue. The operator credits an issued invoice with a reason ("Credit and reissue" in the job's invoices, field credit-invoice, POST /api/jobs/:id/milestones/:index/credit-and-reissue; /revise and revise-invoice are the older names). Field mints the next revision from the current quote, then the issue service, in one transaction under the issue lock: allocates CN-YYYY-M-N (counter credit_note) and INV-YYYY-M-N; renders and stores the credit note's PDF and the new invoice's; writes the credit_notes row (the credited invoice in full, its lines each pointing at the invoice line they credit, the reason, who did it, replacement_invoice_uid) and the new invoice (supersedes_invoice_uid); sets the credited invoice to credited; adds a credited event to its issued document; and moves its payments.

  • The credit note PDF is an adjustment note: titled "Adjustment note", the supplier and ABN, the recipient (always printed; the ATO requires it where the original was $1,000 or more), date and number, the original invoice and job reference, the reason, the amount of the adjustment and the GST adjustment, and the price difference between the original invoice and its replacement. Its line says what it credits in plain words ("Credit of INV-2026-10-1: Deposit: 50% of the quoted price"), with the quote on a quieter line ("Quote EQ-2610-03, accepted 14 September 2026"; "revision 2" only when the customer was sent more than one version, and never "v1"; server/invoices/customer-wording.js). Credit notes are numbered CN-YYYY-M-N, the month not zero-padded (CN-2026-1-1). The replacement invoice prints "Replaces" and "Credit note".

  • Payments move as allocations. A payment is never changed: a payment_allocations row (append-only) moves it from the credited invoice to the replacement. The invoice that holds a payment is the to_invoice_uid of its latest allocation, else the invoice it was recorded against, and an invoice's amount_paid is the active payments it holds.

  • Revisions made in place before credit notes (-R<k>) stay as issued (owner's decision, 2026-10-13). No credit note is invented for them: no client was ever issued a credit note, and an invented one would not match the accounts in Xero. The superseded revision's invoice row is credited with the note "Revised in place before credit notes: superseded by . No credit note was issued." Its payments stay on the invoice they were recorded against; the backfill reports each as a payment-on-superseded warning and changes nothing. Credit notes apply from this release on.

  • An invoice returned to draft and reissued (decided 2026-10-13). When an invoice is marked not sent or unpaid and then issued again with different content, it gets a new number, and the account setting creditNoteOnReissue (business_settings, migration 0016_business_settings; Settings, "Quotes and invoices") decides whether the issue service credits the old one first, in the same transaction:

    • when-emailed (the default, best practice): only when Field has evidence the customer was sent it through Field: an emailed event on its issued document, or a delivered send record (a sent quote snapshot of it). With no such evidence it was never delivered, so it is left void and reissued under a new number with no credit note;
    • always: a credit note every time;
    • never: never a credit note; the old invoice stays void.

    The issue service reads the setting and the evidence inside its transaction. "Credit and reissue" always makes a credit note, whatever the setting says: that action is the operator saying the customer has the invoice. Issuing the same content again still restores the invoice it was, under its number, with no credit note.

  • Sending the replacement. A credit note is not emailed on its own. Its stored PDF is an issued document of the job (GET /api/jobs/:id/documents, GET /api/documents/:uid/pdf). When the replacement invoice is emailed, the email editor offers "Also attach the adjustment note": ticked, the credit note's stored bytes go with the invoice (attachCreditNote on POST /api/jobs/:id/email-quote). Attaching other Field documents is part of the email editor's redesign.

How step 4b prints a milestone invoice (#28, part three). Only invoices issued from now on change; an issued PDF never does.

  • Claim lines, not the quote's schedule. The PDF has one claim line per milestone it bills, in plain words ("Deposit: 50% of the quoted price"), with the quote on a quieter second line ("Quote EQ-2610-03, accepted 14 September 2026", or "sent …" when it was not accepted) and its subtotal, GST and total. "Revision 2" is added only when the customer was sent more than one version of the quote (the job's send history); a customer never sees "v1" (owner, 2026-10-13; server/invoices/customer-wording.js). The invoice rows keep their own line names with the quote version, for the record. Then the claim box ("This claim (ex GST)", "GST (10%)", "Amount Due", the labels issued-totals.js reads) and the quote summary, two columns the width of the claim table: "All amounts include GST" once in its heading, the same quote line, then contract value, previously claimed and this claim, a hairline, and "Remaining after this claim" in bold. Amounts are right-aligned in figures of one width. The quote's schedule is not printed: the quote is its own document.
  • A tax invoice. An issued milestone invoice is titled "Tax invoice" and prints what the ATO asks of one: the supplier's name and ABN, the date, the recipient, a description of what is claimed and the GST, with the job reference. A draft preview stays "Invoice" with "Draft, not yet issued".
  • A lost PDF drawn again is marked "Reconstructed copy" (owner, 2026-10-13). An invoice issued before step 4b (an EI-… number) is drawn in the layout it was issued with: titled "Invoice", with the quote's schedule and the claim box. One issued from the sequence (INV-…) is drawn in the step 4b layout. An invoice not yet issued always uses the step 4b layout.
  • A receipt prints as before, with its number and "For invoice: INV-…" in its details. An invoice for a job with no milestones prints as before.