From a78b57c188ae9ecb1e343d31a7a48b6a45e9f08a Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 12:39:46 -0700 Subject: [PATCH 01/25] platform: httpx.NextGaplessNumber, the gapless counter mint (ADR 0005 4.1) Co-Authored-By: Claude Sonnet 5.5 --- core/internal/platform/httpx/docnum.go | 66 +++++++++++ core/internal/platform/httpx/docnum_test.go | 118 ++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/core/internal/platform/httpx/docnum.go b/core/internal/platform/httpx/docnum.go index ebd519f0..a51e06f4 100644 --- a/core/internal/platform/httpx/docnum.go +++ b/core/internal/platform/httpx/docnum.go @@ -5,6 +5,7 @@ package httpx import ( "context" + "errors" "fmt" "github.com/jackc/pgx/v5" @@ -95,3 +96,68 @@ func validateDocPrefix(prefix string) error { } return nil } + +// NextGaplessNumber takes the next value of a gapless series through the +// caller's transaction and formats it like NextDocumentNumber (ADR 0005 +// section 4.1): `UPDATE document_counters SET next_value = next_value + 1 +// WHERE series = $1 RETURNING next_value - 1`. The row stays locked from the +// mint to the commit, so the minting transactions serialize, and a rollback +// undoes the increment with it: no number is lost, the next mint takes it. +// That is the whole difference from a sequence, and its cost: take the mint +// late in the act, after every other row lock and immediately before the +// document insert (ADR 0005 section 11, step 8). +// +// q must be the caller's open transaction. Outside a transaction the +// statement would commit on its own and a failure after the mint would leave +// a gap, which is the one thing this function exists to prevent; the helper +// cannot tell the two apart through the Querier seam, so the rule is the +// caller's, and the invoice and credit memo repositories pass the executor +// the platform database hands out inside RunInTx (never the pool). +// +// The series is seeded by the migration that introduces the counter +// (document_counters); a series with no row is an error naming it, never a +// number from nowhere. The series name is a server side constant: lowercase +// letters, digits, underscores, hyphens and colons (a per branch series is +// "invoice:"). The prefix and width follow NextDocumentNumber. +func NextGaplessNumber(ctx context.Context, q Querier, series, prefix string, width int) (string, error) { + if err := validateSeriesName(series); err != nil { + return "", err + } + if err := validateDocPrefix(prefix); err != nil { + return "", err + } + if width < 1 { + return "", fmt.Errorf("document number width must be at least 1, got %d", width) + } + if q == nil { + return "", fmt.Errorf("document number querier is nil") + } + var n int64 + err := q.QueryRow(ctx, + `UPDATE document_counters SET next_value = next_value + 1 WHERE series = $1 RETURNING next_value - 1`, + series).Scan(&n) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", fmt.Errorf("gapless series %q has no counter row", series) + } + return "", fmt.Errorf("gapless counter %s: %w", series, err) + } + return fmt.Sprintf("%s-%0*d", prefix, width, n), nil +} + +// validateSeriesName accepts a counter series key: lowercase letters, digits, +// underscores, hyphens and colons, not starting with a digit. +func validateSeriesName(name string) error { + if name == "" { + return fmt.Errorf("series name is empty") + } + for i := 0; i < len(name); i++ { + c := name[i] + switch { + case c >= 'a' && c <= 'z', c == '_', c == '-', c == ':', i > 0 && c >= '0' && c <= '9': + default: + return fmt.Errorf("series name %q is not a plain lowercase key", name) + } + } + return nil +} diff --git a/core/internal/platform/httpx/docnum_test.go b/core/internal/platform/httpx/docnum_test.go index e0189da1..f3e16777 100644 --- a/core/internal/platform/httpx/docnum_test.go +++ b/core/internal/platform/httpx/docnum_test.go @@ -187,3 +187,121 @@ func TestNextDocumentNumberConcurrent(t *testing.T) { t.Errorf("%d distinct numbers minted, want %d", len(seen), workers*each) } } + +// createTestCounter makes a throwaway gapless series (the table is the +// migration's document_counters; created here when absent so the platform +// package tests stand alone). +func createTestCounter(t *testing.T, db *database.DB, series string, next int64) { + t.Helper() + ctx := context.Background() + if _, err := db.Pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS document_counters (series TEXT PRIMARY KEY, next_value BIGINT NOT NULL)`); err != nil { + t.Fatalf("counter table: %v", err) + } + if _, err := db.Pool.Exec(ctx, `INSERT INTO document_counters (series, next_value) VALUES ($1, $2) + ON CONFLICT (series) DO UPDATE SET next_value = EXCLUDED.next_value`, series, next); err != nil { + t.Fatalf("seed counter: %v", err) + } + t.Cleanup(func() { _, _ = db.Pool.Exec(ctx, `DELETE FROM document_counters WHERE series = $1`, series) }) +} + +// RULE (ADR 0005 4.1): a gapless number is the counter's next value through +// the caller's transaction; a rollback gives the number back, so the next mint +// takes it (the whole difference from a sequence). +func TestNextGaplessNumberRollbackGivesTheNumberBack(t *testing.T) { + db := testutil.RequireDB(t) + createTestCounter(t, db, "gapless_rollback", 5) + ctx := context.Background() + + tx, err := db.Pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + got, err := NextGaplessNumber(ctx, tx, "gapless_rollback", "IN", 6) + if err != nil || got != "IN-000005" { + t.Fatalf("in tx = %q, %v, want IN-000005", got, err) + } + if err := tx.Rollback(ctx); err != nil { + t.Fatal(err) + } + again, err := NextGaplessNumber(ctx, db.Pool, "gapless_rollback", "IN", 6) + if err != nil || again != "IN-000005" { + t.Fatalf("after rollback = %q, %v, want IN-000005 again (no gap)", again, err) + } + next, err := NextGaplessNumber(ctx, db.Pool, "gapless_rollback", "IN", 6) + if err != nil || next != "IN-000006" { + t.Fatalf("next = %q, %v, want IN-000006", next, err) + } +} + +// RULE: contenders mint distinct, consecutive numbers: the counter row +// serializes them. +func TestNextGaplessNumberConcurrentConsecutive(t *testing.T) { + db := testutil.RequireDB(t) + createTestCounter(t, db, "gapless_concurrent", 1) + const workers = 8 + nums := make(chan string, workers) + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + ctx := context.Background() + tx, err := db.Pool.Begin(ctx) + if err != nil { + t.Error(err) + return + } + n, err := NextGaplessNumber(ctx, tx, "gapless_concurrent", "IN", 6) + if err != nil { + _ = tx.Rollback(ctx) + t.Error(err) + return + } + if err := tx.Commit(ctx); err != nil { + t.Error(err) + return + } + nums <- n + }() + } + wg.Wait() + close(nums) + seen := map[string]bool{} + for n := range nums { + if seen[n] { + t.Fatalf("number %s minted twice", n) + } + seen[n] = true + } + for i := 1; i <= workers; i++ { + if want := fmt.Sprintf("IN-%06d", i); !seen[want] { + t.Errorf("number %s was not minted: the series has a gap", want) + } + } +} + +// RULE: a series with no counter row, a bad series name, prefix, width or no +// querier are refusals, never a number. +func TestNextGaplessNumberRefusals(t *testing.T) { + db := testutil.RequireDB(t) + ctx := context.Background() + createTestCounter(t, db, "gapless_anchor", 1) // makes sure the table exists + if _, err := NextGaplessNumber(ctx, db.Pool, "gapless_missing", "IN", 6); err == nil { + t.Error("missing series succeeded, want an error") + } + bad := []struct { + series, prefix string + width int + }{{"", "IN", 6}, {"bad name", "IN", 6}, {"1x", "IN", 6}, {"invoice", "", 6}, {"invoice", "in", 6}, {"invoice", "INVOI", 6}, {"invoice", "IN", 0}} + for _, tc := range bad { + if _, err := NextGaplessNumber(ctx, nil, tc.series, tc.prefix, tc.width); err == nil { + t.Errorf("NextGaplessNumber(%q, %q, %d) succeeded, want a refusal", tc.series, tc.prefix, tc.width) + } + } + if _, err := NextGaplessNumber(ctx, nil, "invoice", "IN", 6); err == nil { + t.Error("nil querier succeeded") + } + if got, err := NextGaplessNumber(ctx, db.Pool, "gapless_anchor", "IN", 3); err != nil || got != "IN-001" { + t.Errorf("got %q, %v, want IN-001", got, err) + } +} From bc87c156fd35fc37f9d6a1e886c82c14bf1d0fa0 Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 12:44:10 -0700 Subject: [PATCH 02/25] migration 097: invoices and credit memos onto the wire contract (counters, numbers, terms by id, credit memo lines) Co-Authored-By: Claude Sonnet 5.5 --- .../migrations/097_invoices_wire_contract.sql | 352 ++++++++++++++++++ .../down/097_invoices_wire_contract_down.sql | 90 +++++ 2 files changed, 442 insertions(+) create mode 100644 core/migrations/097_invoices_wire_contract.sql create mode 100644 core/migrations/down/097_invoices_wire_contract_down.sql diff --git a/core/migrations/097_invoices_wire_contract.sql b/core/migrations/097_invoices_wire_contract.sql new file mode 100644 index 00000000..c610eb34 --- /dev/null +++ b/core/migrations/097_invoices_wire_contract.sql @@ -0,0 +1,352 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- 097: invoices and credit memos onto the wire contract (item C2-3, +-- docs/adr/0001-wire-contract.md and docs/adr/0005-sales-and-money-core.md +-- sections 4, 6 and 13). The steps are the ADR's C2-3 list, in order: +-- +-- 1 created_at / updated_at filled and set NOT NULL on invoices and credit +-- memos (the keyset ordering and the number backfill read them); revision. +-- 2 document_counters with the series invoice and credit_memo, and the two +-- numbering functions (the columns' DEFAULTs, so a raw SQL writer such as +-- the seed numbers through the same counter, and a rolled back insert +-- rolls its increment back with it). +-- 3 invoices.number: IN-, backfilled in (created_at, id) order, the counter +-- set past the maximum, DEFAULT, NOT NULL, UNIQUE. +-- 4 OVERDUE stops being a stored status (UNPAID or PARTIAL from the payments +-- recorded against the row); the new status CHECK; due_date to DATE; +-- payment_terms_id from the legacy text with the discount snapshot; the +-- text columns invoices.payment_terms and customers.payment_terms dropped; +-- the void columns (voided_on included). +-- 5 credit_memos: the columns of 6.3, with the backfill (PENDING to DRAFT; +-- APPLIED stays APPLIED when it names an invoice, else OPEN; total = +-- -amount; one ADJUST charge line each; numbers through the counter for +-- every memo not in DRAFT). +-- 6 credit_memo_lines; keyset indexes on both tables. +-- +-- Beside the ADR's list: invoice_lines gains priced_unit_price, +-- override_reason and price_adjusted_by (the price audit trail the order line +-- carries, copied at billing and backfilled through order_line_id), so an +-- invoice line reads in the one shared line shape. + +-- 1. created_at, updated_at, revision. +UPDATE invoices SET created_at = COALESCE(updated_at, NOW()) WHERE created_at IS NULL; +UPDATE invoices SET updated_at = created_at WHERE updated_at IS NULL; +ALTER TABLE invoices ALTER COLUMN created_at SET NOT NULL; +ALTER TABLE invoices ALTER COLUMN updated_at SET NOT NULL; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 1; + +UPDATE credit_memos SET created_at = NOW() WHERE created_at IS NULL; +ALTER TABLE credit_memos ALTER COLUMN created_at SET NOT NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ; +UPDATE credit_memos SET updated_at = COALESCE(applied_at, created_at) WHERE updated_at IS NULL; +ALTER TABLE credit_memos ALTER COLUMN updated_at SET NOT NULL; +ALTER TABLE credit_memos ALTER COLUMN updated_at SET DEFAULT NOW(); +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 1; + +-- 2. The gapless counters and their numbering functions. +CREATE TABLE IF NOT EXISTS document_counters ( + series TEXT PRIMARY KEY, + next_value BIGINT NOT NULL CHECK (next_value >= 1) +); +INSERT INTO document_counters (series, next_value) VALUES ('invoice', 1), ('credit_memo', 1) +ON CONFLICT (series) DO NOTHING; + +CREATE OR REPLACE FUNCTION invoice_next_number() RETURNS TEXT +LANGUAGE sql AS $$ + UPDATE document_counters SET next_value = next_value + 1 WHERE series = 'invoice' + RETURNING 'IN-' || lpad((next_value - 1)::text, GREATEST(6, length((next_value - 1)::text)), '0') +$$; + +CREATE OR REPLACE FUNCTION credit_memo_next_number() RETURNS TEXT +LANGUAGE sql AS $$ + UPDATE document_counters SET next_value = next_value + 1 WHERE series = 'credit_memo' + RETURNING 'CM-' || lpad((next_value - 1)::text, GREATEST(6, length((next_value - 1)::text)), '0') +$$; + +-- 3. invoices.number. +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS number TEXT; + +WITH ordered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) AS n + FROM invoices + WHERE number IS NULL +) +UPDATE invoices i +SET number = 'IN-' || lpad(o.n::text, GREATEST(6, length(o.n::text)), '0') +FROM ordered o +WHERE i.id = o.id; + +UPDATE document_counters +SET next_value = GREATEST(next_value, COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM invoices), 0) + 1) +WHERE series = 'invoice'; + +ALTER TABLE invoices ALTER COLUMN number SET DEFAULT invoice_next_number(); +ALTER TABLE invoices ALTER COLUMN number SET NOT NULL; +ALTER TABLE invoices ADD CONSTRAINT invoices_number_key UNIQUE (number); + +-- 4. Status, due date, terms, void columns. +UPDATE invoices i +SET status = CASE WHEN COALESCE((SELECT SUM(p.amount) FROM payments p WHERE p.invoice_id = i.id), 0) > 0 + THEN 'PARTIAL' ELSE 'UNPAID' END +WHERE i.status = 'OVERDUE'; +ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_status_check; +ALTER TABLE invoices ADD CONSTRAINT invoices_status_check + CHECK (status IN ('UNPAID', 'PARTIAL', 'PAID', 'VOID', 'WRITTEN_OFF')); + +-- due_date to DATE: the business date in the branch's time zone. A column type +-- change cannot read another table, so the date goes through a new column. +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS due_date_new DATE; +UPDATE invoices i +SET due_date_new = (i.due_date AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l WHERE l.id = i.branch_id), 'UTC'))::date +WHERE i.due_date IS NOT NULL AND i.due_date_new IS NULL; +ALTER TABLE invoices DROP COLUMN due_date; +ALTER TABLE invoices RENAME COLUMN due_date_new TO due_date; + +-- Terms by id. The legacy text maps exactly as 091 mapped it (same keying, same +-- own-term naming); terms 091 made already exist and a text it never saw is +-- made here, so no invoice loses its terms. +DROP TABLE IF EXISTS legacy_terms_map; +CREATE TEMP TABLE legacy_terms_map AS +WITH legacy AS ( + SELECT DISTINCT btrim(t) AS original + FROM (SELECT payment_terms::text AS t FROM customers + UNION ALL SELECT payment_terms::text FROM invoices) s + WHERE btrim(t) <> '' +), keyed AS ( + SELECT original, upper(regexp_replace(original, '[\s_.\-]+', '', 'g')) AS key FROM legacy +), classified AS ( + SELECT original, key, + CASE + WHEN key ~ '^NET[0-9]{1,4}(DAYS?)?$' AND substring(key from '^NET([0-9]+)')::int <= 3650 + THEN 'NET' || (substring(key from '^NET([0-9]+)')::int)::text + WHEN key ~ '^N[0-9]{1,4}$' AND substring(key from 2)::int <= 3650 + THEN 'NET' || (substring(key from 2)::int)::text + WHEN key IN ('COD', 'CASHONDELIVERY') THEN 'COD' + WHEN key IN ('DUEONRECEIPT', 'DUEUPONRECEIPT', 'UPONRECEIPT', 'ONRECEIPT') THEN 'DUE_ON_RECEIPT' + END AS matched, + COALESCE(NULLIF(left(trim(both '-' from regexp_replace(upper(original), '[^A-Z0-9_-]+', '-', 'g')), 36), ''), 'LEGACY') AS norm + FROM keyed +), ranked AS ( + SELECT *, row_number() OVER (PARTITION BY norm ORDER BY original COLLATE "C") AS rn + FROM classified WHERE matched IS NULL +) +SELECT c.original, COALESCE(c.matched, CASE WHEN r.rn = 1 THEN r.norm ELSE r.norm || '-' || r.rn END) AS code, + (c.matched IS NULL) AS own +FROM classified c LEFT JOIN ranked r ON r.original = c.original; + +INSERT INTO payment_terms (code, name, kind, net_days) +SELECT DISTINCT code, 'Net ' || substring(code from 4), 'NET_DAYS', substring(code from 4)::int +FROM legacy_terms_map WHERE NOT own AND code ~ '^NET[0-9]+$' +ON CONFLICT (code) DO NOTHING; + +INSERT INTO payment_terms (code, name, kind, net_days) +SELECT code, original, 'NET_DAYS', 30 FROM legacy_terms_map WHERE own +ON CONFLICT (code) DO NOTHING; + +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS payment_terms_id UUID NULL REFERENCES payment_terms (id); +UPDATE invoices i +SET payment_terms_id = COALESCE( + (SELECT pt.id FROM legacy_terms_map m JOIN payment_terms pt ON pt.code = m.code + WHERE m.original = btrim(i.payment_terms::text)), + (SELECT c.payment_terms_id FROM customers c WHERE c.id = i.customer_id), + payment_terms_default_id()) +WHERE i.payment_terms_id IS NULL; +ALTER TABLE invoices ALTER COLUMN payment_terms_id SET NOT NULL; +ALTER TABLE invoices ALTER COLUMN payment_terms_id SET DEFAULT payment_terms_default_id(); + +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS discount_due_date DATE NULL; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS discount_percent NUMERIC(7, 4) NULL; +UPDATE invoices i +SET discount_percent = pt.discount_percent, + discount_due_date = i.invoice_date + pt.discount_days +FROM payment_terms pt +WHERE pt.id = i.payment_terms_id AND pt.discount_percent IS NOT NULL AND pt.discount_days IS NOT NULL + AND i.discount_percent IS NULL; + +DROP TABLE legacy_terms_map; +ALTER TABLE invoices DROP COLUMN IF EXISTS payment_terms; +ALTER TABLE customers DROP COLUMN IF EXISTS payment_terms; + +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS voided_at TIMESTAMPTZ NULL; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS voided_by TEXT NULL; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS void_reason TEXT NULL; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS voided_on DATE NULL; +UPDATE invoices SET voided_at = COALESCE(updated_at, created_at), voided_on = invoice_date +WHERE status = 'VOID' AND voided_at IS NULL; +ALTER TABLE invoices ADD CONSTRAINT invoices_void_columns + CHECK ((status = 'VOID') = (voided_at IS NOT NULL)); + +-- The invoice line's price audit trail, as an order line carries it. +ALTER TABLE invoice_lines ADD COLUMN IF NOT EXISTS priced_unit_price NUMERIC(12, 4) NULL; +ALTER TABLE invoice_lines ADD COLUMN IF NOT EXISTS override_reason TEXT NULL; +ALTER TABLE invoice_lines ADD COLUMN IF NOT EXISTS price_adjusted_by TEXT NULL; +UPDATE invoice_lines il +SET priced_unit_price = ol.priced_unit_price, override_reason = ol.override_reason, + price_adjusted_by = ol.price_adjusted_by +FROM order_lines ol +WHERE il.order_line_id = ol.id AND il.priced_unit_price IS NULL; + +-- 5. Credit memos. +ALTER TABLE credit_memos ALTER COLUMN status DROP DEFAULT; +ALTER TABLE credit_memos DROP CONSTRAINT IF EXISTS credit_memos_status_check; + +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS number TEXT NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS currency CHAR(3) NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS branch_id UUID NULL REFERENCES locations (id); +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS project_id UUID NULL REFERENCES projects (id); +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS ship_to_id UUID NULL REFERENCES customer_ship_tos (id); +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS pos_return_id UUID NULL REFERENCES pos_returns (id); +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS reason_code TEXT NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS subtotal NUMERIC(12, 2) NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS tax_amount NUMERIC(12, 2) NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS total_amount NUMERIC(12, 2) NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS tax_rate NUMERIC(9, 6) NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS gl_entry_id UUID NULL REFERENCES gl_journal_entries (id); +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS memo_date DATE NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS voided_at TIMESTAMPTZ NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS voided_by TEXT NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS void_reason TEXT NULL; +ALTER TABLE credit_memos ADD COLUMN IF NOT EXISTS voided_on DATE NULL; + +-- PENDING to DRAFT; APPLIED stays APPLIED when it names an invoice (C2-4 +-- writes its application) and becomes OPEN when not (the old apply already +-- lowered the subledger, so an open credit keeps documents and subledger +-- agreeing); VOID stays. +UPDATE credit_memos SET status = CASE + WHEN status = 'PENDING' THEN 'DRAFT' + WHEN status = 'APPLIED' AND invoice_id IS NULL THEN 'OPEN' + ELSE status END +WHERE status IN ('PENDING', 'APPLIED'); + +UPDATE credit_memos m +SET branch_id = COALESCE((SELECT i.branch_id FROM invoices i WHERE i.id = m.invoice_id), + (SELECT c.primary_branch_id FROM customers c WHERE c.id = m.customer_id)) +WHERE m.branch_id IS NULL; +UPDATE credit_memos m +SET currency = COALESCE((SELECT i.currency FROM invoices i WHERE i.id = m.invoice_id), + (SELECT c.currency FROM customers c WHERE c.id = m.customer_id), + (SELECT s.value FROM system_settings s WHERE s.key = 'currency.default'), + 'USD') +WHERE m.currency IS NULL; +UPDATE credit_memos SET reason_code = 'OTHER' WHERE reason_code IS NULL; +UPDATE credit_memos SET subtotal = -amount, tax_amount = 0, total_amount = -amount WHERE total_amount IS NULL; +UPDATE credit_memos m +SET memo_date = (m.created_at AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l WHERE l.id = m.branch_id), 'UTC'))::date +WHERE m.memo_date IS NULL; +UPDATE credit_memos SET voided_at = COALESCE(applied_at, created_at), voided_on = memo_date +WHERE status = 'VOID' AND voided_at IS NULL; + +-- Numbers through the counter for every memo not in DRAFT, in (created_at, id) order. +WITH ordered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) AS n + FROM credit_memos + WHERE number IS NULL AND status <> 'DRAFT' +) +UPDATE credit_memos m +SET number = 'CM-' || lpad(o.n::text, GREATEST(6, length(o.n::text)), '0') +FROM ordered o +WHERE m.id = o.id; +UPDATE document_counters +SET next_value = GREATEST(next_value, COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM credit_memos), 0) + 1) +WHERE series = 'credit_memo'; + +ALTER TABLE credit_memos ALTER COLUMN currency SET NOT NULL; +ALTER TABLE credit_memos ALTER COLUMN branch_id SET NOT NULL; +ALTER TABLE credit_memos ALTER COLUMN reason_code SET NOT NULL; +ALTER TABLE credit_memos ALTER COLUMN subtotal SET NOT NULL; +ALTER TABLE credit_memos ALTER COLUMN tax_amount SET NOT NULL; +ALTER TABLE credit_memos ALTER COLUMN total_amount SET NOT NULL; +ALTER TABLE credit_memos ALTER COLUMN memo_date SET NOT NULL; +ALTER TABLE credit_memos ALTER COLUMN status SET DEFAULT 'DRAFT'; +ALTER TABLE credit_memos ALTER COLUMN amount TYPE NUMERIC(12, 2); + +ALTER TABLE credit_memos ADD CONSTRAINT credit_memos_status_check + CHECK (status IN ('DRAFT', 'OPEN', 'PARTIAL', 'APPLIED', 'VOID')); +ALTER TABLE credit_memos ADD CONSTRAINT credit_memos_currency_format CHECK (currency ~ '^[A-Z]{3}$'); +ALTER TABLE credit_memos ADD CONSTRAINT credit_memos_reason_code_check + CHECK (reason_code IN ('RETURN', 'PRICE_ADJUSTMENT', 'DAMAGE', 'OTHER')); +ALTER TABLE credit_memos ADD CONSTRAINT credit_memos_amounts_nonpositive + CHECK (subtotal <= 0 AND tax_amount <= 0 AND total_amount <= 0); +ALTER TABLE credit_memos ADD CONSTRAINT credit_memos_number_key UNIQUE (number); +-- a draft has no number (it is minted at post); a voided draft never had one +ALTER TABLE credit_memos ADD CONSTRAINT credit_memos_number_when_posted + CHECK (status = 'VOID' OR ((status = 'DRAFT') = (number IS NULL))); +ALTER TABLE credit_memos ADD CONSTRAINT credit_memos_void_columns + CHECK ((status = 'VOID') = (voided_at IS NOT NULL)); + +-- 6. credit_memo_lines: the line columns of ADR 0005 2.2, negative quantities and extensions. +CREATE TABLE IF NOT EXISTS credit_memo_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + credit_memo_id UUID NOT NULL REFERENCES credit_memos (id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + line_type TEXT NOT NULL DEFAULT 'PRODUCT', + parent_line_id UUID NULL REFERENCES credit_memo_lines (id) ON DELETE CASCADE, + product_id UUID NULL REFERENCES products (id) ON DELETE RESTRICT, + charge_code_id UUID NULL REFERENCES charge_codes (id), + invoice_line_id UUID NULL REFERENCES invoice_lines (id) ON DELETE SET NULL, + sku TEXT NULL, + description TEXT NOT NULL, + quantity NUMERIC(12, 4) NULL, + uom TEXT NULL, + price_uom TEXT NULL, + uom_qty NUMERIC(12, 4) NULL, + price_uom_qty NUMERIC(12, 4) NULL, + unit_price NUMERIC(12, 4) NULL, + priced_unit_price NUMERIC(12, 4) NULL, + price_source TEXT NOT NULL DEFAULT 'PRICE_LIST', + override_reason TEXT NULL, + discount_percent NUMERIC(7, 4) NULL, + discount_amount NUMERIC(12, 2) NULL, + discount_reason TEXT NULL, + price_adjusted_by TEXT NULL, + line_total NUMERIC(12, 2) NULL, + taxable BOOLEAN NOT NULL DEFAULT TRUE, + revenue_account_code TEXT NULL, + restock BOOLEAN NOT NULL DEFAULT FALSE, + unit_cost NUMERIC(12, 4) NULL, + cost NUMERIC(12, 2) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT credit_memo_lines_line_type_check CHECK (line_type IN ('PRODUCT', 'KIT', 'COMPONENT', 'CHARGE', 'TEXT')), + CONSTRAINT credit_memo_lines_price_source_check CHECK (price_source IN ('PRICE_LIST', 'QUOTE', 'OVERRIDE', 'MANUAL', 'NONE')), + CONSTRAINT credit_memo_lines_pair_positive CHECK ( + (uom_qty IS NULL AND price_uom_qty IS NULL) OR (uom_qty > 0 AND price_uom_qty > 0)), + CONSTRAINT credit_memo_lines_parent_only_on_component CHECK ((line_type = 'COMPONENT') = (parent_line_id IS NOT NULL)), + CONSTRAINT credit_memo_lines_restock_stocked CHECK ( + NOT restock OR (line_type IN ('PRODUCT', 'COMPONENT') AND product_id IS NOT NULL)), + CONSTRAINT credit_memo_lines_shape CHECK ( + (line_type = 'TEXT' AND quantity IS NULL AND uom IS NULL AND price_uom IS NULL AND uom_qty IS NULL + AND price_uom_qty IS NULL AND unit_price IS NULL AND line_total IS NULL AND product_id IS NULL + AND charge_code_id IS NULL) + OR (line_type IN ('PRODUCT', 'KIT') AND quantity IS NOT NULL AND quantity < 0 AND uom IS NOT NULL + AND price_uom IS NOT NULL AND uom_qty IS NOT NULL AND price_uom_qty IS NOT NULL + AND unit_price IS NOT NULL AND unit_price >= 0 AND line_total IS NOT NULL AND line_total <= 0 + AND charge_code_id IS NULL) + OR (line_type = 'COMPONENT' AND product_id IS NOT NULL AND parent_line_id IS NOT NULL AND quantity IS NOT NULL + AND quantity < 0 AND uom IS NOT NULL AND price_uom IS NOT NULL AND uom_qty IS NOT NULL + AND price_uom_qty IS NOT NULL AND unit_price IS NOT NULL AND unit_price = 0 AND line_total IS NOT NULL + AND line_total = 0 AND charge_code_id IS NULL AND taxable = FALSE) + OR (line_type = 'CHARGE' AND quantity IS NOT NULL AND quantity < 0 AND uom IS NOT NULL + AND price_uom IS NOT NULL AND uom_qty IS NOT NULL AND price_uom_qty IS NOT NULL + AND unit_price IS NOT NULL AND unit_price >= 0 AND line_total IS NOT NULL AND line_total <= 0 + AND charge_code_id IS NOT NULL AND product_id IS NULL) + ) +); +CREATE INDEX IF NOT EXISTS idx_credit_memo_lines_memo ON credit_memo_lines (credit_memo_id, position); +CREATE INDEX IF NOT EXISTS idx_credit_memo_lines_invoice_line ON credit_memo_lines (invoice_line_id) + WHERE invoice_line_id IS NOT NULL; + +-- One ADJUST charge line per migrated memo (quantity -1 EA, unit price the amount, untaxed). +INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, charge_code_id, description, quantity, uom, price_uom, + uom_qty, price_uom_qty, unit_price, price_source, line_total, taxable, revenue_account_code) +SELECT m.id, 0, 'CHARGE', cc.id, LEFT(m.reason, 500), -1, 'EA', 'EA', 1, 1, m.amount, 'MANUAL', -m.amount, FALSE, + cc.revenue_account_code +FROM credit_memos m +CROSS JOIN LATERAL (SELECT id, revenue_account_code FROM charge_codes WHERE code = 'ADJUST') cc +WHERE NOT EXISTS (SELECT 1 FROM credit_memo_lines l WHERE l.credit_memo_id = m.id); + +CREATE INDEX IF NOT EXISTS idx_invoices_created_id ON invoices (created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_credit_memos_created_id ON credit_memos (created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_invoices_project ON invoices (project_id) WHERE project_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_invoices_ship_to ON invoices (ship_to_id) WHERE ship_to_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_credit_memos_project ON credit_memos (project_id) WHERE project_id IS NOT NULL; diff --git a/core/migrations/down/097_invoices_wire_contract_down.sql b/core/migrations/down/097_invoices_wire_contract_down.sql new file mode 100644 index 00000000..881bf315 --- /dev/null +++ b/core/migrations/down/097_invoices_wire_contract_down.sql @@ -0,0 +1,90 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Rollback for 097_invoices_wire_contract.sql. Lives in migrations/down/ so +-- cmd/migrate's `migrations/*.sql` glob cannot pick it up as a forward +-- migration. The drops lose the numbers, revisions, credit memo lines, voids +-- and the discount snapshot the migration wrote; the documents themselves +-- stay. The legacy text columns come back from the terms the rows now point +-- at (the term's code). A WRITTEN_OFF invoice goes back as PAID and a DRAFT or +-- OPEN credit memo as PENDING or APPLIED, the old vocabulary's nearest word; +-- the credit memo lines of a posted memo cannot be held by the old shape and +-- are dropped with their table. + +DROP INDEX IF EXISTS idx_credit_memos_project; +DROP INDEX IF EXISTS idx_invoices_ship_to; +DROP INDEX IF EXISTS idx_invoices_project; +DROP INDEX IF EXISTS idx_credit_memos_created_id; +DROP INDEX IF EXISTS idx_invoices_created_id; + +DROP TABLE IF EXISTS credit_memo_lines; + +ALTER TABLE credit_memos DROP CONSTRAINT IF EXISTS credit_memos_void_columns; +ALTER TABLE credit_memos DROP CONSTRAINT IF EXISTS credit_memos_number_when_posted; +ALTER TABLE credit_memos DROP CONSTRAINT IF EXISTS credit_memos_number_key; +ALTER TABLE credit_memos DROP CONSTRAINT IF EXISTS credit_memos_amounts_nonpositive; +ALTER TABLE credit_memos DROP CONSTRAINT IF EXISTS credit_memos_reason_code_check; +ALTER TABLE credit_memos DROP CONSTRAINT IF EXISTS credit_memos_currency_format; +ALTER TABLE credit_memos DROP CONSTRAINT IF EXISTS credit_memos_status_check; +ALTER TABLE credit_memos ALTER COLUMN status DROP DEFAULT; +UPDATE credit_memos SET status = CASE WHEN status = 'DRAFT' THEN 'PENDING' WHEN status IN ('OPEN', 'PARTIAL', 'APPLIED') THEN 'APPLIED' ELSE status END; +ALTER TABLE credit_memos ADD CONSTRAINT credit_memos_status_check CHECK (status IN ('PENDING', 'APPLIED', 'VOID')); +ALTER TABLE credit_memos ALTER COLUMN status SET DEFAULT 'PENDING'; +ALTER TABLE credit_memos ALTER COLUMN amount TYPE NUMERIC(10, 2); +ALTER TABLE credit_memos DROP COLUMN IF EXISTS voided_on; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS void_reason; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS voided_by; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS voided_at; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS memo_date; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS gl_entry_id; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS tax_rate; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS total_amount; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS tax_amount; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS subtotal; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS reason_code; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS pos_return_id; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS ship_to_id; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS project_id; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS branch_id; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS currency; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS number; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS revision; +ALTER TABLE credit_memos DROP COLUMN IF EXISTS updated_at; +ALTER TABLE credit_memos ALTER COLUMN created_at DROP NOT NULL; + +ALTER TABLE invoice_lines DROP COLUMN IF EXISTS price_adjusted_by; +ALTER TABLE invoice_lines DROP COLUMN IF EXISTS override_reason; +ALTER TABLE invoice_lines DROP COLUMN IF EXISTS priced_unit_price; + +ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_void_columns; +ALTER TABLE invoices DROP COLUMN IF EXISTS voided_on; +ALTER TABLE invoices DROP COLUMN IF EXISTS void_reason; +ALTER TABLE invoices DROP COLUMN IF EXISTS voided_by; +ALTER TABLE invoices DROP COLUMN IF EXISTS voided_at; + +ALTER TABLE customers ADD COLUMN IF NOT EXISTS payment_terms VARCHAR(20) DEFAULT 'NET30'; +UPDATE customers c SET payment_terms = LEFT(pt.code, 20) FROM payment_terms pt WHERE pt.id = c.payment_terms_id; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS payment_terms VARCHAR(20) DEFAULT 'NET30'; +UPDATE invoices i SET payment_terms = LEFT(pt.code, 20) FROM payment_terms pt WHERE pt.id = i.payment_terms_id; +ALTER TABLE invoices DROP COLUMN IF EXISTS discount_percent; +ALTER TABLE invoices DROP COLUMN IF EXISTS discount_due_date; +ALTER TABLE invoices DROP COLUMN IF EXISTS payment_terms_id; + +ALTER TABLE invoices ALTER COLUMN due_date TYPE TIMESTAMPTZ USING due_date::timestamptz; +ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_status_check; +UPDATE invoices SET status = 'PAID' WHERE status = 'WRITTEN_OFF'; +ALTER TABLE invoices ADD CONSTRAINT invoices_status_check + CHECK (status IN ('UNPAID', 'PARTIAL', 'PAID', 'VOID', 'OVERDUE')); + +ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_number_key; +ALTER TABLE invoices ALTER COLUMN number DROP NOT NULL; +ALTER TABLE invoices ALTER COLUMN number DROP DEFAULT; +ALTER TABLE invoices DROP COLUMN IF EXISTS number; + +DROP FUNCTION IF EXISTS credit_memo_next_number(); +DROP FUNCTION IF EXISTS invoice_next_number(); +DROP TABLE IF EXISTS document_counters; + +ALTER TABLE invoices DROP COLUMN IF EXISTS revision; +ALTER TABLE invoices ALTER COLUMN updated_at DROP NOT NULL; +ALTER TABLE invoices ALTER COLUMN created_at DROP NOT NULL; From a38d1201419c73e866601313394b09e103e17eed Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 12:45:15 -0700 Subject: [PATCH 03/25] gl: PostReversal (a dated, currencied reversal of a posted entry); account: credit memo and reversal row types Co-Authored-By: Claude Sonnet 5.5 --- core/internal/account/model.go | 5 +++ core/internal/gl/postentry.go | 52 +++++++++++++++++++++++++ core/internal/gl/postentry_test.go | 62 ++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/core/internal/account/model.go b/core/internal/account/model.go index 1dcaada5..11080ced 100644 --- a/core/internal/account/model.go +++ b/core/internal/account/model.go @@ -16,6 +16,11 @@ const ( TransactionTypePayment TransactionType = "PAYMENT" TransactionTypeAdjustment TransactionType = "ADJUSTMENT" TransactionTypeRefund TransactionType = "REFUND" + // The credit memo and reversal rows of ADR 0005 section 9.3 (the type + // CHECK has allowed them since C2-2): a posted credit memo is a negative + // CREDIT_MEMO row; a voided invoice or credit memo is the opposite REVERSAL. + TransactionTypeCreditMemo TransactionType = "CREDIT_MEMO" + TransactionTypeReversal TransactionType = "REVERSAL" ) type CustomerTransaction struct { diff --git a/core/internal/gl/postentry.go b/core/internal/gl/postentry.go index 83f1fbb4..38c39d27 100644 --- a/core/internal/gl/postentry.go +++ b/core/internal/gl/postentry.go @@ -118,3 +118,55 @@ func (s *Service) PostEntry(ctx context.Context, in PostingInput) (*JournalEntry } return entry, nil } + +// ReversalInput names the entry to reverse and the business date and currency +// of the reversal (ADR 0005 section 8.2: a void reverses the document's whole +// entry, dated the void date, in the document's currency). +type ReversalInput struct { + EntryID uuid.UUID + EntryDate time.Time + Currency string + Reason string + PostedBy string +} + +// ErrAlreadyReversed is the refusal to reverse an entry a reversal already +// points at (the one-reversal-per-entry index would refuse it too). +var ErrAlreadyReversed = errors.New("gl: the entry is already reversed") + +// PostReversal books the net zero reversal of a POSTED entry through the +// caller's transaction: the same accounts, debits and credits swapped, source +// REVERSAL, reverses_entry_id set, dated the given business date (period +// checked like any posting). It refuses a call with no transaction open, an +// entry that is not POSTED, and an entry already reversed. +func (s *Service) PostReversal(ctx context.Context, in ReversalInput) (*JournalEntry, error) { + if !database.InTx(ctx) { + return nil, ErrNoTransaction + } + orig, err := s.repo.GetJournalEntry(ctx, in.EntryID) + if err != nil { + return nil, err + } + if orig.Status != StatusPosted { + return nil, fmt.Errorf("gl: only a POSTED entry can be reversed (current: %s)", orig.Status) + } + if reversed, err := s.repo.IsReversed(ctx, in.EntryID); err != nil { + return nil, err + } else if reversed { + return nil, ErrAlreadyReversed + } + memo := fmt.Sprintf("Reversal of #%d", orig.EntryNumber) + if in.Reason != "" { + memo += ": " + in.Reason + } + legs := make([]Leg, 0, len(orig.Lines)) + for _, l := range orig.Lines { + legs = append(legs, Leg{AccountCode: l.AccountCode, Description: "Reversal: " + l.Description, + Debit: l.Credit, Credit: l.Debit}) + } + origID := orig.ID + return s.PostEntry(ctx, PostingInput{ + EntryDate: in.EntryDate, Memo: memo, Source: SourceReversal, SourceRefID: &origID, + ReversesEntryID: &origID, Currency: in.Currency, PostedBy: in.PostedBy, Legs: legs, + }) +} diff --git a/core/internal/gl/postentry_test.go b/core/internal/gl/postentry_test.go index b40c61c0..9c15811a 100644 --- a/core/internal/gl/postentry_test.go +++ b/core/internal/gl/postentry_test.go @@ -110,3 +110,65 @@ func TestPostEntry(t *testing.T) { t.Errorf("entry into a closed period = %v, want ErrPeriodClosed", err) } } + +// RULE (ADR 0005 section 8.2): a reversal is the original's legs swapped, +// source REVERSAL, linked to the original, dated and currencied as asked, once +// per entry, inside the act's transaction. +func TestPostReversal(t *testing.T) { + db := testutil.RequireDB(t) + ctx := context.Background() + svc := gl.NewService(gl.NewRepository(db), nil, slog.Default()) + ref := uuid.New() + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM gl_journal_lines WHERE journal_entry_id IN (SELECT id FROM gl_journal_entries WHERE source_ref_id = $1 OR reverses_entry_id IN (SELECT id FROM gl_journal_entries WHERE source_ref_id = $1))`, ref) + _, _ = db.Pool.Exec(ctx, `DELETE FROM gl_journal_entries WHERE reverses_entry_id IN (SELECT id FROM gl_journal_entries WHERE source_ref_id = $1)`, ref) + _, _ = db.Pool.Exec(ctx, `DELETE FROM gl_journal_entries WHERE source_ref_id = $1`, ref) + }) + var orig uuid.UUID + day := time.Date(2031, 3, 4, 0, 0, 0, 0, time.UTC) + err := db.RunInTx(ctx, func(ctx context.Context) error { + e, err := svc.PostEntry(ctx, gl.PostingInput{EntryDate: time.Now(), Memo: "orig", Source: gl.SourceInvoice, SourceRefID: &ref, Currency: "CAD", + Legs: []gl.Leg{{AccountCode: gl.AccountCodeAR, Debit: 1100}, {AccountCode: gl.AccountCodeRevenue, Credit: 1000}, {AccountCode: gl.AccountCodeSalesTax, Credit: 100}}}) + if err != nil { + return err + } + orig = e.ID + return nil + }) + if err != nil { + t.Fatal(err) + } + if _, err := svc.PostReversal(ctx, gl.ReversalInput{EntryID: orig, EntryDate: day, Currency: "CAD"}); !errors.Is(err, gl.ErrNoTransaction) { + t.Fatalf("PostReversal outside a transaction = %v, want ErrNoTransaction", err) + } + err = db.RunInTx(ctx, func(ctx context.Context) error { + rev, err := svc.PostReversal(ctx, gl.ReversalInput{EntryID: orig, EntryDate: day, Currency: "CAD", Reason: "void", PostedBy: "tester"}) + if err != nil || rev == nil { + t.Fatalf("PostReversal = %v, %v", rev, err) + } + if _, err := svc.PostReversal(ctx, gl.ReversalInput{EntryID: orig, EntryDate: day, Currency: "CAD"}); !errors.Is(err, gl.ErrAlreadyReversed) { + t.Errorf("second reversal = %v, want ErrAlreadyReversed", err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + var source, cur string + var date time.Time + var debits, credits int64 + if err := db.Pool.QueryRow(ctx, ` + SELECT e.source, e.currency, e.entry_date, COALESCE(SUM(l.debit * 100), 0)::bigint, COALESCE(SUM(l.credit * 100), 0)::bigint + FROM gl_journal_entries e JOIN gl_journal_lines l ON l.journal_entry_id = e.id + WHERE e.reverses_entry_id = $1 GROUP BY e.id`, orig).Scan(&source, &cur, &date, &debits, &credits); err != nil { + t.Fatal(err) + } + if source != "REVERSAL" || cur != "CAD" || !date.Equal(day) || debits != 1100 || credits != 1100 { + t.Errorf("reversal = %s %s %s debit %d credit %d, want REVERSAL CAD %s 1100/1100", source, cur, date, debits, credits, day) + } + var arCredit int64 + if err := db.Pool.QueryRow(ctx, `SELECT (l.credit * 100)::bigint FROM gl_journal_lines l JOIN gl_journal_entries e ON e.id = l.journal_entry_id + JOIN gl_accounts a ON a.id = l.account_id WHERE e.reverses_entry_id = $1 AND a.code = $2`, orig, gl.AccountCodeAR).Scan(&arCredit); err != nil || arCredit != 1100 { + t.Errorf("the reversal credits AR %d, %v, want 1100 (the original's debit)", arCredit, err) + } +} From 02891e9e27f6ccf6445f659d0a7605069462d4b5 Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 12:47:15 -0700 Subject: [PATCH 04/25] inventory: UnrestockQty, the reverse of RestockQty (a voided restocking credit memo) Co-Authored-By: Claude Sonnet 5.5 --- core/internal/inventory/qty.go | 49 +++++++++++++++++++++++ core/internal/inventory/qty_test.go | 15 +++++++ core/internal/inventory/repository.go | 16 ++++++++ core/internal/inventory/service_test.go | 4 ++ core/internal/portal/service_repo_test.go | 1 + 5 files changed, 85 insertions(+) diff --git a/core/internal/inventory/qty.go b/core/internal/inventory/qty.go index cc038927..7d12ed93 100644 --- a/core/internal/inventory/qty.go +++ b/core/internal/inventory/qty.go @@ -211,3 +211,52 @@ func scaled(f float64) int64 { } return int64(f*10000 + 0.5) } + +// UnrestockQty takes qty back off on hand at the branch, the reverse of +// RestockQty (a voided restocking credit memo). It takes from the rows +// holding the most unallocated stock first and never below what is +// allocated: a quantity the branch no longer has free is +// ErrInsufficientAvailable and moves nothing. +func (s *Service) UnrestockQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error { + if qty <= 0 { + return nil + } + items, err := s.repo.LockBranchInventory(ctx, productID, branchID) + if err != nil { + return err + } + type slot struct { + id uuid.UUID + free int64 + } + var slots []slot + var total int64 + for i := range items { + if f := scaled(items[i].Quantity) - scaled(items[i].Allocated); f > 0 { + slots = append(slots, slot{items[i].ID, f}) + total += f + } + } + if total < int64(qty) { + return fmt.Errorf("%w: product %s has %d free, the void takes back %d", ErrInsufficientAvailable, productID, total, qty) + } + remaining := int64(qty) + for remaining > 0 { + best := 0 + for i := range slots { + if slots[i].free > slots[best].free { + best = i + } + } + take := remaining + if slots[best].free < take { + take = slots[best].free + } + if err := s.repo.UnstockQty(ctx, slots[best].id, take); err != nil { + return err + } + slots[best].free -= take + remaining -= take + } + return nil +} diff --git a/core/internal/inventory/qty_test.go b/core/internal/inventory/qty_test.go index edbcb607..4c3a6036 100644 --- a/core/internal/inventory/qty_test.go +++ b/core/internal/inventory/qty_test.go @@ -140,4 +140,19 @@ func TestQty_ReleaseFulfillRestock(t *testing.T) { if err := w.svc.RestockQty(ctx, w.productID, uuid.New(), q(t, "1")); err == nil { t.Error("restock at a branch with no row succeeded") } + + // The reverse (a voided restocking credit memo): 15 on hand, 2 allocated, + // so 13 are free; taking 13 back leaves exactly the allocation, 14 is refused. + if err := w.svc.UnrestockQty(ctx, w.productID, w.branchID, q(t, "13.0001")); !errors.Is(err, inventory.ErrInsufficientAvailable) { + t.Errorf("unrestock past the free stock = %v, want ErrInsufficientAvailable", err) + } + if got := read(`SELECT sum(quantity)::text FROM inventory WHERE product_id = $1`, w.productID); got != "15.0000" { + t.Errorf("a refused unrestock moved on hand: %s", got) + } + if err := w.svc.UnrestockQty(ctx, w.productID, w.branchID, q(t, "13")); err != nil { + t.Fatalf("unrestock: %v", err) + } + if got := read(`SELECT sum(quantity)::text || '/' || sum(allocated)::text FROM inventory WHERE product_id = $1`, w.productID); got != "2.0000/2.0000" { + t.Errorf("on hand/allocated after unrestocking 13 = %s, want 2.0000/2.0000", got) + } } diff --git a/core/internal/inventory/repository.go b/core/internal/inventory/repository.go index 4ad450fe..41afed4f 100644 --- a/core/internal/inventory/repository.go +++ b/core/internal/inventory/repository.go @@ -35,6 +35,7 @@ type Repository interface { DeallocateStockQty(ctx context.Context, inventoryID uuid.UUID, delta int64) error FulfillStockQty(ctx context.Context, inventoryID uuid.UUID, delta int64) error RestockQty(ctx context.Context, inventoryID uuid.UUID, delta int64) error + UnstockQty(ctx context.Context, inventoryID uuid.UUID, delta int64) error } type PostgresRepository struct { @@ -332,3 +333,18 @@ func (r *PostgresRepository) RestockQty(ctx context.Context, inventoryID uuid.UU } return nil } + +// UnstockQty takes delta (scale 4) off on hand, never below what is allocated: +// the reverse of RestockQty (a voided restocking credit memo). +func (r *PostgresRepository) UnstockQty(ctx context.Context, inventoryID uuid.UUID, delta int64) error { + ct, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE inventory SET quantity = quantity - $1::numeric / 10000, updated_at = NOW() + WHERE id = $2 AND (quantity - allocated) >= $1::numeric / 10000`, delta, inventoryID) + if err != nil { + return fmt.Errorf("failed to take stock back out: %w", err) + } + if ct.RowsAffected() == 0 { + return ErrInsufficientAvailable + } + return nil +} diff --git a/core/internal/inventory/service_test.go b/core/internal/inventory/service_test.go index 5c381965..f3a8e261 100644 --- a/core/internal/inventory/service_test.go +++ b/core/internal/inventory/service_test.go @@ -260,3 +260,7 @@ func (m *MockRepository) FulfillStockQty(ctx context.Context, inventoryID uuid.U func (m *MockRepository) RestockQty(ctx context.Context, inventoryID uuid.UUID, delta int64) error { return nil } + +func (m *MockRepository) UnstockQty(ctx context.Context, inventoryID uuid.UUID, delta int64) error { + return nil +} diff --git a/core/internal/portal/service_repo_test.go b/core/internal/portal/service_repo_test.go index bbf9b059..154108bb 100644 --- a/core/internal/portal/service_repo_test.go +++ b/core/internal/portal/service_repo_test.go @@ -482,6 +482,7 @@ func (f *fakeInventoryRepo) AllocateStockQty(context.Context, uuid.UUID, int64) func (f *fakeInventoryRepo) DeallocateStockQty(context.Context, uuid.UUID, int64) error { return nil } func (f *fakeInventoryRepo) FulfillStockQty(context.Context, uuid.UUID, int64) error { return nil } func (f *fakeInventoryRepo) RestockQty(context.Context, uuid.UUID, int64) error { return nil } +func (f *fakeInventoryRepo) UnstockQty(context.Context, uuid.UUID, int64) error { return nil } type fakeOrderRepo struct { created []order.Order From 73b1e3eeb168e1e9bb760407d25d3d6883c3bb82 Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 13:06:59 -0700 Subject: [PATCH 05/25] invoices and credit memos: the module on the contract (wire types, repository, void, credit memo lifecycle, gapless numbering), callers moved; WIP tests --- core/internal/app/orderwire/orderwire.go | 7 +- core/internal/app/seed/seed.go | 77 +- core/internal/app/serve/serve.go | 7 +- core/internal/app/serve/wire_branch_wall.go | 9 + core/internal/customer/repository.go | 2 +- core/internal/dashboard/repository.go | 2 +- core/internal/document/service.go | 63 +- core/internal/document/service_test.go | 240 +++-- core/internal/invoice/credit_build.go | 450 ++++++++++ core/internal/invoice/fulfilment.go | 59 +- core/internal/invoice/handler.go | 494 +++++++++-- core/internal/invoice/input.go | 343 ++++++++ core/internal/invoice/model.go | 314 ++++++- core/internal/invoice/repository.go | 825 ++++++++++++------ core/internal/invoice/repository_cm.go | 520 +++++++++++ core/internal/invoice/service.go | 669 +++++++++----- .../internal/invoice/service_audit_tx_test.go | 4 +- core/internal/invoice/service_cm.go | 601 +++++++++++++ core/internal/invoice/service_test.go | 190 +--- core/internal/order/alloc.go | 9 +- core/internal/order/fulfil.go | 57 +- core/internal/order/invoice_void.go | 212 +++++ core/internal/order/model.go | 18 +- core/internal/order/repository.go | 24 +- core/internal/order/tax_provider_test.go | 4 +- core/internal/order/wire_test.go | 4 +- core/internal/payment/handler.go | 9 + core/internal/payment/service.go | 50 +- .../internal/payment/service_audit_tx_test.go | 10 +- core/internal/portal/model.go | 2 + core/internal/portal/repository.go | 30 +- core/internal/pos/service.go | 15 +- core/internal/reporting/repository.go | 4 +- core/internal/salesdoc/billing.go | 34 + core/internal/salesdoc/rate.go | 56 ++ core/internal/salesdoc/scan.go | 84 ++ 36 files changed, 4435 insertions(+), 1063 deletions(-) create mode 100644 core/internal/invoice/credit_build.go create mode 100644 core/internal/invoice/input.go create mode 100644 core/internal/invoice/repository_cm.go create mode 100644 core/internal/invoice/service_cm.go create mode 100644 core/internal/order/invoice_void.go create mode 100644 core/internal/salesdoc/rate.go create mode 100644 core/internal/salesdoc/scan.go diff --git a/core/internal/app/orderwire/orderwire.go b/core/internal/app/orderwire/orderwire.go index 290c7264..17fab3f7 100644 --- a/core/internal/app/orderwire/orderwire.go +++ b/core/internal/app/orderwire/orderwire.go @@ -77,7 +77,7 @@ func New(d Deps) *order.Service { &auditAdapter{auditLog: auditLog}, exposureChecker, logger, ).WithOutbox(outbox.NewWriter(d.DB, d.Config.EventsOrg)).WithTxRunner(d.DB) - return order.NewService(order.NewRepository(d.DB)). + svc := order.NewService(order.NewRepository(d.DB)). WithOutbox(outbox.NewWriter(d.DB, d.Config.EventsOrg)). WithTxRunner(d.DB). WithAuditLog(auditLog). @@ -86,6 +86,11 @@ func New(d Deps) *order.Service { WithInvoices(d.Invoices). WithTaxProvider(&TaxProviderAdapter{Svc: NewTaxService(d.DB, d.Config, logger)}). WithExposureGate(exposureChecker, &ExposureOverriderAdapter{Svc: exposureSvc}) + // The invoice void reaches back into the order (its row lock, its lines, + // its stock and status): the invoice module cannot import this one, so the + // seam is wired here, in the one constructor both roles use. + d.Invoices.WithOrders(svc).WithStock(d.Inventory) + return svc } // NewTaxService builds the tax service both the order provider adapter and diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index 7e9537e9..2cb51f1d 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -121,6 +121,11 @@ func resetTransactionalData(db *sql.DB) { if _, err := db.Exec(`ALTER SEQUENCE IF EXISTS quote_number_seq RESTART`); err != nil { log.Printf("resetTransactionalData: restart quote_number_seq: %v", err) } + // The invoices and credit memos are gone too, and their gapless counters + // start again at 1 so the demo's series stays unbroken from the first. + if _, err := db.Exec(`UPDATE document_counters SET next_value = 1 WHERE series IN ('invoice', 'credit_memo')`); err != nil { + log.Printf("resetTransactionalData: reset the invoice counters: %v", err) + } } // demoSeedEnv gates the entire command. Keep this name in sync with the @@ -572,9 +577,9 @@ func Run() { for _, c := range customers { plID := priceLevelIDs[c.PriceLevel] var cid string - err := db.QueryRow(`INSERT INTO customers (name, account_number, email, phone, address, credit_limit, balance_due, tier, payment_terms, payment_terms_id, price_level_id, primary_branch_id) - VALUES ($1,$2,$3,$4,$5,$6,0,$7,$8::text,COALESCE((SELECT id FROM payment_terms WHERE code = $8::text), payment_terms_default_id()),$9,$10) - ON CONFLICT (account_number) DO UPDATE SET name=$1, phone=$4, address=$5, tier=$7, payment_terms=$8::text, + err := db.QueryRow(`INSERT INTO customers (name, account_number, email, phone, address, credit_limit, balance_due, tier, payment_terms_id, price_level_id, primary_branch_id) + VALUES ($1,$2,$3,$4,$5,$6,0,$7,COALESCE((SELECT id FROM payment_terms WHERE code = $8::text), payment_terms_default_id()),$9,$10) + ON CONFLICT (account_number) DO UPDATE SET name=$1, phone=$4, address=$5, tier=$7, payment_terms_id=COALESCE((SELECT id FROM payment_terms WHERE code = $8::text), payment_terms_default_id()), price_level_id=$9, primary_branch_id=$10 RETURNING id`, c.Name, c.Acct, c.Email, c.Phone, c.Addr, c.CreditLimit, c.Tier, c.Terms, plID, c.PrimaryBranch).Scan(&cid) @@ -772,11 +777,16 @@ func Run() { orderIDs = append(orderIDs, orderID) orderCustMap[orderID] = custID invID := uuid.New() + // A share of the invoices are paid in full, a share partly paid and a + // share left unpaid; some of the unpaid ones are past their due date + // (overdue is computed from the due date, never stored). invStatus := "UNPAID" - if rand.Float32() < 0.65 { + partPaid := false + switch r := rand.Float32(); { + case r < 0.65: invStatus = "PAID" - } else if rand.Float32() < 0.3 { - invStatus = "OVERDUE" + case r < 0.75: + invStatus, partPaid = "PARTIAL", true } dueDate := orderDate.AddDate(0, 1, 0) taxRate := 0.12 // BC: GST 5% + PST 7% on building materials. @@ -784,14 +794,27 @@ func Run() { taxAmt := subtotal * taxRate total := subtotal + taxAmt - _, err = db.Exec(`INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_rate, tax_amount, due_date, payment_terms, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'NET30',$11)`, + // number is left to the column DEFAULT: the seed numbers its invoices + // through the same gapless counter as every other writer. + _, err = db.Exec(`INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_rate, tax_amount, + due_date, payment_terms_id, invoice_date, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::date,(SELECT payment_terms_id FROM customers WHERE id = $3),$11::date,$11)`, invID, orderID, custID, branchID, invStatus, total, subtotal, taxRate, taxAmt, dueDate, orderDate.AddDate(0, 0, 1)) if err == nil { invoiceIDs = append(invoiceIDs, invID) - if invStatus == "PAID" { + // the billed lines: the order's, in the shared line shape + db.Exec(`INSERT INTO invoice_lines (invoice_id, order_line_id, product_id, line_type, position, description, sku, + quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, price_each, price_source, line_total, taxable) + SELECT $1, ol.id, ol.product_id, ol.line_type, ol.position, ol.description, ol.sku, + ol.quantity, ol.uom, ol.price_uom, ol.uom_qty, ol.price_uom_qty, ol.unit_price, ol.unit_price, ol.price_source, ol.line_total, ol.taxable + FROM order_lines ol WHERE ol.order_id = $2 AND ol.line_type = 'PRODUCT'`, invID, orderID) + switch { + case invStatus == "PAID": db.Exec(`INSERT INTO payments (invoice_id, amount, method, reference, notes) VALUES ($1,$2,'CHECK','CHK-'||floor(random()*10000+1000)::text,'Payment in full')`, invID, total) + case partPaid: + db.Exec(`INSERT INTO payments (invoice_id, amount, method, reference, notes) + VALUES ($1,ROUND($2::numeric * 0.4, 2),'CHECK','CHK-'||floor(random()*10000+1000)::text,'Partial payment')`, invID, total) } } } @@ -1197,23 +1220,35 @@ func Run() { // ========================================================================= if len(invoiceIDs) > 3 { memos := []struct { - Reason string - Amt float64 - Status string + ReasonCode string + Reason string + Amt float64 + Status string }{ - {"Damaged material on delivery - 2x4x8 split ends", 125.00, "APPLIED"}, - {"Wrong product shipped - returned OSB", 285.00, "APPLIED"}, - {"Price adjustment per contract terms", 450.00, "PENDING"}, - {"Customer loyalty credit Q1", 200.00, "PENDING"}, + {"DAMAGE", "Damaged material on delivery - 2x4x8 split ends", 125.00, "OPEN"}, + {"RETURN", "Wrong product shipped - returned OSB", 285.00, "OPEN"}, + {"PRICE_ADJUSTMENT", "Price adjustment per contract terms", 450.00, "DRAFT"}, + {"OTHER", "Customer loyalty credit Q1", 200.00, "DRAFT"}, } for i, m := range memos { invID := invoiceIDs[i%len(invoiceIDs)] - var custIDStr string - db.QueryRow("SELECT customer_id FROM invoices WHERE id=$1", invID).Scan(&custIDStr) - if custIDStr != "" { - db.Exec(`INSERT INTO credit_memos (invoice_id, customer_id, amount, reason, status) - VALUES ($1,$2,$3,$4,$5)`, invID, custIDStr, m.Amt, m.Reason, m.Status) + // A posted memo numbers itself through the gapless counter (the column has + // no DEFAULT: a draft carries none); each memo has one ADJUST charge line. + var memoID string + err := db.QueryRow(`INSERT INTO credit_memos (invoice_id, customer_id, branch_id, currency, reason_code, reason, amount, status, + number, memo_date, subtotal, tax_amount, total_amount, tax_rate) + SELECT i.id, i.customer_id, i.branch_id, i.currency, $2, $3, $4::numeric, $5, + CASE WHEN $5 = 'DRAFT' THEN NULL ELSE credit_memo_next_number() END, + i.invoice_date, -$4::numeric, 0, -$4::numeric, 0 + FROM invoices i WHERE i.id = $1 RETURNING id`, invID, m.ReasonCode, m.Reason, m.Amt, m.Status).Scan(&memoID) + if err != nil { + log.Printf("Credit memo %q: %v", m.Reason, err) + continue } + db.Exec(`INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, charge_code_id, description, quantity, uom, price_uom, + uom_qty, price_uom_qty, unit_price, price_source, line_total, taxable, revenue_account_code) + SELECT $1, 0, 'CHARGE', cc.id, $2, -1, 'EA', 'EA', 1, 1, $3::numeric, 'MANUAL', -$3::numeric, FALSE, cc.revenue_account_code + FROM charge_codes cc WHERE cc.code = 'ADJUST'`, memoID, m.Reason, m.Amt) } fmt.Printf("Seed: %d Credit Memos\n", len(memos)) } diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index f2b5a0a7..1e5c3d5d 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -332,9 +332,10 @@ func Run() { // Invoice Module invoiceRepo := invoice.NewRepository(db) invoiceSvc := invoice.NewService(invoiceRepo, glSvc, accountSvc, db) - invoiceSvc.WithAuditLog(auditLog) - invoiceHandler := invoice.NewHandler(invoiceSvc) - invoiceHandler.RegisterRoutes(mux, scoped("admin", "owner", "sales", "finance")) + invoiceSvc.WithAuditLog(auditLog). + WithOutbox(outbox.NewWriter(db, cfg.EventsOrg)). + WithStock(inventorySvc) + wall.invoices(mux, invoiceSvc) // Deposit Module (customer prepayments held as 2200 liability, applied to AR) depositRepo := deposit.NewRepository(db) diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index d7662e2b..98281322 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -10,6 +10,7 @@ import ( "github.com/gablelbm/gable/internal/customer/customerguard" "github.com/gablelbm/gable/internal/document" "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/invoice" "github.com/gablelbm/gable/internal/location" "github.com/gablelbm/gable/internal/matching" "github.com/gablelbm/gable/internal/order" @@ -71,6 +72,14 @@ func (w *branchWall) orders(mux *http.ServeMux, svc *order.Service) { w.scoped("admin", "owner", "finance", "warehouse")) } +// invoices mounts the invoice and credit memo routes: the credit memo create +// takes branch_id from its body and the writes address a document by path id, +// so the service holds both to the caller's branch context (ADR 0007 section +// 2.3) beside the wall every read already carries. +func (w *branchWall) invoices(mux *http.ServeMux, svc *invoice.Service) { + invoice.NewHandler(svc.WithBranchGuard(w.guard)).RegisterRoutes(mux, w.scoped("admin", "owner", "sales", "finance")) +} + func (w *branchWall) purchaseOrders(mux *http.ServeMux, h *purchase_order.Handler) { h.WithBranchGuard(w.guard).RegisterRoutes(mux, w.scoped("admin", "owner", "purchasing")) } diff --git a/core/internal/customer/repository.go b/core/internal/customer/repository.go index a7cc8287..b2127f35 100644 --- a/core/internal/customer/repository.go +++ b/core/internal/customer/repository.go @@ -533,7 +533,7 @@ func (r *PostgresRepository) OpenDocuments(ctx context.Context, customerID uuid. err := r.db.GetExecutor(ctx).QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM orders WHERE customer_id = $1 AND status IN ('DRAFT', 'CONFIRMED', 'ON_HOLD')), - EXISTS (SELECT 1 FROM invoices WHERE customer_id = $1 AND status IN ('UNPAID', 'PARTIAL', 'OVERDUE')), + EXISTS (SELECT 1 FROM invoices WHERE customer_id = $1 AND status IN ('UNPAID', 'PARTIAL')), EXISTS (SELECT 1 FROM credit_memos WHERE customer_id = $1 AND status = 'PENDING'), EXISTS (SELECT 1 FROM customer_deposits WHERE customer_id = $1 AND status = 'OPEN' AND amount > applied_amount)`, customerID).Scan(&orders, &invoices, &memos, &deposits) diff --git a/core/internal/dashboard/repository.go b/core/internal/dashboard/repository.go index e4b9c7ef..61c7b59c 100644 --- a/core/internal/dashboard/repository.go +++ b/core/internal/dashboard/repository.go @@ -77,7 +77,7 @@ func (r *PostgresRepository) GetDashboardSummary(ctx context.Context, branchID * outstanding AS ( SELECT (COALESCE(SUM(total_amount), 0) * 100)::bigint AS amount, COUNT(*) AS cnt FROM invoices - WHERE status IN ('UNPAID', 'PARTIAL', 'OVERDUE') + WHERE status IN ('UNPAID', 'PARTIAL') AND ($4::uuid IS NULL OR branch_id = $4) ) SELECT diff --git a/core/internal/document/service.go b/core/internal/document/service.go index 7185b5ff..4a783b8f 100644 --- a/core/internal/document/service.go +++ b/core/internal/document/service.go @@ -34,8 +34,12 @@ func (s *Service) GenerateInvoicePDF(ctx context.Context, inv *invoice.Invoice, WithPageNumber(). Build()) + title := "GABLE LBM - INVOICE" + if inv.Status == invoice.InvoiceStatusVoid { + title = "GABLE LBM - INVOICE - VOID" + } m.AddRow(20, - text.NewCol(12, "GABLE LBM - INVOICE", props.Text{ + text.NewCol(12, title, props.Text{ Size: 18, Style: fontstyle.Bold, Align: align.Center, @@ -44,7 +48,7 @@ func (s *Service) GenerateInvoicePDF(ctx context.Context, inv *invoice.Invoice, m.AddRow(20, text.NewCol(6, fmt.Sprintf("Bill To: %s\nAccount: %s", cust.Name, cust.AccountNumber), props.Text{Size: 10}), - text.NewCol(6, fmt.Sprintf("Invoice #: %s\nDate: %s", inv.ID, inv.CreatedAt.Format("2006-01-02")), props.Text{Size: 10, Align: align.Right}), + text.NewCol(6, fmt.Sprintf("Invoice #: %s\nDate: %s", inv.Number, inv.InvoiceDate), props.Text{Size: 10, Align: align.Right}), ) m.AddRow(10, @@ -54,34 +58,39 @@ func (s *Service) GenerateInvoicePDF(ctx context.Context, inv *invoice.Invoice, text.NewCol(3, "Total", props.Text{Style: fontstyle.Bold, Align: align.Right}), ) - for _, line := range inv.Lines { - prod, err := s.productRepo.GetProduct(ctx, line.ProductID) - desc := "Unknown Product" - if err == nil { - desc = fmt.Sprintf("%s - %s", prod.SKU, prod.Description) + for i := range inv.Lines { + line := &inv.Lines[i] + desc := line.Description + if line.SKU != nil && *line.SKU != "" { + desc = fmt.Sprintf("%s - %s", *line.SKU, line.Description) + } + // A text line is a note; a kit's components are shown with their kit + // at no charge; every other line prints its quantity, its price per + // price unit and its extension, exactly as the invoice stores them. + if line.Quantity == nil || line.UnitPrice == nil || line.LineTotal == nil { + m.AddRow(10, text.NewCol(12, desc, props.Text{Size: 9})) + continue + } + uom, priceUOM := "", "" + if line.UOM != nil { + uom = " " + *line.UOM + } + if line.PriceUOM != nil { + priceUOM = "/" + *line.PriceUOM } - m.AddRow(10, text.NewCol(4, desc, props.Text{Size: 9}), - text.NewCol(2, fmt.Sprintf("%.2f", line.Quantity), props.Text{Size: 9, Align: align.Center}), - text.NewCol(3, fmt.Sprintf("$%.2f", float64(line.PriceEach)/100.0), props.Text{Size: 9, Align: align.Right}), - text.NewCol(3, fmt.Sprintf("$%.2f", (line.Quantity*float64(line.PriceEach))/100.0), props.Text{Size: 9, Align: align.Right}), + text.NewCol(2, line.Quantity.WireString()+uom, props.Text{Size: 9, Align: align.Center}), + text.NewCol(3, fmt.Sprintf("$%s%s", line.UnitPrice.DecimalString(), priceUOM), props.Text{Size: 9, Align: align.Right}), + text.NewCol(3, "$"+line.LineTotal.DecimalString(), props.Text{Size: 9, Align: align.Right}), ) } - // Subtotal and tax must be shown separately, or the line extensions - // visibly do not add up to TOTAL DUE. Showing the tax as its own line is - // also a statutory requirement in the GST/HST jurisdictions this product - // targets. An invoice written before those columns were populated carries - // zero in both, and total == subtotal + tax means the total IS the - // subtotal in that case — so nothing is invented. - subtotal, taxAmount := inv.Subtotal, inv.TaxAmount - if subtotal == 0 && taxAmount == 0 { - subtotal = inv.TotalAmount - } - + // Subtotal and tax are shown separately, or the line extensions visibly do + // not add up to TOTAL DUE; the tax as its own line is also a statutory + // requirement in the GST/HST jurisdictions this product targets. m.AddRow(10, - text.NewCol(12, fmt.Sprintf("SUBTOTAL: $%.2f", float64(subtotal)/100.0), props.Text{ + text.NewCol(12, "SUBTOTAL: $"+inv.SubtotalCents.DecimalString(), props.Text{ Top: 5, Align: align.Right, Size: 10, @@ -89,18 +98,18 @@ func (s *Service) GenerateInvoicePDF(ctx context.Context, inv *invoice.Invoice, ) taxLabel := "TAX" - if inv.TaxRate > 0 { - taxLabel = fmt.Sprintf("TAX @ %.2f%%", inv.TaxRate*100) + if inv.TaxRatePercent != nil { + taxLabel = fmt.Sprintf("TAX @ %s%%", *inv.TaxRatePercent) } m.AddRow(10, - text.NewCol(12, fmt.Sprintf("%s: $%.2f", taxLabel, float64(taxAmount)/100.0), props.Text{ + text.NewCol(12, fmt.Sprintf("%s: $%s", taxLabel, inv.TaxCents.DecimalString()), props.Text{ Align: align.Right, Size: 10, }), ) m.AddRow(15, - text.NewCol(12, fmt.Sprintf("TOTAL DUE: $%.2f", float64(inv.TotalAmount)/100.0), props.Text{ + text.NewCol(12, "TOTAL DUE: $"+inv.TotalCents.DecimalString(), props.Text{ Top: 5, Style: fontstyle.Bold, Align: align.Right, diff --git a/core/internal/document/service_test.go b/core/internal/document/service_test.go index bcdf8450..5db46114 100644 --- a/core/internal/document/service_test.go +++ b/core/internal/document/service_test.go @@ -97,6 +97,36 @@ func min(a, b int) int { // --- invoice PDF --------------------------------------------------------- +// mkInvoice builds an invoice in the wire shape with the given total, number +// and date. +func mkInvoice(totalCents int64, mod func(*invoice.Invoice)) *invoice.Invoice { + inv := &invoice.Invoice{} + inv.ID = uuid.New() + inv.Number = "IN-000001" + inv.InvoiceDate = "2026-03-04" + inv.TotalCents = httpx.Cents(totalCents) + inv.SubtotalCents = httpx.Cents(totalCents) + if mod != nil { + mod(inv) + } + return inv +} + +// mkLine builds a priced product line: quantity, unit and price as the invoice +// stores them, the extension in cents. +func mkLine(sku, desc, qty, uom string, unitPrice int64, totalCents int64) invoice.InvoiceLine { + q, _ := httpx.ParseQuantity(qty) + price := httpx.Price(unitPrice) + total := httpx.Cents(totalCents) + var l invoice.InvoiceLine + l.ID = uuid.New() + l.LineType = salesdoc.LineProduct + l.SKU, l.Description = &sku, desc + l.Quantity, l.UOM, l.PriceUOM = &q, &uom, &uom + l.UnitPrice, l.LineTotal = &price, &total + return l +} + // CORRECTNESS: the total on the invoice is the customer's payable amount. It is // stored as int64 cents and must render as dollars with exactly two decimal // places. Rendering 7388 as $7,388.00 is the exact failure mode CLAUDE.md warns @@ -113,24 +143,18 @@ func TestGenerateInvoicePDF_TotalIsCentsRenderedAsDollars(t *testing.T) { {"one cent", 1, "TOTAL DUE: $0.01"}, {"zero", 0, "TOTAL DUE: $0.00"}, {"large invoice", 12845000, "TOTAL DUE: $128450.00"}, - {"a credit total renders negative", -2500, "TOTAL DUE: $-25.00"}, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := NewService(newRepo()) - inv := &invoice.Invoice{ID: uuid.New(), TotalAmount: tc.cents, CreatedAt: time.Now()} - cust := &customer.Customer{Name: "Acme Construction", AccountNumber: "C-1001"} - - doc, err := svc.GenerateInvoicePDF(context.Background(), inv, cust) + doc, err := svc.GenerateInvoicePDF(context.Background(), mkInvoice(tc.cents, nil), &customer.Customer{Name: "Acme Construction", AccountNumber: "C-1001"}) if err != nil { t.Fatalf("GenerateInvoicePDF: %v", err) } assertPDF(t, doc) mustContain(t, doc, tc.want) // The bare cents value must never be printed as if it were - // dollars — that is the $73.88-becomes-$7,388.00 failure. Skipped - // where the two renderings coincide (whole-dollar and zero cases). + // dollars. Skipped where the two renderings coincide. if asDollars := "TOTAL DUE: $" + itoa(tc.cents) + ".00"; asDollars != tc.want { mustNotContain(t, doc, asDollars) } @@ -138,35 +162,31 @@ func TestGenerateInvoicePDF_TotalIsCentsRenderedAsDollars(t *testing.T) { } } -// CORRECTNESS: each line renders unit price and extended total in dollars. The -// extended total is quantity x unit price, so a two-of-a-$4.75-item line is -// $9.50 — not $950.00 and not $0.095. +// CORRECTNESS: each line renders its unit price and its extension exactly as +// the invoice stores them: the extension is the stored line total, rounded +// once, never recomputed from the quantity in float. func TestGenerateInvoicePDF_LineArithmetic(t *testing.T) { tests := []struct { - name string - qty float64 - priceEach int64 - wantUnit string - wantLine string + name string + qty string + unitPrice int64 // scale 4 + total int64 // cents + wantUnit string + wantLine string + wantQtyStr string }{ - {"two at $4.75", 2, 475, "$4.75", "$9.50"}, - {"one at $128.45", 1, 12845, "$128.45", "$128.45"}, - {"fractional quantity", 2.5, 400, "$4.00", "$10.00"}, - {"zero quantity", 0, 475, "$4.75", "$0.00"}, - {"a thousand board feet", 1000, 68500, "$685.00", "$685000.00"}, - {"one cent unit price", 3, 1, "$0.01", "$0.03"}, + {"two at $4.75", "2", 47500, 950, "$4.7500/PCS", "$9.50", "2 PCS"}, + {"one at $128.45", "1", 1284500, 12845, "$128.4500/PCS", "$128.45", "1 PCS"}, + {"fractional quantity", "2.5", 40000, 1000, "$4.0000/PCS", "$10.00", "2.5 PCS"}, + {"a thousand board feet", "1000", 6850000, 68500000, "$685.0000/PCS", "$685000.00", "1000 PCS"}, + {"a sub cent price", "3", 100, 3, "$0.0100/PCS", "$0.03", "3 PCS"}, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - prodID := uuid.New() - repo := newRepo(&product.Product{ID: prodID, SKU: "2X4-8", Description: "SPF Stud"}) - svc := NewService(repo) - - inv := &invoice.Invoice{ - ID: uuid.New(), CreatedAt: time.Now(), - Lines: []invoice.InvoiceLine{{ProductID: prodID, Quantity: tc.qty, PriceEach: tc.priceEach}}, - } + svc := NewService(newRepo()) + inv := mkInvoice(tc.total, func(i *invoice.Invoice) { + i.Lines = []invoice.InvoiceLine{mkLine("2X4-8", "SPF Stud", tc.qty, "PCS", tc.unitPrice, tc.total)} + }) doc, err := svc.GenerateInvoicePDF(context.Background(), inv, &customer.Customer{Name: "Acme"}) if err != nil { t.Fatalf("GenerateInvoicePDF: %v", err) @@ -174,27 +194,26 @@ func TestGenerateInvoicePDF_LineArithmetic(t *testing.T) { assertPDF(t, doc) mustContain(t, doc, tc.wantUnit) mustContain(t, doc, tc.wantLine) + mustContain(t, doc, tc.wantQtyStr) }) } } // CORRECTNESS: the line identifies the product by SKU and description, because -// that is what the customer matches against their own purchase order. +// that is what the customer matches against their own purchase order. The +// invoice prints its own snapshot of both: a product the catalogue no longer +// has (or a repository outage) cannot change what was billed, and the +// generator needs no product lookup at all. func TestGenerateInvoicePDF_LinesNameTheProduct(t *testing.T) { - a, b := uuid.New(), uuid.New() - repo := newRepo( - &product.Product{ID: a, SKU: "2X4-8-SPF", Description: "2x4x8 SPF Stud"}, - &product.Product{ID: b, SKU: "OSB-716", Description: "7/16 OSB Sheathing"}, - ) + repo := newRepo() // every lookup would miss: the invoice does not ask svc := NewService(repo) - inv := &invoice.Invoice{ - ID: uuid.New(), CreatedAt: time.Now(), TotalAmount: 20000, - Lines: []invoice.InvoiceLine{ - {ProductID: a, Quantity: 10, PriceEach: 475}, - {ProductID: b, Quantity: 4, PriceEach: 2899}, - }, - } + inv := mkInvoice(20000, func(i *invoice.Invoice) { + i.Lines = []invoice.InvoiceLine{ + mkLine("2X4-8-SPF", "2x4x8 SPF Stud", "10", "PCS", 47500, 4750), + mkLine("OSB-716", "7/16 OSB Sheathing", "4", "PCS", 289900, 11596), + } + }) doc, err := svc.GenerateInvoicePDF(context.Background(), inv, &customer.Customer{Name: "Acme", AccountNumber: "C-1001"}) if err != nil { t.Fatalf("GenerateInvoicePDF: %v", err) @@ -206,60 +225,38 @@ func TestGenerateInvoicePDF_LinesNameTheProduct(t *testing.T) { mustContain(t, doc, "Acme") mustContain(t, doc, "C-1001") mustContain(t, doc, "GABLE LBM - INVOICE") - - if len(repo.lookups) != 2 { - t.Errorf("looked up %d products, want one per line", len(repo.lookups)) + if len(repo.lookups) != 0 { + t.Errorf("looked up %d products; the invoice carries its own snapshot", len(repo.lookups)) } } -// CORRECTNESS: a product the catalogue no longer has must not stop the invoice -// being produced, and must not render as a blank line the customer cannot -// identify. A missing product is a data problem, not a reason to fail billing. -func TestGenerateInvoicePDF_UnknownProductFallsBack(t *testing.T) { - repo := newRepo() // every lookup misses - svc := NewService(repo) - - inv := &invoice.Invoice{ - ID: uuid.New(), CreatedAt: time.Now(), TotalAmount: 950, - Lines: []invoice.InvoiceLine{{ProductID: uuid.New(), Quantity: 2, PriceEach: 475}}, - } - doc, err := svc.GenerateInvoicePDF(context.Background(), inv, &customer.Customer{Name: "Acme"}) - if err != nil { - t.Fatalf("GenerateInvoicePDF must not fail on a missing product: %v", err) - } - assertPDF(t, doc) - mustContain(t, doc, "Unknown Product") - // The money must still be right even when the description is not. - mustContain(t, doc, "$9.50") - mustContain(t, doc, "TOTAL DUE: $9.50") -} - -// CORRECTNESS: a repository outage must degrade the same way — the invoice -// still renders, with placeholder descriptions. -func TestGenerateInvoicePDF_RepositoryOutageStillProducesADocument(t *testing.T) { - repo := newRepo() - repo.err = errors.New("db down") - svc := NewService(repo) - - inv := &invoice.Invoice{ - ID: uuid.New(), CreatedAt: time.Now(), TotalAmount: 475, - Lines: []invoice.InvoiceLine{{ProductID: uuid.New(), Quantity: 1, PriceEach: 475}}, - } +// CORRECTNESS: the invoice shows its number and its business date, not its +// internal id and a timestamp; a text line prints as a note; a void invoice is +// marked void. +func TestGenerateInvoicePDF_NumberDateNotesAndVoid(t *testing.T) { + svc := NewService(newRepo()) + note := salesdoc.Line{ID: uuid.New(), LineType: salesdoc.LineText, Description: "Leave at the east gate"} + inv := mkInvoice(100, func(i *invoice.Invoice) { + i.Number, i.InvoiceDate = "IN-000042", "2026-03-04" + i.Status = invoice.InvoiceStatusVoid + i.Lines = []invoice.InvoiceLine{{Line: note}} + }) doc, err := svc.GenerateInvoicePDF(context.Background(), inv, &customer.Customer{Name: "Acme"}) if err != nil { t.Fatalf("GenerateInvoicePDF: %v", err) } assertPDF(t, doc) - mustContain(t, doc, "Unknown Product") + mustContain(t, doc, "Invoice #: IN-000042") + mustContain(t, doc, "2026-03-04") + mustContain(t, doc, "Leave at the east gate") + mustContain(t, doc, "INVOICE - VOID") } // CORRECTNESS: an invoice with no lines is still a valid document showing the -// total — a customer must not be sent a truncated or empty file. +// total: a customer must not be sent a truncated or empty file. func TestGenerateInvoicePDF_NoLines(t *testing.T) { svc := NewService(newRepo()) - inv := &invoice.Invoice{ID: uuid.New(), CreatedAt: time.Now(), TotalAmount: 5000} - - doc, err := svc.GenerateInvoicePDF(context.Background(), inv, &customer.Customer{Name: "Acme"}) + doc, err := svc.GenerateInvoicePDF(context.Background(), mkInvoice(5000, nil), &customer.Customer{Name: "Acme"}) if err != nil { t.Fatalf("GenerateInvoicePDF: %v", err) } @@ -267,54 +264,29 @@ func TestGenerateInvoicePDF_NoLines(t *testing.T) { mustContain(t, doc, "TOTAL DUE: $50.00") } -// CORRECTNESS: the invoice date is the creation date, formatted unambiguously. -func TestGenerateInvoicePDF_ShowsTheInvoiceDate(t *testing.T) { - created := time.Date(2026, 3, 4, 16, 30, 0, 0, time.UTC) - svc := NewService(newRepo()) - inv := &invoice.Invoice{ID: uuid.New(), CreatedAt: created, TotalAmount: 100} - - doc, err := svc.GenerateInvoicePDF(context.Background(), inv, &customer.Customer{Name: "Acme"}) - if err != nil { - t.Fatalf("GenerateInvoicePDF: %v", err) - } - mustContain(t, assertPDF(t, doc), "2026-03-04") -} - // CORRECTNESS: the invoice PDF must not jump from the line extensions straight // to a tax-inclusive "TOTAL DUE". Without a subtotal row and a tax row the -// lines visibly do not add up to the total: a $100.00 order with 12% BC tax -// renders as lines summing to $100.00 above a TOTAL DUE of $112.00 with -// nothing to explain the $12.00. -// -// The Invoice model already carries Subtotal, TaxRate and TaxAmount -// (invoice/model.go:29-32) and the tax rate is resolved per branch, so the -// data is present. Showing tax separately is also a statutory requirement in -// the GST/HST jurisdictions this product targets. +// lines visibly do not add up to the total. Showing tax separately is also a +// statutory requirement in the GST/HST jurisdictions this product targets. func TestGenerateInvoicePDF_MustShowSubtotalAndTax(t *testing.T) { - prodID := uuid.New() - repo := newRepo(&product.Product{ID: prodID, SKU: "2X4-8", Description: "SPF Stud"}) - svc := NewService(repo) - - inv := &invoice.Invoice{ - ID: uuid.New(), CreatedAt: time.Now(), - Subtotal: 10000, // $100.00 - TaxRate: 0.12, - TaxAmount: 1200, // $12.00 - TotalAmount: 11200, // $112.00 + svc := NewService(newRepo()) + rate := "12" + inv := mkInvoice(11200, func(i *invoice.Invoice) { + i.SubtotalCents, i.TaxCents, i.TaxRatePercent = 10000, 1200, &rate // Two $50.00 lines, so "$100.00" can only appear if a subtotal row is - // rendered — it is not a line extension. - Lines: []invoice.InvoiceLine{ - {ProductID: prodID, Quantity: 50, PriceEach: 100}, - {ProductID: prodID, Quantity: 50, PriceEach: 100}, - }, - } + // rendered: it is not a line extension. + i.Lines = []invoice.InvoiceLine{ + mkLine("2X4-8", "SPF Stud", "50", "PCS", 10000, 5000), + mkLine("2X4-8", "SPF Stud", "50", "PCS", 10000, 5000), + } + }) doc, err := svc.GenerateInvoicePDF(context.Background(), inv, &customer.Customer{Name: "Acme"}) if err != nil { t.Fatalf("GenerateInvoicePDF: %v", err) } assertPDF(t, doc) - mustContain(t, doc, "$100.00") // subtotal - mustContain(t, doc, "$12.00") // tax + mustContain(t, doc, "SUBTOTAL: $100.00") + mustContain(t, doc, "TAX @ 12%: $12.00") mustContain(t, doc, "TOTAL DUE: $112.00") } @@ -323,15 +295,13 @@ func TestGenerateInvoicePDF_MustShowSubtotalAndTax(t *testing.T) { // URL points at someone else's domain; the portal branding config // (portal.PortalConfig) is not consulted. func TestGenerateInvoicePDF_PayLinkIsHardCoded(t *testing.T) { - id := uuid.New() + inv := mkInvoice(100, nil) svc := NewService(newRepo()) - inv := &invoice.Invoice{ID: id, CreatedAt: time.Now(), TotalAmount: 100} - doc, err := svc.GenerateInvoicePDF(context.Background(), inv, &customer.Customer{Name: "Acme"}) if err != nil { t.Fatalf("GenerateInvoicePDF: %v", err) } - mustContain(t, assertPDF(t, doc), "https://app.gable.com/pay/"+id.String()) + mustContain(t, assertPDF(t, doc), "https://app.gable.com/pay/"+inv.ID.String()) } // --- pick ticket --------------------------------------------------------- @@ -458,14 +428,10 @@ func TestGeneratePickTicketPDF_JobIsAlwaysNA(t *testing.T) { // whose output varies run to run cannot be cached, diffed, or trusted when a // customer disputes what they were sent. func TestGenerateInvoicePDF_IsDeterministicApartFromMetadata(t *testing.T) { - prodID := uuid.New() - repo := newRepo(&product.Product{ID: prodID, SKU: "2X4-8", Description: "SPF Stud"}) - svc := NewService(repo) - - inv := &invoice.Invoice{ - ID: uuid.New(), CreatedAt: time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC), TotalAmount: 950, - Lines: []invoice.InvoiceLine{{ProductID: prodID, Quantity: 2, PriceEach: 475}}, - } + svc := NewService(newRepo()) + inv := mkInvoice(950, func(i *invoice.Invoice) { + i.Lines = []invoice.InvoiceLine{mkLine("2X4-8", "SPF Stud", "2", "PCS", 47500, 950)} + }) cust := &customer.Customer{Name: "Acme", AccountNumber: "C-1"} first, err := svc.GenerateInvoicePDF(context.Background(), inv, cust) @@ -479,7 +445,7 @@ func TestGenerateInvoicePDF_IsDeterministicApartFromMetadata(t *testing.T) { // The PDF trailer carries a creation timestamp, so the whole byte stream // is not expected to match; the rendered content must. - for _, want := range []string{"TOTAL DUE: $9.50", "2X4-8 - SPF Stud", "$4.75", "2026-03-04"} { + for _, want := range []string{"TOTAL DUE: $9.50", "2X4-8 - SPF Stud", "$4.7500/PCS", "2026-03-04"} { mustContain(t, first, want) mustContain(t, second, want) } diff --git a/core/internal/invoice/credit_build.go b/core/internal/invoice/credit_build.go new file mode 100644 index 00000000..9ee85609 --- /dev/null +++ b/core/internal/invoice/credit_build.go @@ -0,0 +1,450 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice + +import ( + "context" + "fmt" + "math/big" + "strings" + "time" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/google/uuid" +) + +// creditBuild is a credit memo's lines and totals as built from a draft (or +// rebuilt from a stored draft at post). +type creditBuild struct { + lines []CreditLine + subtotal int64 // cents, negative + tax int64 // cents, negative + taxRate *string +} + +func (b *creditBuild) total() int64 { return b.subtotal + b.tax } + +// share is round_half_away(whole x part / of), exact in big arithmetic: a +// part of one invoice line's amount, so the credit memos of a line sum to the +// line's own amount to the cent whatever order and size the quantities come +// back in (the telescoping rule of ADR 0005 2.4, applied to credits). +func share(whole, part, of int64) int64 { + if of <= 0 || part <= 0 || whole == 0 { + return 0 + } + if part >= of { + return whole + } + n := new(big.Int).Mul(big.NewInt(whole), big.NewInt(part)) + neg := n.Sign() < 0 + n.Abs(n) + d := big.NewInt(of) + n.Add(n, new(big.Int).Rsh(d, 1)) + n.Div(n, d) + v := n.Int64() + if neg { + v = -v + } + return v +} + +func exceedsBilled(path string, billed, credited httpx.Quantity) *httpx.Error { + left := billed - credited + if left < 0 { + left = 0 + } + msg := fmt.Sprintf("%s credits more than was billed: %s left to credit on the invoice line", path, left.DecimalString()) + return &httpx.Error{Status: 409, Code: httpx.CodeConflict, Message: msg, + Details: []httpx.FieldError{httpx.Blocker("exceeds_billed", msg)}} +} + +// buildCredit builds a credit memo's lines and totals (ADR 0005 6.3 and +// section 3). source is the invoice it names (nil for none). amounts is what +// the POSTED memos of the invoice have credited (the extension, discount and +// cost pieces telescope against it); guard adds the drafts, for the +// exceeds_billed check at draft time. A free line is priced by its own +// request; an invoice line takes its price, pair and discount from the +// invoice line, so a partial credit is the same proportion of what was +// charged and the last credit takes the remainder. +func (s *Service) buildCredit(ctx context.Context, st Store, d *CreditInput, source *Invoice, amounts, guard CreditedAgainst, + currentIDs map[uuid.UUID]bool, branchID uuid.UUID, shipToID *uuid.UUID, exempt bool) (*creditBuild, error) { + + srcLines := map[uuid.UUID]*InvoiceLine{} + if source != nil { + for i := range source.Lines { + srcLines[source.Lines[i].ID] = &source.Lines[i] + } + } + var productIDs []uuid.UUID + for _, l := range d.Lines { + if l.ProductID != nil { + productIDs = append(productIDs, *l.ProductID) + } + } + refs, err := st.LookupProducts(ctx, productIDs) + if err != nil { + return nil, err + } + + out := &creditBuild{} + for i := range d.Lines { + l := &d.Lines[i] + path := fmt.Sprintf("lines[%d]", i) + var cl CreditLine + if l.InvoiceLineID != nil { + if source == nil { + return nil, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: path + ".invoice_line_id", Message: "the credit memo names no invoice: credit a free line instead"}) + } + src, found := srcLines[*l.InvoiceLineID] + if !found { + return nil, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: path + ".invoice_line_id", Message: "no such line on this invoice"}) + } + if src.LineType != salesdoc.LineProduct && src.LineType != salesdoc.LineCharge { + return nil, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: path + ".invoice_line_id", Message: "a kit, a kit component or a text line is not credited by line: credit its price as a free line"}) + } + cl, err = creditFromInvoiceLine(path, l, src, amounts.ByLine[src.ID], guard.ByLine[src.ID]) + if err != nil { + return nil, err + } + } else { + cl, err = s.freeCreditLine(ctx, st, path, l, refs) + if err != nil { + return nil, err + } + } + if l.ID != nil && currentIDs[*l.ID] { + cl.ID = *l.ID + } else { + cl.ID = uuid.New() + } + cl.Position = i + cl.CreatedAt = httpx.TimestampOf(s.now().UTC()) + out.lines = append(out.lines, cl) + } + + // Totals and tax (section 3). + var taxable int64 + for i := range out.lines { + l := &out.lines[i] + if l.LineTotal == nil { + continue + } + out.subtotal += int64(*l.LineTotal) + if l.Taxable { + taxable += int64(*l.LineTotal) + } + } + rate, tax, err := s.creditTax(ctx, st, source, amounts, taxable, branchID, shipToID, exempt) + if err != nil { + return nil, err + } + out.taxRate, out.tax = rate, tax + return out, nil +} + +// creditFromInvoiceLine builds the credit line for r units returned of an +// invoice line. +func creditFromInvoiceLine(path string, d *CreditLineInput, src *InvoiceLine, amounts, guard Credited) (CreditLine, error) { + whole := int64(0) + if src.Quantity != nil { + whole = int64(*src.Quantity) + } + r := -int64(d.Quantity) + if guard.Quantity+r > whole { + return CreditLine{}, exceedsBilled(path, httpx.Quantity(whole), httpx.Quantity(guard.Quantity)) + } + before := amounts.Quantity + var total, discount int64 + if src.LineTotal != nil { + total = share(int64(*src.LineTotal), before+r, whole) - amounts.TotalCents + } + if src.DiscountAmount != nil { + discount = share(int64(*src.DiscountAmount), before+r, whole) - amounts.DiscountCents + } + if total < 0 { + total = 0 + } + if discount < 0 { + discount = 0 + } + desc := src.Description + if d.Description != "" { + desc = d.Description + } + q := httpx.Quantity(-r) + lt := httpx.Cents(-total) + cl := CreditLine{InvoiceLineID: &src.ID} + cl.Line = salesdoc.Line{ + LineType: src.LineType, ProductID: src.ProductID, ChargeCodeID: src.ChargeCodeID, ChargeCode: src.ChargeCode, + SKU: src.SKU, Description: desc, Quantity: &q, UOM: src.UOM, PriceUOM: src.PriceUOM, UOMQty: src.UOMQty, + PriceUOMQty: src.PriceUOMQty, UnitPrice: src.UnitPrice, PricedUnitPrice: src.PricedUnitPrice, + PriceSource: src.PriceSource, OverrideReason: src.OverrideReason, DiscountPercent: src.DiscountPercent, + DiscountReason: src.DiscountReason, PriceAdjustedBy: src.PriceAdjustedBy, + LineTotal: <, Taxable: src.Taxable, RevenueAccountCode: src.RevenueAccountCode, + } + if src.DiscountAmount != nil { + dc := httpx.Cents(discount) + cl.DiscountAmount = &dc + } + if d.Restock { + if src.LineType != salesdoc.LineProduct || src.ProductID == nil { + return CreditLine{}, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: path + ".restock", Message: "only a stocked product line goes back to stock"}) + } + cl.Restock = true + cl.UnitCost = src.UnitCost + // The cost that comes back is the invoice line's own cost in the + // same proportion, never more than that line relieved and not yet + // reversed: COGS reverses at the original cost, not today's. + cost := share(int64(src.CostCents), r, whole) + if left := int64(src.CostCents) - amounts.CostCents; cost > left { + cost = left + } + if cost < 0 { + cost = 0 + } + cl.CostCents = httpx.Cents(-cost) + } + return cl, nil +} + +// freeCreditLine builds a credit line that names no invoice line: a price +// adjustment, a fee given back, goods returned against no invoice, a note. +func (s *Service) freeCreditLine(ctx context.Context, st Store, path string, d *CreditLineInput, refs map[uuid.UUID]salesdoc.ProductRef) (CreditLine, error) { + var cl CreditLine + cl.LineType = d.LineType + cl.Description = d.Description + switch d.LineType { + case salesdoc.LineText: + cl.PriceSource = salesdoc.PriceSourceNone + return cl, nil + + case salesdoc.LineCharge: + code, found, err := st.ChargeCodeByCode(ctx, d.ChargeCode) + if err != nil { + return cl, err + } + if !found || !code.IsActive { + return cl, validationFailed("a referenced record does not exist", + httpx.FieldError{Field: path + ".charge_code", Message: "no such active charge code"}) + } + price := httpx.Price(0) + switch { + case d.UnitPrice != nil: + price = *d.UnitPrice + case code.DefaultUnitPrice != nil: + price = *code.DefaultUnitPrice + default: + return cl, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: path + ".unit_price_ten_thousandths", Message: "is required: the charge code has no default price"}) + } + cl.ChargeCodeID, cl.ChargeCode = &code.ID, &code.Code + cl.RevenueAccountCode = &code.RevenueAccountCode + cl.Taxable = code.Taxable + if d.Taxable != nil { + cl.Taxable = *d.Taxable + } + if cl.Description == "" { + cl.Description = code.Name + } + uom := "EA" + if d.UOM != "" { + uom = d.UOM + } + return finishFree(path, &cl, d, uom, price) + + default: // a product line + uom := d.UOM + cl.Taxable = true + if d.Taxable != nil { + cl.Taxable = *d.Taxable + } + if d.ProductID != nil { + ref, found := refs[*d.ProductID] + if !found { + return cl, validationFailed("a referenced record does not exist", + httpx.FieldError{Field: path + ".product_id", Message: "no such product"}) + } + if ref.IsKit { + return cl, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: path + ".product_id", Message: "a kit is credited through the invoice line that billed it"}) + } + cl.ProductID = d.ProductID + sku := ref.SKU + cl.SKU = &sku + cl.Taxable = ref.Taxable + if uom == "" { + uom = ref.UOMPrimary + } + if cl.Description == "" { + cl.Description = ref.Description + } + if d.Restock { + cost := ref.AverageCost + cl.Restock = true + if cost > 0 { + cl.UnitCost = &cost + cl.CostCents = httpx.Cents(-int64(salesdoc.CostOf(-d.Quantity, cost))) + } + } + } + return finishFree(path, &cl, d, uom, *d.UnitPrice) + } +} + +// finishFree prices a free line: the unit, the pair 1 and 1, the extension +// rounded once (negative with the quantity), the source MANUAL. +func finishFree(path string, cl *CreditLine, d *CreditLineInput, uom string, price httpx.Price) (CreditLine, error) { + q := d.Quantity + one := salesdoc.One + total, err := httpx.Extend(q, one, one, price) + if err != nil { + return *cl, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: path + ".quantity", Message: "the extension is out of range"}) + } + cl.Quantity, cl.UOM, cl.PriceUOM = &q, &uom, &uom + cl.UOMQty, cl.PriceUOMQty = &one, &one + cl.UnitPrice = &price + cl.PriceSource = salesdoc.PriceSourceManual + cl.LineTotal = &total + return *cl, nil +} + +// creditTax is the credit memo's tax (ADR 0005 section 3): at the invoice's +// rate on the memo's taxable base, never more than the invoice's tax less the +// tax earlier memos credited, the memo that returns the last of the invoice's +// taxable amount taking exactly that remainder; a provider's invoice (no +// rate) credits in proportion to its tax. With no invoice the rate resolves +// from the ship-to or the branch (the exemption first), and a document with +// no configured rate is refused. The result is negative like the memo. +func (s *Service) creditTax(ctx context.Context, st Store, source *Invoice, amounts CreditedAgainst, taxableBase int64, + branchID uuid.UUID, shipToID *uuid.UUID, exempt bool) (*string, int64, error) { + + base := -taxableBase // positive + if source == nil { + if exempt { + zero := "0.000000" + return &zero, 0, nil + } + shipRate, branchRate, err := st.TaxInputs(ctx, branchID, shipToID) + if err != nil { + return nil, 0, err + } + resolved, err := salesdoc.ResolveTax(salesdoc.TaxInputs{Delivery: shipToID != nil, ShipToRate: shipRate, BranchRate: branchRate}) + if err != nil { + if err == salesdoc.ErrTaxRateNotConfigured { + return nil, 0, conflictBlocker("tax_rate_not_configured", + "set the branch's default tax rate (or the ship-to's rate) before crediting") + } + return nil, 0, err + } + scaled, _, err := salesdoc.ParseTaxRate(*resolved.Rate) + if err != nil { + return nil, 0, err + } + return resolved.Rate, int64(salesdoc.TaxAt(httpx.Cents(taxableBase), scaled)), nil + } + + // The invoice's own taxable base and tax. + var invTaxable int64 + for i := range source.Lines { + if source.Lines[i].Taxable && source.Lines[i].LineTotal != nil { + invTaxable += int64(*source.Lines[i].LineTotal) + } + } + invTax := int64(source.TaxCents) + var rateStr *string + if source.TaxRatePercent != nil { + if scaled, err := httpx.ParseQuantity(*source.TaxRatePercent); err == nil { + // percent at scale 4 and the rate at scale 6 are the same digits + r := fmt.Sprintf("%d.%06d", int64(scaled)/1000000, abs(int64(scaled))%1000000) + rateStr = &r + } + } + if base == 0 || invTaxable <= 0 || invTax <= 0 { + return rateStr, 0, nil + } + left := invTax - amounts.TaxCents + if left < 0 { + left = 0 + } + var tax int64 + switch { + case amounts.TaxableCents+base >= invTaxable: + tax = left // the memo that returns the last of the taxable amount takes the remainder + case rateStr != nil: + scaled, _, err := salesdoc.ParseTaxRate(*rateStr) + if err != nil { + return nil, 0, err + } + tax = int64(salesdoc.TaxAt(httpx.Cents(base), scaled)) + default: + tax = share(invTax, base, invTaxable) + } + if tax > left { + tax = left + } + return rateStr, -tax, nil +} + +func abs(v int64) int64 { + if v < 0 { + return -v + } + return v +} + +// draftFromStored rebuilds a stored draft's request lines so the post can run +// the same build against what has been POSTED by then. +func draftFromStored(cm *CreditMemo) *CreditInput { + d := &CreditInput{InvoiceID: cm.InvoiceID, ReasonCode: cm.ReasonCode, Reason: cm.Reason} + for i := range cm.Lines { + l := &cm.Lines[i] + id := l.ID + ld := CreditLineInput{ID: &id} + if l.Quantity != nil { + ld.Quantity = *l.Quantity + } + ld.Description = l.Description + ld.Restock = l.Restock + if l.InvoiceLineID != nil { + ld.InvoiceLineID = l.InvoiceLineID + d.Lines = append(d.Lines, ld) + continue + } + ld.LineType = l.LineType + ld.ProductID = l.ProductID + if l.ChargeCode != nil { + ld.ChargeCode = *l.ChargeCode + } + if l.UOM != nil { + ld.UOM = *l.UOM + } + ld.UnitPrice = l.UnitPrice + taxable := l.Taxable + if l.ProductID == nil { + ld.Taxable = &taxable + } + d.Lines = append(d.Lines, ld) + } + return d +} + +// restockSum is the cost a memo's restocked lines bring back, as a positive +// figure in cents. +func restockSum(lines []CreditLine) int64 { + var c int64 + for i := range lines { + if lines[i].Restock { + c += -int64(lines[i].CostCents) + } + } + return c +} + +func memoDay(t time.Time) string { return strings.TrimSpace(t.Format("2006-01-02")) } diff --git a/core/internal/invoice/fulfilment.go b/core/internal/invoice/fulfilment.go index b8b70920..a99ce76a 100644 --- a/core/internal/invoice/fulfilment.go +++ b/core/internal/invoice/fulfilment.go @@ -34,7 +34,10 @@ type FulfilmentLine struct { UOMQty *int64 PriceUOMQty *int64 UnitPrice *int64 // scale 4 + PricedUnitPrice *int64 // scale 4: what the engine resolved PriceSource string + OverrideReason *string + PriceAdjustedBy *string DiscountPercent *int64 // scale 4 DiscountCents *int64 DiscountReason *string @@ -54,9 +57,11 @@ type RevenueLeg struct { // FulfilmentInvoice is the invoice an order fulfilment bills (ADR 0005 5.6 and // 6.1) with everything its entry needs: the revenue by account, the tax and -// the cost of goods sold. +// the cost of goods sold. The number, the terms and their dates are filled by +// CreateFulfilmentInvoice (the number is minted late, section 11). type FulfilmentInvoice struct { ID uuid.UUID + Number string BranchID uuid.UUID OrderID uuid.UUID CustomerID uuid.UUID @@ -70,6 +75,11 @@ type FulfilmentInvoice struct { ProjectID *uuid.UUID InvoiceDate time.Time // the branch's local date + PaymentTermsID uuid.UUID + DueDate time.Time + DiscountDueDate *time.Time + DiscountPercent *int64 // scale 4 + SubtotalCents int64 TaxCents int64 TotalCents int64 @@ -87,16 +97,21 @@ type FulfilmentInvoice struct { // FulfilmentStore is the repository half the fulfilment invoice needs; the // Postgres repository implements it. type FulfilmentStore interface { + TermsFor(ctx context.Context, customerID uuid.UUID) (*CustomerTerms, error) + LockCustomer(ctx context.Context, customerID uuid.UUID) error + NextInvoiceNumber(ctx context.Context) (string, error) InsertFulfilmentInvoice(ctx context.Context, in *FulfilmentInvoice) error SetInvoiceGLEntry(ctx context.Context, invoiceID, entryID uuid.UUID) error } // CreateFulfilmentInvoice writes the invoice of an order fulfilment inside the -// caller's transaction (ADR 0005 5.6 step 5 and 6, 8.2): the invoice with its -// lines, the balanced invoice entry through gl.PostEntry (DR 1020 total and -// 5010 cost; CR each revenue account, 2020 tax and 1030 cost), and the AR -// subledger debit through today's PostTransaction. It refuses to run outside -// a transaction. Until C2-4 moves both postings into the AR core this is the +// caller's transaction (ADR 0005 5.6 steps 5 and 6, 8.2), in section 11's +// order: the customer row is locked, THEN the gapless number is minted, +// immediately before the insert; the journal entry and the subledger follow. +// It posts the balanced invoice entry through gl.PostEntry (DR 1020 total and +// 5010 cost; CR each revenue account, 2020 tax and 1030 cost) and the AR +// subledger debit through today's PostTransaction. It refuses to run outside a +// transaction. Until C2-4 moves both postings into the AR core this is the // path; the events are the caller's, written last. func (s *Service) CreateFulfilmentInvoice(ctx context.Context, in *FulfilmentInvoice) error { if !database.InTx(ctx) { @@ -112,6 +127,32 @@ func (s *Service) CreateFulfilmentInvoice(ctx context.Context, in *FulfilmentInv if len(in.Lines) == 0 { return fmt.Errorf("invoice must have lines") } + + // The terms the invoice snapshots: due date and early pay discount from + // the customer's terms at the invoice date (ADR 0005 7.2). + terms, err := store.TermsFor(ctx, in.CustomerID) + if err != nil { + return err + } + in.PaymentTermsID = terms.ID + in.DueDate = terms.Terms.DueDate(in.InvoiceDate) + in.DiscountDueDate = terms.Terms.DiscountDueDate(in.InvoiceDate) + if in.DiscountDueDate != nil && terms.Terms.DiscountPercent != nil { + pct := int64(*terms.Terms.DiscountPercent) + in.DiscountPercent = &pct + } else { + in.DiscountDueDate = nil + } + + // Step 7 then 8: the customer row, then the gapless counter. The mint + // holds the series until the commit, so it comes after every other lock + // of the transaction and just before the insert. + if err := store.LockCustomer(ctx, in.CustomerID); err != nil { + return err + } + if in.Number, err = store.NextInvoiceNumber(ctx); err != nil { + return err + } if err := store.InsertFulfilmentInvoice(ctx, in); err != nil { return err } @@ -130,7 +171,7 @@ func (s *Service) CreateFulfilmentInvoice(ctx context.Context, in *FulfilmentInv ) invID := in.ID entry, err := s.gl.PostEntry(ctx, gl.PostingInput{ - EntryDate: in.InvoiceDate, Memo: fmt.Sprintf("Invoice %s", in.ID), Source: gl.SourceInvoice, + EntryDate: in.InvoiceDate, Memo: fmt.Sprintf("Invoice %s", in.Number), Source: gl.SourceInvoice, SourceRefID: &invID, Currency: in.Currency, PostedBy: in.Actor, Legs: legs, }) if err != nil { @@ -143,7 +184,7 @@ func (s *Service) CreateFulfilmentInvoice(ctx context.Context, in *FulfilmentInv } if s.account != nil { - if _, err := s.account.PostTransaction(ctx, in.CustomerID, account.TransactionTypeInvoice, in.TotalCents, &invID, "Invoice #"+in.ID.String()); err != nil { + if _, err := s.account.PostTransaction(ctx, in.CustomerID, account.TransactionTypeInvoice, in.TotalCents, &invID, "Invoice "+in.Number); err != nil { return fmt.Errorf("failed to post the invoice to the account ledger: %w", err) } } @@ -151,7 +192,7 @@ func (s *Service) CreateFulfilmentInvoice(ctx context.Context, in *FulfilmentInv if err := s.auditLog.Log(ctx, audit.Entry{ Action: "invoice.created", EntityType: "invoice", EntityID: in.ID, UserID: in.Actor, Changes: map[string]interface{}{ - "customer_id": in.CustomerID, "order_id": in.OrderID, "total_cents": in.TotalCents, + "number": in.Number, "customer_id": in.CustomerID, "order_id": in.OrderID, "total_cents": in.TotalCents, "cost_cents": in.CostCents, "delivery_id": in.DeliveryID, }, }); err != nil { diff --git a/core/internal/invoice/handler.go b/core/internal/invoice/handler.go index bce8d98e..5a00d88f 100644 --- a/core/internal/invoice/handler.go +++ b/core/internal/invoice/handler.go @@ -6,12 +6,20 @@ package invoice import ( "encoding/json" "net/http" + "strings" + "time" - "github.com/gablelbm/gable/pkg/httputil" - "github.com/gablelbm/gable/pkg/pagination" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) +// The lists' orderings: created_at then id, newest first (ADR 0001 section 2). +const ( + invoiceCursorScope = "invoices.created_at_id_desc" + creditCursorScope = "credit_memos.created_at_id_desc" +) + type Handler struct { svc *Service } @@ -20,6 +28,10 @@ func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } +// RegisterRoutes mounts the invoice and credit memo routes (ADR 0005 6.2 and +// 6.3). The document print and email routes keep their paths in the document +// module. The payments of an invoice stay with the payment module until C2-4 +// converts them into applications. func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Handler) http.Handler) { guard := func(handler http.HandlerFunc) http.HandlerFunc { if len(roleGuard) > 0 && roleGuard[0] != nil { @@ -32,106 +44,476 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand mux.HandleFunc("GET /api/v1/invoices", guard(h.HandleList)) mux.HandleFunc("GET /api/v1/invoices/{id}", guard(h.HandleGet)) - mux.HandleFunc("POST /api/v1/invoices/{id}/credit-memo", guard(h.HandleCreateCreditMemo)) - mux.HandleFunc("GET /api/v1/credit-memos/{customerId}", guard(h.HandleListCreditMemos)) + mux.HandleFunc("POST /api/v1/invoices/{id}/transitions", guard(h.HandleTransition)) + + mux.HandleFunc("GET /api/v1/credit-memos", guard(h.HandleListCreditMemos)) + mux.HandleFunc("POST /api/v1/credit-memos", guard(h.HandleCreateCreditMemo)) + mux.HandleFunc("GET /api/v1/credit-memos/{id}", guard(h.HandleGetCreditMemo)) + mux.HandleFunc("PUT /api/v1/credit-memos/{id}", guard(h.HandleUpdateCreditMemo)) + mux.HandleFunc("POST /api/v1/credit-memos/{id}/transitions", guard(h.HandleCreditTransition)) } -func (h *Handler) HandleList(w http.ResponseWriter, r *http.Request) { - page := pagination.FromRequest(r) - invoices, total, err := h.svc.ListInvoicesPaginated(r.Context(), page.Limit, page.Offset) +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func pathID(r *http.Request, what string) (uuid.UUID, error) { + id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "failed to list invoices", http.StatusInternalServerError, err) - return + return uuid.Nil, httpx.BadRequest("invalid "+what+" id", + httpx.FieldError{Field: "id", Message: "must be a UUID"}) } + return id, nil +} + +func noQuery(r *http.Request) error { + _, err := httpx.StrictQuery(r) + return err +} - resp := pagination.PagedResponse[Invoice]{ - Data: invoices, - Total: total, - Limit: page.Limit, - Offset: page.Offset, +// actor reads the caller's subject for the audit rows. +func actor(r *http.Request) string { + if claims, ok := r.Context().Value(middleware.UserContextKey).(*middleware.UserClaims); ok && claims != nil { + return claims.Subject } - if resp.Data == nil { - resp.Data = []Invoice{} + return "" +} + +// callerRole reads the caller's role; empty when the auth chain set none (an +// in process caller, a machine key). +func callerRole(r *http.Request) string { + if claims, ok := r.Context().Value(middleware.UserContextKey).(*middleware.UserClaims); ok && claims != nil { + return claims.Role } + return "" +} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) +func cursorError() error { + return httpx.BadRequest("cursor keyset is malformed", + httpx.FieldError{Field: "cursor", Message: "cursor keyset is malformed"}) } -func (h *Handler) HandleGet(w http.ResponseWriter, r *http.Request) { - idStr := r.PathValue("id") - id, err := uuid.Parse(idStr) +// repeated reports a query parameter sent more than once. +func single(v *httpx.Validator, q map[string][]string, name string) (string, bool) { + vals := q[name] + if len(vals) == 0 { + return "", false + } + if len(vals) > 1 { + v.Check(false, name, "parameter is repeated") + return "", false + } + return vals[0], true +} + +func uuidParam(v *httpx.Validator, q map[string][]string, name string) *uuid.UUID { + raw, ok := single(v, q, name) + if !ok { + return nil + } + if id, ok := v.UUID(name, &raw, true); ok { + return &id + } + return nil +} + +// parseKey reads the keyset position of a cursor. +func parseKey(page httpx.CursorPage) (at *timeAndID, err error) { + if page.Key == nil { + return nil, nil + } + if len(page.Key) != 2 { + return nil, cursorError() + } + t, terr := httpx.ParseKeyTime(page.Key[0]) + id, uerr := httpx.ParseKeyUUID(page.Key[1]) + if terr != nil || uerr != nil { + return nil, cursorError() + } + return &timeAndID{t, id}, nil +} + +// parseListFilter reads the invoice list's filters (ADR 0005 6.1): status, +// customer_id, job_id, ship_to_id, order_id and overdue beside the platform's +// cursor, limit and include. Unknown parameters and uppercase values are +// refused, never ignored. +func parseListFilter(r *http.Request) (f ListFilter, wantTotal bool, err error) { + q, err := httpx.StrictQuery(r, "cursor", "limit", "include", "status", "customer_id", "job_id", "ship_to_id", "order_id", "overdue") + if err != nil { + return f, false, err + } + page, err := httpx.ParseListQuery(r, invoiceCursorScope) + if err != nil { + return f, false, err + } + f.Limit = page.Limit + v := &httpx.Validator{} + if raw, ok := single(v, q, "status"); ok { + seen := map[InvoiceStatus]bool{} + for _, name := range strings.Split(raw, ",") { + st, ok := ParseStatus(name) + if !ok { + v.Check(false, "status", "must be a comma separated list of: "+strings.Join(invoiceStatusNames, ", ")) + break + } + if !seen[st] { + seen[st] = true + f.Statuses = append(f.Statuses, st) + } + } + } + f.CustomerID = uuidParam(v, q, "customer_id") + f.JobID = uuidParam(v, q, "job_id") + f.ShipToID = uuidParam(v, q, "ship_to_id") + f.OrderID = uuidParam(v, q, "order_id") + if raw, ok := single(v, q, "overdue"); ok { + switch raw { + case "true": + t := true + f.Overdue = &t + case "false": + t := false + f.Overdue = &t + default: + v.Check(false, "overdue", "must be true or false") + } + } + wantTotal, ierr := includeTotal(v, q) + if ierr != nil { + return f, false, ierr + } + if err := v.Err(); err != nil { + return f, false, err + } + key, err := parseKey(page) + if err != nil { + return f, false, err + } + if key != nil { + f.AfterTime, f.AfterID = &key.at, key.id + } + return f, wantTotal, nil +} + +func includeTotal(v *httpx.Validator, q map[string][]string) (bool, error) { + raw, ok := single(v, q, "include") + if !ok { + return false, nil + } + set, err := httpx.ParseInclude(raw) + if err != nil { + return false, err + } + return set.Has(httpx.IncludeTotal), nil +} + +func (h *Handler) HandleList(w http.ResponseWriter, r *http.Request) { + f, wantTotal, err := parseListFilter(r) + if err != nil { + httpx.WriteError(w, r, err) + return + } + items, hasMore, total, err := h.svc.ListInvoices(r.Context(), f, wantTotal) if err != nil { - httputil.RespondError(w, r, "invalid invoice ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } + next := "" + if hasMore && len(items) > 0 { + last := items[len(items)-1] + next, err = httpx.MintCursor(invoiceCursorScope, httpx.FormatKeyTime(last.CreatedAt.Time), last.ID.String()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + } + var opts []httpx.ListOption + if total != nil { + opts = append(opts, httpx.WithTotal(*total)) + } + httpx.WriteList(w, items, next, f.Limit, opts...) +} +func (h *Handler) HandleGet(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + id, err := pathID(r, "invoice") + if err != nil { + httpx.WriteError(w, r, err) + return + } inv, err := h.svc.GetInvoice(r.Context(), id) if err != nil { - httputil.RespondError(w, r, "invoice not found", http.StatusNotFound, err) + httpx.WriteError(w, r, notFound(err)) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(inv) + httpx.WriteRevisionETag(w, inv.Revision) + writeJSON(w, http.StatusOK, inv) } -type CreateCreditMemoRequest struct { - AmountCents int64 `json:"amount_cents"` - Reason string `json:"reason"` +// transitionBody parses the shared transition body: the target, the +// revision and the reason (a void's reason is required, 1 to 500 characters). +func transitionBody(r *http.Request) (to string, rev *int64, reason string, err error) { + var req TransitionRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + return "", nil, "", err + } + v := &httpx.Validator{} + if req.To == nil { + v.Check(false, "to", "is required") + } else { + to = *req.To + } + if n, ok := v.Int("revision", req.Revision, false); ok { + v.Check(n >= 1, "revision", "must be a revision number, 1 or more") + rev = &n + } + if req.Reason != nil { + reason = strings.TrimSpace(*req.Reason) + v.Check(len(reason) <= 500, "reason", "must be at most 500 characters") + } + if err := v.Err(); err != nil { + return "", nil, "", err + } + return to, rev, reason, nil } -func (h *Handler) HandleCreateCreditMemo(w http.ResponseWriter, r *http.Request) { - invoiceIDStr := r.PathValue("id") - invoiceID, err := uuid.Parse(invoiceIDStr) +// HandleTransition runs POST /invoices/{id}/transitions. Only void is a +// client's transition: unpaid, partial, paid and written_off are derived by +// the AR core from applications (never sent by a client), so asking for one +// is a 409 invalid_state_transition, and a word that is no status a 400. +func (h *Handler) HandleTransition(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + id, err := pathID(r, "invoice") if err != nil { - httputil.RespondError(w, r, "invalid invoice ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } - - var req CreateCreditMemoRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "invalid request body", http.StatusBadRequest, err) + to, rev, reason, err := transitionBody(r) + if err != nil { + httpx.WriteError(w, r, err) return } - - if req.AmountCents <= 0 { - httputil.RespondError(w, r, "amount_cents must be positive", http.StatusBadRequest, nil) + status, ok := ParseStatus(to) + if !ok { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "to", Message: "must be one of: " + strings.Join(invoiceStatusNames, ", ")})) return } + if status != InvoiceStatusVoid { + httpx.WriteError(w, r, httpx.InvalidStateTransition("an invoice is moved to "+status.Status()+" by its payments and credit memos, not by a transition; the only transition is void")) + return + } + if reason == "" { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "reason", Message: "is required to void an invoice"})) + return + } + inv, err := h.svc.VoidInvoice(r.Context(), id, Precondition{IfMatch: r.Header.Get("If-Match"), Revision: rev}, + Transition{Reason: reason, Actor: actor(r), Role: callerRole(r)}) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteRevisionETag(w, inv.Revision) + writeJSON(w, http.StatusOK, inv) +} + +// --------------------------------------------------------------------------- +// Credit memos. +// --------------------------------------------------------------------------- + +func writeCredit(w http.ResponseWriter, status int, cm *CreditMemo) { + httpx.WriteRevisionETag(w, cm.Revision) + writeJSON(w, status, cm) +} - // Get invoice to find customer - inv, err := h.svc.GetInvoice(r.Context(), invoiceID) +func parseCreditFilter(r *http.Request) (f CreditFilter, wantTotal bool, err error) { + q, err := httpx.StrictQuery(r, "cursor", "limit", "include", "status", "customer_id", "invoice_id", "job_id") + if err != nil { + return f, false, err + } + page, err := httpx.ParseListQuery(r, creditCursorScope) if err != nil { - httputil.RespondError(w, r, "invoice not found", http.StatusNotFound, err) + return f, false, err + } + f.Limit = page.Limit + v := &httpx.Validator{} + if raw, ok := single(v, q, "status"); ok { + seen := map[CreditStatus]bool{} + for _, name := range strings.Split(raw, ",") { + st, ok := ParseCreditStatus(name) + if !ok { + v.Check(false, "status", "must be a comma separated list of: "+strings.Join(creditStatusNames, ", ")) + break + } + if !seen[st] { + seen[st] = true + f.Statuses = append(f.Statuses, st) + } + } + } + f.CustomerID = uuidParam(v, q, "customer_id") + f.InvoiceID = uuidParam(v, q, "invoice_id") + f.JobID = uuidParam(v, q, "job_id") + wantTotal, ierr := includeTotal(v, q) + if ierr != nil { + return f, false, ierr + } + if err := v.Err(); err != nil { + return f, false, err + } + key, err := parseKey(page) + if err != nil { + return f, false, err + } + if key != nil { + f.AfterTime, f.AfterID = &key.at, key.id + } + return f, wantTotal, nil +} + +func (h *Handler) HandleListCreditMemos(w http.ResponseWriter, r *http.Request) { + f, wantTotal, err := parseCreditFilter(r) + if err != nil { + httpx.WriteError(w, r, err) + return + } + items, hasMore, total, err := h.svc.ListCreditMemos(r.Context(), f, wantTotal) + if err != nil { + httpx.WriteError(w, r, err) return } + next := "" + if hasMore && len(items) > 0 { + last := items[len(items)-1] + next, err = httpx.MintCursor(creditCursorScope, httpx.FormatKeyTime(last.CreatedAt.Time), last.ID.String()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + } + var opts []httpx.ListOption + if total != nil { + opts = append(opts, httpx.WithTotal(*total)) + } + httpx.WriteList(w, items, next, f.Limit, opts...) +} - cm, err := h.svc.CreateAndApplyCreditMemo(r.Context(), inv.CustomerID, &invoiceID, req.AmountCents, req.Reason) +func (h *Handler) HandleCreateCreditMemo(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + var req CreditRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + draft, err := req.Parse() if err != nil { - httputil.RespondError(w, r, "failed to create credit memo", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } + cm, err := h.svc.CreateCreditMemo(r.Context(), draft, actor(r)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + w.Header().Set("Location", "/api/v1/credit-memos/"+cm.ID.String()) + writeCredit(w, http.StatusCreated, cm) +} - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(cm) +func (h *Handler) HandleGetCreditMemo(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + id, err := pathID(r, "credit memo") + if err != nil { + httpx.WriteError(w, r, err) + return + } + cm, err := h.svc.GetCreditMemo(r.Context(), id) + if err != nil { + httpx.WriteError(w, r, notFound(err)) + return + } + writeCredit(w, http.StatusOK, cm) } -func (h *Handler) HandleListCreditMemos(w http.ResponseWriter, r *http.Request) { - customerIDStr := r.PathValue("customerId") - customerID, err := uuid.Parse(customerIDStr) +func (h *Handler) HandleUpdateCreditMemo(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + id, err := pathID(r, "credit memo") if err != nil { - httputil.RespondError(w, r, "invalid customer ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) + return + } + var req CreditRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } + draft, err := req.ParseUpdate() + if err != nil { + httpx.WriteError(w, r, err) + return + } + cm, err := h.svc.UpdateCreditMemo(r.Context(), id, draft, Precondition{IfMatch: r.Header.Get("If-Match"), Revision: draft.Revision}, actor(r)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeCredit(w, http.StatusOK, cm) +} - memos, err := h.svc.ListCreditMemos(r.Context(), customerID) +func (h *Handler) HandleCreditTransition(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + id, err := pathID(r, "credit memo") + if err != nil { + httpx.WriteError(w, r, err) + return + } + to, rev, reason, err := transitionBody(r) if err != nil { - httputil.RespondError(w, r, "failed to list credit memos", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } + status, ok := ParseCreditStatus(to) + if !ok { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "to", Message: "must be one of: " + strings.Join(creditStatusNames, ", ")})) + return + } + if status == CreditVoid && reason == "" { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "reason", Message: "is required to void a credit memo"})) + return + } + cm, err := h.svc.TransitionCreditMemo(r.Context(), id, status, Precondition{IfMatch: r.Header.Get("If-Match"), Revision: rev}, + Transition{Reason: reason, Actor: actor(r), Role: callerRole(r)}) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeCredit(w, http.StatusOK, cm) +} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(memos) +// timeAndID is a keyset position. +type timeAndID struct { + at time.Time + id uuid.UUID } diff --git a/core/internal/invoice/input.go b/core/internal/invoice/input.go new file mode 100644 index 00000000..c4d5c1fd --- /dev/null +++ b/core/internal/invoice/input.go @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/google/uuid" +) + +// TransitionRequest is the body of POST /invoices/{id}/transitions and +// POST /credit-memos/{id}/transitions. +type TransitionRequest struct { + To *string `json:"to"` + Revision json.RawMessage `json:"revision"` + // Reason is the void's reason (required for a void). + Reason *string `json:"reason"` +} + +// CreditRequest is the JSON body of POST /credit-memos and PUT +// /credit-memos/{id}. Identifiers and numbers decode loosely and are parsed +// by Parse, so every problem is one field error with its full path, collected +// into the request's one 400 (ADR 0001 section 4). +type CreditRequest struct { + BranchID *string `json:"branch_id"` + CustomerID *string `json:"customer_id"` + InvoiceID *string `json:"invoice_id"` + JobID *string `json:"job_id"` + ShipToID *string `json:"ship_to_id"` + ReasonCode *string `json:"reason_code"` + Reason *string `json:"reason"` + Revision json.RawMessage `json:"revision"` + Lines []CreditLineRequest `json:"lines"` +} + +// CreditLineRequest is one credit memo line. A line that names an invoice +// line (invoice_line_id) takes its price, pair and discount from it and sends +// only the quantity credited, whether it goes back to stock and, optionally, +// a description. A free line (a price adjustment, a fee given back, a note) +// names a product, a charge code or only a description, with its own price. +// Quantities are negative (ADR 0001 section 7a). +type CreditLineRequest struct { + ID *string `json:"id"` + InvoiceLineID *string `json:"invoice_line_id"` + LineType *string `json:"line_type"` + ProductID *string `json:"product_id"` + ChargeCode *string `json:"charge_code"` + Description *string `json:"description"` + Quantity json.RawMessage `json:"quantity"` + UOM *string `json:"uom"` + UnitPriceTenThou json.RawMessage `json:"unit_price_ten_thousandths"` + Taxable *bool `json:"taxable"` + Restock *bool `json:"restock"` +} + +// CreditInput is a validated request ready for the service to build. +type CreditInput struct { + BranchID *uuid.UUID + CustomerID *uuid.UUID + InvoiceID *uuid.UUID + JobID *uuid.UUID + ShipToID *uuid.UUID + ReasonCode ReasonCode + Reason string + Revision *int64 + Lines []CreditLineInput +} + +// CreditLineInput is one validated credit line. +type CreditLineInput struct { + ID *uuid.UUID + InvoiceLineID *uuid.UUID + LineType salesdoc.LineType + ProductID *uuid.UUID + ChargeCode string + Description string + Quantity httpx.Quantity // negative + UOM string + UnitPrice *httpx.Price + Taxable *bool + Restock bool +} + +var ( + chargeCodePattern = regexp.MustCompile(`^[A-Z0-9_]{1,16}$`) + unitPattern = regexp.MustCompile(`^[A-Z]{1,6}$`) +) + +// Parse validates a create request in one pass. +func (req *CreditRequest) Parse() (*CreditInput, error) { return req.parse(false) } + +// ParseUpdate is Parse for PUT /credit-memos/{id}: a PUT replaces the header +// fields and the lines and applies nothing else, so a field it does not apply +// is a 400 naming it, never a silent drop. +func (req *CreditRequest) ParseUpdate() (*CreditInput, error) { return req.parse(true) } + +func isAbsentRaw(raw json.RawMessage) bool { + t := strings.TrimSpace(string(raw)) + return t == "" || t == "null" +} + +func (req *CreditRequest) parse(update bool) (*CreditInput, error) { + v := &httpx.Validator{} + d := &CreditInput{} + + if update { + v.Check(req.BranchID == nil, "branch_id", "cannot be changed by an edit: it is fixed when the credit memo is created") + if n, ok := v.Int("revision", req.Revision, false); ok { + v.Check(n >= 1, "revision", "must be a revision number, 1 or more") + d.Revision = &n + } + } else { + v.Check(isAbsentRaw(req.Revision), "revision", "is not accepted on create: a new credit memo has no revision to precondition on") + } + if id, ok := v.UUID("branch_id", req.BranchID, false); ok && !update { + d.BranchID = &id + } + if id, ok := v.UUID("customer_id", req.CustomerID, false); ok { + d.CustomerID = &id + } + if id, ok := v.UUID("invoice_id", req.InvoiceID, false); ok { + d.InvoiceID = &id + } + if d.CustomerID == nil && d.InvoiceID == nil && req.CustomerID == nil && req.InvoiceID == nil { + v.Check(false, "customer_id", "is required when the credit memo names no invoice") + } + if id, ok := v.UUID("job_id", req.JobID, false); ok { + d.JobID = &id + } + if id, ok := v.UUID("ship_to_id", req.ShipToID, false); ok { + d.ShipToID = &id + } + if req.ReasonCode == nil { + v.Check(false, "reason_code", "is required") + } else if rc, ok := ParseReasonCode(*req.ReasonCode); ok { + d.ReasonCode = rc + } else { + v.Check(false, "reason_code", "must be one of: "+strings.Join(reasonCodeNames, ", ")) + } + if req.Reason == nil || strings.TrimSpace(*req.Reason) == "" { + v.Check(false, "reason", "is required") + } else { + d.Reason = strings.TrimSpace(*req.Reason) + v.Check(len(d.Reason) <= 500, "reason", "must be at most 500 characters") + } + + credited := 0 + seen := map[uuid.UUID]bool{} + for i := range req.Lines { + path := fmt.Sprintf("lines[%d]", i) + line, ok := req.Lines[i].parse(v, path) + if !ok { + continue + } + if line.InvoiceLineID != nil { + if seen[*line.InvoiceLineID] { + v.Check(false, path+".invoice_line_id", "is named twice: send one line for each invoice line") + } + seen[*line.InvoiceLineID] = true + } + if line.LineType != salesdoc.LineText { + credited++ + } + d.Lines = append(d.Lines, line) + } + if credited == 0 && len(d.Lines) == len(req.Lines) { + v.Check(false, "lines", "needs at least one credited line") + } + if err := v.Err(); err != nil { + return nil, err + } + return d, nil +} + +func (l *CreditLineRequest) parse(v *httpx.Validator, path string) (CreditLineInput, bool) { + var d CreditLineInput + ok := true + fail := func(field, msg string) { + v.Check(false, path+"."+field, msg) + ok = false + } + if id, idOK := v.UUID(path+".id", l.ID, false); idOK { + d.ID = &id + } else if l.ID != nil { + ok = false + } + if l.Description != nil { + d.Description = strings.TrimSpace(*l.Description) + } + + // A line that names an invoice line: everything but the quantity, the + // restock flag and a description comes from the invoice line. + if l.InvoiceLineID != nil { + id, idOK := v.UUID(path+".invoice_line_id", l.InvoiceLineID, true) + if !idOK { + ok = false + } + d.InvoiceLineID = &id + for _, f := range []struct { + name string + present bool + }{ + {"line_type", l.LineType != nil}, {"product_id", l.ProductID != nil}, {"charge_code", l.ChargeCode != nil}, + {"uom", l.UOM != nil && strings.TrimSpace(*l.UOM) != ""}, + {"unit_price_ten_thousandths", !isAbsentRaw(l.UnitPriceTenThou)}, {"taxable", l.Taxable != nil}, + } { + if f.present { + fail(f.name, "comes from the invoice line: send only the quantity, restock and a description") + } + } + qty, qtyOK := v.Quantity(path+".quantity", l.Quantity, true) + if qtyOK { + if qty >= 0 { + fail("quantity", "must be negative on a credit memo line: it is the quantity returned") + } + d.Quantity = qty + } else { + ok = false + } + d.Restock = l.Restock != nil && *l.Restock + return d, ok + } + + // A free line. + if l.LineType != nil { + switch *l.LineType { + case "product", "charge", "text": + d.LineType = salesdoc.LineType(strings.ToUpper(*l.LineType)) + case "kit", "component": + fail("line_type", "a kit is credited through the invoice line that billed it; send product, charge or text") + default: + fail("line_type", "must be one of: product, charge, text") + } + } else { + switch { + case l.ChargeCode != nil: + d.LineType = salesdoc.LineCharge + case l.ProductID != nil || !isAbsentRaw(l.Quantity) || !isAbsentRaw(l.UnitPriceTenThou): + d.LineType = salesdoc.LineProduct + default: + d.LineType = salesdoc.LineText + } + } + if d.LineType == "" { + return d, false + } + if d.LineType == salesdoc.LineText { + if d.Description == "" { + fail("description", "is required on a text line") + } + for _, f := range []struct { + name string + present bool + }{ + {"product_id", l.ProductID != nil}, {"charge_code", l.ChargeCode != nil}, {"quantity", !isAbsentRaw(l.Quantity)}, + {"uom", l.UOM != nil && strings.TrimSpace(*l.UOM) != ""}, + {"unit_price_ten_thousandths", !isAbsentRaw(l.UnitPriceTenThou)}, {"taxable", l.Taxable != nil}, {"restock", l.Restock != nil}, + } { + if f.present { + fail(f.name, "is a priced field: a text line carries only a description") + } + } + return d, ok + } + + if d.LineType == salesdoc.LineCharge { + if l.ProductID != nil { + fail("product_id", "belongs to a product line; a charge line names a charge_code") + } + if l.ChargeCode == nil || strings.TrimSpace(*l.ChargeCode) == "" { + fail("charge_code", "is required on a charge line") + } else { + d.ChargeCode = strings.TrimSpace(*l.ChargeCode) + if !chargeCodePattern.MatchString(d.ChargeCode) { + fail("charge_code", "must be a charge code of one to sixteen capital letters, digits or underscores") + } + } + if l.Restock != nil && *l.Restock { + fail("restock", "a fee cannot go back to stock") + } + } else { + if l.ChargeCode != nil { + fail("charge_code", "belongs to a charge line") + } + if id, idOK := v.UUID(path+".product_id", l.ProductID, false); idOK { + d.ProductID = &id + } else if l.ProductID != nil { + ok = false + } + if d.ProductID == nil { + if d.Description == "" { + fail("description", "is required when the line names no product") + } + if l.Restock != nil && *l.Restock { + fail("restock", "needs a product: there is no stock to return a non stock line to") + } + } + if l.Taxable != nil { + if d.ProductID != nil { + fail("taxable", "comes from the product; a non stock line may send it") + } else { + d.Taxable = l.Taxable + } + } + d.Restock = l.Restock != nil && *l.Restock + } + qty, qtyOK := v.Quantity(path+".quantity", l.Quantity, true) + if qtyOK { + if qty >= 0 { + fail("quantity", "must be negative on a credit memo line: it is the quantity credited") + } + d.Quantity = qty + } else { + ok = false + } + if l.UOM != nil && strings.TrimSpace(*l.UOM) != "" { + d.UOM = strings.TrimSpace(*l.UOM) + if !unitPattern.MatchString(d.UOM) { + fail("uom", "must be a unit code of one to six capital letters, for example PCS, EA or MBF") + } + } else if d.LineType == salesdoc.LineProduct && d.ProductID == nil { + fail("uom", "is required when the line names no product") + } + if price, pOK := v.Int(path+".unit_price_ten_thousandths", l.UnitPriceTenThou, false); pOK { + if price < 0 { + fail("unit_price_ten_thousandths", "a unit price is never negative") + } + p := httpx.Price(price) + d.UnitPrice = &p + } else if !isAbsentRaw(l.UnitPriceTenThou) { + ok = false + } + if d.UnitPrice == nil && d.LineType == salesdoc.LineProduct { + fail("unit_price_ten_thousandths", "is required on a free product line: the credit memo prices what it gives back") + } + return d, ok +} diff --git a/core/internal/invoice/model.go b/core/internal/invoice/model.go index 4223e068..496956ba 100644 --- a/core/internal/invoice/model.go +++ b/core/internal/invoice/model.go @@ -4,70 +4,290 @@ package invoice import ( + "strings" "time" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" "github.com/google/uuid" ) +// InvoiceStatus is the invoice lifecycle in its storage vocabulary (ADR 0005 +// section 6.2): UPPERCASE in the database CHECK, lowercase on the wire. +// OVERDUE is not a status: it is the computed is_overdue flag and the +// overdue list filter. type InvoiceStatus string const ( - InvoiceStatusUnpaid InvoiceStatus = "UNPAID" - InvoiceStatusPartial InvoiceStatus = "PARTIAL" - InvoiceStatusPaid InvoiceStatus = "PAID" - InvoiceStatusVoid InvoiceStatus = "VOID" - InvoiceStatusOverdue InvoiceStatus = "OVERDUE" + InvoiceStatusUnpaid InvoiceStatus = "UNPAID" + InvoiceStatusPartial InvoiceStatus = "PARTIAL" + InvoiceStatusPaid InvoiceStatus = "PAID" + InvoiceStatusVoid InvoiceStatus = "VOID" + InvoiceStatusWrittenOff InvoiceStatus = "WRITTEN_OFF" ) +var invoiceStatusNames = []string{"unpaid", "partial", "paid", "void", "written_off"} + +// Status is the wire name. +func (s InvoiceStatus) Status() string { return strings.ToLower(string(s)) } + +// MarshalText writes the lowercase wire name. +func (s InvoiceStatus) MarshalText() ([]byte, error) { return []byte(s.Status()), nil } + +// ParseStatus maps a lowercase wire name to its status. Any other spelling, +// the legacy uppercase and overdue included, is not a status. +func ParseStatus(name string) (InvoiceStatus, bool) { + for _, n := range invoiceStatusNames { + if name == n { + return InvoiceStatus(strings.ToUpper(name)), true + } + } + return "", false +} + +// open reports whether the status counts in a customer's open receivable. +func (s InvoiceStatus) open() bool { return s == InvoiceStatusUnpaid || s == InvoiceStatusPartial } + +// OpenInvoiceStatuses is the canonical set of invoice statuses that contribute +// to a customer's outstanding AR balance (ADR 0005 5.3: UNPAID and PARTIAL; +// OVERDUE is gone from storage). The portal, dashboard, reporting and credit +// surfaces read the same set. +const OpenInvoiceStatuses = "'UNPAID','PARTIAL'" + +// DeliveryType and Origin are the lowercase wire spellings of the invoice's +// fulfilment and source. +type DeliveryType string + +func (d DeliveryType) MarshalText() ([]byte, error) { return []byte(strings.ToLower(string(d))), nil } + +type Origin string + +func (o Origin) MarshalText() ([]byte, error) { return []byte(strings.ToLower(string(o))), nil } + +// InvoiceSummary is the head of an invoice: a list item, and the summary of +// the full document. Money is integer cents; optional fields are present as +// null (ADR 0001 sections 7 and 12). +type InvoiceSummary struct { + ID uuid.UUID `json:"id"` + Number string `json:"number"` + BranchID uuid.UUID `json:"branch_id"` + CustomerID uuid.UUID `json:"customer_id"` + CustomerName string `json:"customer_name"` + OrderID *uuid.UUID `json:"order_id"` + JobID *uuid.UUID `json:"job_id"` + ShipToID *uuid.UUID `json:"ship_to_id"` + Status InvoiceStatus `json:"status"` + Revision int64 `json:"revision"` + Currency string `json:"currency"` + Origin Origin `json:"origin"` + + DeliveryType DeliveryType `json:"delivery_type"` + PickedUpBy *string `json:"picked_up_by"` + DeliveryID *uuid.UUID `json:"delivery_id"` + + InvoiceDate string `json:"invoice_date"` // YYYY-MM-DD, the branch's local date + DueDate *string `json:"due_date"` + PaymentTermsID uuid.UUID `json:"payment_terms_id"` + DiscountDueDate *string `json:"discount_due_date"` + DiscountPercent *httpx.Quantity `json:"discount_percent"` + + SubtotalCents httpx.Cents `json:"subtotal_cents"` + TaxCents httpx.Cents `json:"tax_cents"` + TaxRatePercent *string `json:"tax_rate_percent"` // "8.875", null when the provider answered + TaxExempt bool `json:"tax_exempt"` + TaxSource salesdoc.TaxSource `json:"tax_source"` + TotalCents httpx.Cents `json:"total_cents"` + // OpenCents is what is still owed: the total less the payments recorded + // against the invoice while it is unpaid or partial, zero when it is + // paid, written off or void. Until C2-4 stores amount_open this is read + // from the documents, never a stale snapshot. + OpenCents httpx.Cents `json:"open_cents"` + IsOverdue bool `json:"is_overdue"` + + PaidAt *httpx.Timestamp `json:"paid_at"` + GLEntryID *uuid.UUID `json:"gl_entry_id"` + VoidedAt *httpx.Timestamp `json:"voided_at"` + VoidedBy *string `json:"voided_by"` + VoidReason *string `json:"void_reason"` + CreatedAt httpx.Timestamp `json:"created_at"` + UpdatedAt httpx.Timestamp `json:"updated_at"` +} + +// Invoice is the full document: the summary, the ship-to captured at confirm +// and the lines (IN-1.8). type Invoice struct { - ID uuid.UUID `json:"id" db:"id"` - BranchID uuid.UUID `json:"branch_id" db:"branch_id"` - OrderID uuid.UUID `json:"order_id" db:"order_id"` - CustomerID uuid.UUID `json:"customer_id" db:"customer_id"` - CustomerName string `json:"customer_name,omitempty"` - Status InvoiceStatus `json:"status" db:"status"` - Subtotal int64 `json:"subtotal" db:"subtotal"` // Cents - TaxRate float64 `json:"tax_rate" db:"tax_rate"` // e.g. 0.0825 for 8.25% - TaxAmount int64 `json:"tax_amount" db:"tax_amount"` // Cents - TotalAmount int64 `json:"total_amount" db:"total_amount"` // Cents (subtotal + tax) - PaymentTerms string `json:"payment_terms" db:"payment_terms"` // NET30, NET60, COD, DUE_ON_RECEIPT - DueDate *time.Time `json:"due_date" db:"due_date"` - PaidAt *time.Time `json:"paid_at" db:"paid_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - - // Relations - Lines []InvoiceLine `json:"lines,omitempty" db:"-"` + InvoiceSummary + + ShipTo *salesdoc.ShipToSnapshot `json:"ship_to"` + Lines []InvoiceLine `json:"lines"` +} + +// InvoiceLine is the shared sales line shape (salesdoc.Line, ADR 0005 2.2) +// with the invoice's own columns: the order line it bills and the cost that +// left (8.4), shown to the roles that see margin, which are the roles that +// reach these routes. +type InvoiceLine struct { + salesdoc.Line + + OrderLineID *uuid.UUID `json:"order_line_id"` + UnitCost *httpx.Price `json:"unit_cost_ten_thousandths"` + CostCents httpx.Cents `json:"cost_cents"` +} + +// CreditStatus is the credit memo lifecycle in its storage vocabulary (ADR +// 0005 section 6.3). +type CreditStatus string + +const ( + CreditDraft CreditStatus = "DRAFT" + CreditOpen CreditStatus = "OPEN" + CreditPartial CreditStatus = "PARTIAL" + CreditApplied CreditStatus = "APPLIED" + CreditVoid CreditStatus = "VOID" +) + +var creditStatusNames = []string{"draft", "open", "partial", "applied", "void"} + +// Status is the wire name. +func (s CreditStatus) Status() string { return strings.ToLower(string(s)) } + +// MarshalText writes the lowercase wire name. +func (s CreditStatus) MarshalText() ([]byte, error) { return []byte(s.Status()), nil } + +// ParseCreditStatus maps a lowercase wire name to its status. +func ParseCreditStatus(name string) (CreditStatus, bool) { + for _, n := range creditStatusNames { + if name == n { + return CreditStatus(strings.ToUpper(name)), true + } + } + return "", false +} + +// ReasonCode is why a credit memo exists (ADR 0005 section 6.3). +type ReasonCode string + +const ( + ReasonReturn ReasonCode = "RETURN" + ReasonPriceAdjustment ReasonCode = "PRICE_ADJUSTMENT" + ReasonDamage ReasonCode = "DAMAGE" + ReasonOther ReasonCode = "OTHER" +) + +var reasonCodeNames = []string{"return", "price_adjustment", "damage", "other"} + +// MarshalText writes the lowercase wire name. +func (r ReasonCode) MarshalText() ([]byte, error) { return []byte(strings.ToLower(string(r))), nil } + +// ParseReasonCode maps a lowercase wire name. +func ParseReasonCode(name string) (ReasonCode, bool) { + for _, n := range reasonCodeNames { + if name == n { + return ReasonCode(strings.ToUpper(name)), true + } + } + return "", false } -// CreditMemo represents a credit against a customer account +// CreditMemoSummary is the head of a credit memo: a list item, and the +// summary of the full document. Lines, totals and the open amount are +// negative (ADR 0001 section 7a: a plain sum is the AR effect). The number is +// null while the memo is a draft: it is minted at post. +type CreditMemoSummary struct { + ID uuid.UUID `json:"id"` + Number *string `json:"number"` + BranchID uuid.UUID `json:"branch_id"` + CustomerID uuid.UUID `json:"customer_id"` + CustomerName string `json:"customer_name"` + InvoiceID *uuid.UUID `json:"invoice_id"` + PosReturnID *uuid.UUID `json:"pos_return_id"` + JobID *uuid.UUID `json:"job_id"` + ShipToID *uuid.UUID `json:"ship_to_id"` + Status CreditStatus `json:"status"` + Revision int64 `json:"revision"` + Currency string `json:"currency"` + ReasonCode ReasonCode `json:"reason_code"` + Reason string `json:"reason"` + + SubtotalCents httpx.Cents `json:"subtotal_cents"` + TaxCents httpx.Cents `json:"tax_cents"` + TaxRatePercent *string `json:"tax_rate_percent"` + TotalCents httpx.Cents `json:"total_cents"` + // OpenCents is the credit not yet used: the total while the memo is open + // (or partial or applied, until C2-4 stores amount_open and its + // applications), zero for a draft or a void memo. + OpenCents httpx.Cents `json:"open_cents"` + + GLEntryID *uuid.UUID `json:"gl_entry_id"` + MemoDate string `json:"memo_date"` // YYYY-MM-DD + VoidedAt *httpx.Timestamp `json:"voided_at"` + VoidedBy *string `json:"voided_by"` + VoidReason *string `json:"void_reason"` + CreatedAt httpx.Timestamp `json:"created_at"` + UpdatedAt httpx.Timestamp `json:"updated_at"` +} + +// CreditMemo is the full document: the summary and its lines. type CreditMemo struct { - ID uuid.UUID `json:"id"` - InvoiceID *uuid.UUID `json:"invoice_id,omitempty"` - CustomerID uuid.UUID `json:"customer_id"` - Amount int64 `json:"amount"` // Cents - Reason string `json:"reason"` - Status string `json:"status"` // PENDING, APPLIED, VOID - CreatedAt time.Time `json:"created_at"` - AppliedAt *time.Time `json:"applied_at,omitempty"` + CreditMemoSummary + + Lines []CreditLine `json:"lines"` } -// Payment terms constants +// CreditLine is a credit memo line: the shared line shape with negative +// quantity and extension, the invoice line it credits, whether it returns +// the goods to stock and the cost that comes back (negative: ADR 0005 8.4). +type CreditLine struct { + salesdoc.Line + + InvoiceLineID *uuid.UUID `json:"invoice_line_id"` + Restock bool `json:"restock"` + UnitCost *httpx.Price `json:"unit_cost_ten_thousandths"` + CostCents httpx.Cents `json:"cost_cents"` +} + +// Event types the module writes to the outbox (ADR 0005 section 12). const ( - TermsCOD = "COD" - TermsDueOnReceipt = "DUE_ON_RECEIPT" - TermsNet30 = "NET30" - TermsNet60 = "NET60" - TermsNet90 = "NET90" + EventInvoiceCreated = "invoice.created" + EventInvoiceVoided = "invoice.voided" + + EventCreditCreated = "credit_memo.created" + EventCreditUpdated = "credit_memo.updated" + EventCreditPosted = "credit_memo.posted" + EventCreditVoided = "credit_memo.voided" ) -type InvoiceLine struct { - ID uuid.UUID `json:"id" db:"id"` - InvoiceID uuid.UUID `json:"invoice_id" db:"invoice_id"` - ProductID uuid.UUID `json:"product_id" db:"product_id"` - ProductSKU string `json:"product_sku,omitempty"` - ProductName string `json:"product_name,omitempty"` - Quantity float64 `json:"quantity" db:"quantity"` - PriceEach int64 `json:"price_each" db:"price_each"` // Cents - CreatedAt time.Time `json:"created_at" db:"created_at"` +// --------------------------------------------------------------------------- +// The legacy writers: the counter's account charge (and tests) still hand the +// invoice module whole cents per sale unit and float quantities. C2-5 retires +// this path; it lands in the shared line shape in the repository. +// --------------------------------------------------------------------------- + +// LegacyInvoice is the invoice the counter's account charge creates through +// CreateInvoice. Money is whole cents; the terms are the customer's, by id. +type LegacyInvoice struct { + ID uuid.UUID + BranchID uuid.UUID + OrderID uuid.UUID + CustomerID uuid.UUID + Status InvoiceStatus + Subtotal int64 // cents + TaxRate float64 // e.g. 0.0825 for 8.25% + TaxAmount int64 // cents + TotalAmount int64 // cents (subtotal + tax) + DueDate *time.Time + PaidAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time + Lines []LegacyLine +} + +// LegacyLine is one line of a LegacyInvoice. +type LegacyLine struct { + ID uuid.UUID + InvoiceID uuid.UUID + ProductID uuid.UUID + Quantity float64 + PriceEach int64 // cents + CreatedAt time.Time } diff --git a/core/internal/invoice/repository.go b/core/internal/invoice/repository.go index 083ef393..3a6e3362 100644 --- a/core/internal/invoice/repository.go +++ b/core/internal/invoice/repository.go @@ -5,38 +5,36 @@ package invoice import ( "context" + "encoding/json" + "errors" "fmt" - "math" "time" + "github.com/gablelbm/gable/internal/customer" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" - "github.com/gablelbm/gable/pkg/money" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) +// Repository is the slice of the store the older callers use: the payment +// module reads and updates an invoice's status around a payment, the counter +// creates an account charge, and the order credit check sums the open +// balance. The invoice acts of C2-3 (void, the credit memo lifecycle, the +// fulfilment invoice) use Store, which the Postgres repository also +// implements; unit tests that fake only this slice never reach them. type Repository interface { - CreateInvoice(ctx context.Context, inv *Invoice) error + CreateInvoice(ctx context.Context, inv *LegacyInvoice) error GetInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) - ListInvoices(ctx context.Context) ([]Invoice, error) - ListInvoicesPaginated(ctx context.Context, limit, offset int) ([]Invoice, int, error) UpdateInvoice(ctx context.Context, inv *Invoice) error ExistsInvoiceForOrder(ctx context.Context, orderID uuid.UUID) (bool, error) SumOpenBalanceCents(ctx context.Context, customerID uuid.UUID) (int64, error) GetBranchTaxRate(ctx context.Context, branchID *uuid.UUID) (float64, bool) - CreateCreditMemo(ctx context.Context, cm *CreditMemo) error - ListCreditMemos(ctx context.Context, customerID uuid.UUID) ([]CreditMemo, error) - UpdateCreditMemo(ctx context.Context, cm *CreditMemo) error } -// OpenInvoiceStatuses is the canonical set of invoice statuses that contribute -// to a customer's outstanding AR balance. The portal, dashboard, reporting, and -// credit-limit surfaces all use this same set so their "current balance" agrees. -// NOTE: this sums full invoice totals and does not yet net partial payments on -// PARTIAL invoices — a known follow-up shared by all those surfaces. -const OpenInvoiceStatuses = "'UNPAID','PARTIAL','OVERDUE'" - type PostgresRepository struct { db *database.DB } @@ -45,88 +43,297 @@ func NewRepository(db *database.DB) *PostgresRepository { return &PostgresRepository{db: db} } -func (r *PostgresRepository) CreateInvoice(ctx context.Context, inv *Invoice) error { - exec := r.db.GetExecutor(ctx) +// referenceFields maps the foreign keys a write can violate to the request +// field that named the missing record, so a bad reference is a 400 naming it +// and not a 500 from the database. +var referenceFields = map[string]string{ + "credit_memos_customer_id_fkey": "customer_id", + "credit_memos_invoice_id_fkey": "invoice_id", + "credit_memos_project_id_fkey": "job_id", + "credit_memos_ship_to_id_fkey": "ship_to_id", + "credit_memos_branch_id_fkey": "branch_id", + "credit_memo_lines_product_id_fkey": "lines.product_id", + "credit_memo_lines_charge_code_id_fkey": "lines.charge_code", + "credit_memo_lines_invoice_line_id_fkey": "lines.invoice_line_id", +} - if inv.ID == uuid.Nil { - inv.ID = uuid.New() +func validationFailed(msg string, details ...httpx.FieldError) *httpx.Error { + return &httpx.Error{Status: 400, Code: httpx.CodeValidationFailed, Message: msg, Details: details} +} + +func mapWriteError(err error, what string) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" { + if field, ok := referenceFields[pgErr.ConstraintName]; ok { + return validationFailed("a referenced record does not exist", + httpx.FieldError{Field: field, Message: "no such record"}) + } } - now := time.Now() - inv.CreatedAt = now - inv.UpdatedAt = now - if inv.Status == "" { - inv.Status = InvoiceStatusUnpaid + return fmt.Errorf("%s: %w", what, err) +} + +// --------------------------------------------------------------------------- +// The branch wall (ADR 0007 section 2.3): a context branch reads its own +// rows; with no context branch a bound non-admin user reads the branches +// granted to the user, none granted none; an administrator without a header, +// an unbound key, the single-branch switch and dev mode read every branch's. +// The wall applies to EVERY read and every lock, not only the list. +// --------------------------------------------------------------------------- + +// wall is the predicate on an aliased table's branch_id; branch and grants +// are the placeholders it uses. +func wall(alias string, branch, grants int) string { + return fmt.Sprintf(`( + ($%[2]d::uuid IS NOT NULL AND %[1]s.branch_id = $%[2]d) + OR ($%[2]d::uuid IS NULL AND $%[3]d::text IS NOT NULL AND %[1]s.branch_id IN + (SELECT branch_id FROM user_locations WHERE user_sub = $%[3]d)) + OR ($%[2]d::uuid IS NULL AND $%[3]d::text IS NULL) + )`, alias, branch, grants) +} + +// overdueExpr is the computed is_overdue flag: open and due before the +// branch's today. +const overdueExpr = `(i.status IN ('UNPAID', 'PARTIAL') AND i.due_date IS NOT NULL + AND i.due_date < (NOW() AT TIME ZONE COALESCE((SELECT lc.timezone FROM locations lc WHERE lc.id = i.branch_id), 'UTC'))::date)` + +// openExpr is the open amount in cents: the total less the payments recorded +// against the invoice while it is unpaid or partial. +const openExpr = `CASE WHEN i.status IN ('UNPAID', 'PARTIAL') THEN GREATEST( + ROUND(i.total_amount * 100)::bigint + - COALESCE((SELECT SUM(ROUND(p.amount * 100)::bigint) FROM payments p WHERE p.invoice_id = i.id), 0), 0) + ELSE 0 END` + +// summaryColumns is the one invoice header projection: a list item and the +// head of the full document both read it. +const summaryColumns = ` + i.id, i.number, i.branch_id, i.customer_id, COALESCE(c.name, ''), i.order_id, i.project_id, i.ship_to_id, + i.status, i.revision, i.currency, i.origin, i.delivery_type, i.picked_up_by, i.delivery_id, + to_char(i.invoice_date, 'YYYY-MM-DD'), to_char(i.due_date, 'YYYY-MM-DD'), i.payment_terms_id, + to_char(i.discount_due_date, 'YYYY-MM-DD'), ROUND(i.discount_percent * 10000)::bigint, + ROUND(COALESCE(i.subtotal, 0) * 100)::bigint, ROUND(COALESCE(i.tax_amount, 0) * 100)::bigint, + i.tax_rate::text, i.tax_exempt, i.tax_source, ROUND(i.total_amount * 100)::bigint, + ` + openExpr + `, ` + overdueExpr + `, + i.paid_at, i.gl_entry_id, i.voided_at, i.voided_by, i.void_reason, i.created_at, i.updated_at` + +const summaryFrom = ` + FROM invoices i + LEFT JOIN customers c ON c.id = i.customer_id` + +func scanSummary(row pgx.Row, s *InvoiceSummary, extra ...any) error { + var ( + status, origin, delivery, taxSource string + discount *int64 + subtotal, tax, total, open int64 + taxRate *string + paid, voided *time.Time + created, updated time.Time + ) + dest := append([]any{ + &s.ID, &s.Number, &s.BranchID, &s.CustomerID, &s.CustomerName, &s.OrderID, &s.JobID, &s.ShipToID, + &status, &s.Revision, &s.Currency, &origin, &delivery, &s.PickedUpBy, &s.DeliveryID, + &s.InvoiceDate, &s.DueDate, &s.PaymentTermsID, + &s.DiscountDueDate, &discount, + &subtotal, &tax, &taxRate, &s.TaxExempt, &taxSource, &total, + &open, &s.IsOverdue, + &paid, &s.GLEntryID, &voided, &s.VoidedBy, &s.VoidReason, &created, &updated, + }, extra...) + if err := row.Scan(dest...); err != nil { + return err } + s.Status = InvoiceStatus(status) + s.Origin, s.DeliveryType = Origin(origin), DeliveryType(delivery) + s.TaxSource = salesdoc.TaxSource(taxSource) + s.SubtotalCents, s.TaxCents, s.TotalCents, s.OpenCents = httpx.Cents(subtotal), httpx.Cents(tax), httpx.Cents(total), httpx.Cents(open) + s.DiscountPercent = salesdoc.PtrQuantity(discount) + if taxRate != nil { + if pct, err := salesdoc.RateToPercent(*taxRate); err == nil { + s.TaxRatePercent = &pct + } + } + s.PaidAt, s.VoidedAt = httpx.PtrTimestamp(paid), httpx.PtrTimestamp(voided) + s.CreatedAt, s.UpdatedAt = httpx.TimestampOf(created), httpx.TimestampOf(updated) + return nil +} - // Insert Invoice - // Convert Cents -> Dollars - totalAmountFloat := float64(inv.TotalAmount) / 100.0 - subtotalFloat := float64(inv.Subtotal) / 100.0 - taxAmountFloat := float64(inv.TaxAmount) / 100.0 +// ListFilter is the list's filters and keyset position. +type ListFilter struct { + Statuses []InvoiceStatus + CustomerID *uuid.UUID + JobID *uuid.UUID + ShipToID *uuid.UUID + OrderID *uuid.UUID + Overdue *bool + AfterTime *time.Time + AfterID uuid.UUID + Limit int +} - // branch_id falls back to default when caller hasn't set one. - var branchArg any - if inv.BranchID != uuid.Nil { - branchArg = inv.BranchID - } else if bid := middleware.BranchIDForQuery(ctx); bid != nil { - branchArg = *bid - inv.BranchID = *bid +func statusStrings(states []InvoiceStatus) []string { + out := make([]string, len(states)) + for i, s := range states { + out[i] = string(s) } - queryInv := ` - INSERT INTO invoices (id, order_id, customer_id, status, total_amount, subtotal, tax_rate, tax_amount, payment_terms, due_date, paid_at, created_at, updated_at, branch_id) - VALUES ($1, NULLIF($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, - COALESCE($14::uuid, (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'))) - ` - _, err := exec.Exec(ctx, queryInv, - inv.ID, inv.OrderID, inv.CustomerID, inv.Status, totalAmountFloat, subtotalFloat, inv.TaxRate, taxAmountFloat, inv.PaymentTerms, inv.DueDate, inv.PaidAt, inv.CreatedAt, inv.UpdatedAt, branchArg, - ) + return out +} + +// listWhere is the shared predicate of the list and its count: the branch +// wall, then the filters that filter. The count ignores the keyset position. +var listWhere = "WHERE " + wall("i", 1, 2) + ` + AND (cardinality($3::text[]) = 0 OR i.status = ANY($3)) + AND ($4::uuid IS NULL OR i.customer_id = $4) + AND ($5::uuid IS NULL OR i.project_id = $5) + AND ($6::uuid IS NULL OR i.ship_to_id = $6) + AND ($7::uuid IS NULL OR i.order_id = $7) + AND ($8::boolean IS NULL OR ` + overdueExpr + ` = $8)` + +func (r *PostgresRepository) listArgs(ctx context.Context, f ListFilter) []any { + return []any{middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx), statusStrings(f.Statuses), + f.CustomerID, f.JobID, f.ShipToID, f.OrderID, f.Overdue} +} + +// ListInvoices answers one page, newest first by (created_at, id). +func (r *PostgresRepository) ListInvoices(ctx context.Context, f ListFilter) ([]InvoiceSummary, error) { + args := append(r.listArgs(ctx, f), f.AfterTime, f.AfterID, f.Limit) + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` + SELECT `+summaryColumns+summaryFrom+listWhere+` + AND ($9::timestamptz IS NULL OR (i.created_at, i.id) < ($9, $10::uuid)) + ORDER BY i.created_at DESC, i.id DESC + LIMIT $11`, args...) if err != nil { - return fmt.Errorf("failed to insert invoice: %w", err) + return nil, fmt.Errorf("failed to list invoices: %w", err) } + defer rows.Close() + out := []InvoiceSummary{} + for rows.Next() { + var s InvoiceSummary + if err := scanSummary(rows, &s); err != nil { + return nil, fmt.Errorf("failed to scan invoice: %w", err) + } + out = append(out, s) + } + return out, rows.Err() +} - // Insert Lines - // The legacy writers (the counter's account charge, the seed, tests) still - // hand over whole cents per sale unit; the line lands in the shared shape - // (ADR 0005 2.2) with the unit and description from the product, the pair - // 1 and 1 and the extension rounded once. C2-3 and C2-5 retire this path. - queryLine := ` - INSERT INTO invoice_lines (id, invoice_id, product_id, quantity, price_each, created_at, - sku, description, uom, price_uom, uom_qty, price_uom_qty, unit_price, line_total) - SELECT $1, $2, $3, $4::numeric, $5::numeric, $6, - p.sku, COALESCE(p.description, p.sku, ''), p.uom_primary::text, p.uom_primary::text, 1, 1, $5::numeric, - ROUND($4::numeric * $5::numeric, 2) - FROM products p WHERE p.id = $3 - ` - for i := range inv.Lines { - line := &inv.Lines[i] - if line.ID == uuid.Nil { - line.ID = uuid.New() +// CountInvoices is the size of the filtered set, for ?include=total. +func (r *PostgresRepository) CountInvoices(ctx context.Context, f ListFilter) (int64, error) { + var n int64 + if err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM invoices i`+listWhere, r.listArgs(ctx, f)...).Scan(&n); err != nil { + return 0, fmt.Errorf("failed to count invoices: %w", err) + } + return n, nil +} + +var errInvoiceNotFound = httpx.NotFound("invoice not found") + +// GetInvoice reads one invoice with its lines, held to the branch wall. A +// missing invoice, or one outside the caller's branches, is a 404. +func (r *PostgresRepository) GetInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) { + inv, err := r.invoiceHead(ctx, id, false) + if err != nil { + return nil, err + } + lines, err := r.invoiceLines(ctx, id) + if err != nil { + return nil, err + } + inv.Lines = lines + return inv, nil +} + +// LockInvoice takes the invoice row FOR UPDATE (section 11, step 4), held to +// the branch wall, and reads it. Lock first, read second: a joined select +// cannot lock the joined rows. +func (r *PostgresRepository) LockInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) { + var locked uuid.UUID + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT i.id FROM invoices i WHERE i.id = $1 AND `+wall("i", 2, 3)+` FOR UPDATE`, + id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)).Scan(&locked) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errInvoiceNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to lock invoice: %w", err) + } + return r.GetInvoice(ctx, id) +} + +// invoiceHead reads the invoice header; the wall applies. +func (r *PostgresRepository) invoiceHead(ctx context.Context, id uuid.UUID, _ bool) (*Invoice, error) { + var inv Invoice + var snapshot []byte + row := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT `+summaryColumns+`, i.ship_to_snapshot`+summaryFrom+` + WHERE i.id = $1 AND `+wall("i", 2, 3), + id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + if err := scanSummary(row, &inv.InvoiceSummary, &snapshot); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errInvoiceNotFound } - line.InvoiceID = inv.ID - // Convert PriceEach (Cents -> Dollars) - priceEachFloat := float64(line.PriceEach) / 100.0 + return nil, fmt.Errorf("failed to get invoice: %w", err) + } + if len(snapshot) > 0 { + var s salesdoc.ShipToSnapshot + if err := json.Unmarshal(snapshot, &s); err != nil { + return nil, fmt.Errorf("failed to read the ship-to snapshot: %w", err) + } + inv.ShipTo = &s + } + return &inv, nil +} - tag, err := exec.Exec(ctx, queryLine, - line.ID, line.InvoiceID, line.ProductID, line.Quantity, priceEachFloat, now, +func (r *PostgresRepository) invoiceLines(ctx context.Context, id uuid.UUID) ([]InvoiceLine, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` + SELECT `+salesdoc.LineColumns("COALESCE(ol.is_special_order, false), ol.vendor_id, ROUND(ol.special_order_cost * 10000)::bigint")+`, + l.order_line_id, ROUND(l.unit_cost * 10000)::bigint, ROUND(l.cost * 100)::bigint + FROM invoice_lines l + LEFT JOIN charge_codes cc ON cc.id = l.charge_code_id + LEFT JOIN order_lines ol ON ol.id = l.order_line_id + WHERE l.invoice_id = $1 + ORDER BY l.position, l.created_at, l.id`, id) + if err != nil { + return nil, fmt.Errorf("failed to get invoice lines: %w", err) + } + defer rows.Close() + out := []InvoiceLine{} + for rows.Next() { + var ( + l InvoiceLine + ls salesdoc.LineScan + unitCost *int64 + cost int64 ) - if err != nil { - return fmt.Errorf("failed to insert invoice line: %w", err) - } - if tag.RowsAffected() == 0 { - return fmt.Errorf("failed to insert invoice line: product %s does not exist", line.ProductID) + if err := rows.Scan(append(ls.Dests(&l.Line), &l.OrderLineID, &unitCost, &cost)...); err != nil { + return nil, fmt.Errorf("failed to scan invoice line: %w", err) } + ls.Finish(&l.Line) + l.UnitCost = salesdoc.PtrPrice(unitCost) + l.CostCents = httpx.Cents(cost) + out = append(out, l) } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// The older callers' slice. +// --------------------------------------------------------------------------- +// UpdateInvoice writes the status and paid_at the payment module derives and +// moves the revision (an in process write: no client precondition). +func (r *PostgresRepository) UpdateInvoice(ctx context.Context, inv *Invoice) error { + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE invoices SET status = $1, paid_at = $2, updated_at = NOW(), revision = revision + 1 + WHERE id = $3`, string(inv.Status), inv.PaidAt, inv.ID) + if err != nil { + return fmt.Errorf("failed to update invoice: %w", err) + } return nil } -// ExistsInvoiceForOrder reports whether an invoice already exists for the order. -// Used to prevent double-invoicing (an order fulfilled and then delivered would -// otherwise produce two invoices and double the customer's AR). +// ExistsInvoiceForOrder reports whether an invoice (not void) exists for the order. func (r *PostgresRepository) ExistsInvoiceForOrder(ctx context.Context, orderID uuid.UUID) (bool, error) { var exists bool err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT EXISTS(SELECT 1 FROM invoices WHERE order_id = $1)`, orderID).Scan(&exists) + `SELECT EXISTS(SELECT 1 FROM invoices WHERE order_id = $1 AND status <> 'VOID')`, orderID).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check existing invoice for order: %w", err) } @@ -134,28 +341,27 @@ func (r *PostgresRepository) ExistsInvoiceForOrder(ctx context.Context, orderID } // SumOpenBalanceCents returns the customer's outstanding AR balance, computed -// live from open invoices (the column customers.balance_due is unmaintained). -// total_amount is stored as dollars; convert to cents for the app convention. +// live from open invoices (total less the payments recorded against each). func (r *PostgresRepository) SumOpenBalanceCents(ctx context.Context, customerID uuid.UUID) (int64, error) { - var dollars float64 - query := `SELECT COALESCE(SUM(total_amount), 0) FROM invoices WHERE customer_id = $1 AND status IN (` + OpenInvoiceStatuses + `)` - if err := r.db.GetExecutor(ctx).QueryRow(ctx, query, customerID).Scan(&dollars); err != nil { + var cents int64 + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT COALESCE(SUM(`+openExpr+`), 0)::bigint FROM invoices i + WHERE i.customer_id = $1 AND i.status IN (`+OpenInvoiceStatuses+`)`, customerID).Scan(¢s) + if err != nil { return 0, fmt.Errorf("failed to sum open balance: %w", err) } - return int64(math.Round(dollars * 100)), nil + return cents, nil } // GetBranchTaxRate returns the default sales tax rate configured on a branch -// (locations.default_tax_rate, e.g. 0.12 for BC). branchID may be nil, in which -// case the active branch context — or the system default branch — is used. -// Returns (rate, true) only when a positive rate is found; callers fall back to -// invoice.DefaultTaxRate otherwise. default_tax_rate is a nullable NUMERIC. +// (locations.default_tax_rate). branchID may be nil, in which case the active +// branch context, or the system default branch, is used. It answers true only +// for a positive rate; the counter's legacy path falls back to DefaultTaxRate. func (r *PostgresRepository) GetBranchTaxRate(ctx context.Context, branchID *uuid.UUID) (float64, bool) { bid := branchID if bid == nil || *bid == uuid.Nil { bid = middleware.BranchIDForQuery(ctx) } - var rate *float64 var err error if bid != nil { @@ -172,232 +378,200 @@ func (r *PostgresRepository) GetBranchTaxRate(ctx context.Context, branchID *uui return *rate, true } -func (r *PostgresRepository) GetInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) { - branchID := middleware.BranchIDForQuery(ctx) - queryInv := ` - SELECT i.id, i.order_id, i.customer_id, COALESCE(c.name, ''), i.status, - i.total_amount, COALESCE(i.subtotal, i.total_amount), COALESCE(i.tax_rate, 0), COALESCE(i.tax_amount, 0), - COALESCE(i.payment_terms, 'NET30'), - i.due_date, i.paid_at, i.created_at, i.updated_at, i.branch_id - FROM invoices i - LEFT JOIN customers c ON c.id = i.customer_id - WHERE i.id = $1 - AND ($2::uuid IS NULL OR i.branch_id = $2) - ` - var inv Invoice - var totalAmountFloat, subtotalFloat, taxAmountFloat float64 - err := r.db.GetExecutor(ctx).QueryRow(ctx, queryInv, id, branchID).Scan( - &inv.ID, &inv.OrderID, &inv.CustomerID, &inv.CustomerName, &inv.Status, - &totalAmountFloat, &subtotalFloat, &inv.TaxRate, &taxAmountFloat, - &inv.PaymentTerms, - &inv.DueDate, &inv.PaidAt, &inv.CreatedAt, &inv.UpdatedAt, &inv.BranchID, - ) - if err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("invoice not found") - } - return nil, fmt.Errorf("failed to get invoice: %w", err) +// CreateInvoice is the counter's account charge (the legacy path C2-5 +// retires): the customer row is locked first (section 11: nothing takes the +// customer after the gapless counter), the invoice numbers itself through the +// column DEFAULT, the terms are the customer's by id, and the line lands in +// the shared shape with the unit and description from the product, the pair +// 1 and 1 and the extension rounded once. +func (r *PostgresRepository) CreateInvoice(ctx context.Context, inv *LegacyInvoice) error { + exec := r.db.GetExecutor(ctx) + if inv.ID == uuid.Nil { + inv.ID = uuid.New() } - inv.TotalAmount = money.DollarsToCents(totalAmountFloat) - inv.Subtotal = money.DollarsToCents(subtotalFloat) - inv.TaxAmount = money.DollarsToCents(taxAmountFloat) - - // Get Lines with product names - queryLines := ` - SELECT il.id, il.invoice_id, COALESCE(il.product_id, '00000000-0000-0000-0000-000000000000'::uuid), - COALESCE(p.sku, il.sku, ''), COALESCE(p.description, il.description, ''), - COALESCE(il.quantity, 0), COALESCE(il.price_each, 0), il.created_at - FROM invoice_lines il - LEFT JOIN products p ON p.id = il.product_id - WHERE il.invoice_id = $1 AND il.line_type <> 'TEXT' - ORDER BY il.position, il.created_at, il.id - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, queryLines, id) - if err != nil { - return nil, fmt.Errorf("failed to get invoice lines: %w", err) + now := time.Now() + inv.CreatedAt, inv.UpdatedAt = now, now + if inv.Status == "" { + inv.Status = InvoiceStatusUnpaid } - defer rows.Close() - - for rows.Next() { - var l InvoiceLine - var priceEachFloat float64 - if err := rows.Scan(&l.ID, &l.InvoiceID, &l.ProductID, &l.ProductSKU, &l.ProductName, &l.Quantity, &priceEachFloat, &l.CreatedAt); err != nil { - return nil, fmt.Errorf("failed to scan invoice line: %w", err) - } - l.PriceEach = money.DollarsToCents(priceEachFloat) - inv.Lines = append(inv.Lines, l) + if err := r.LockCustomer(ctx, inv.CustomerID); err != nil { + return err } - - return &inv, nil -} - -func (r *PostgresRepository) ListInvoices(ctx context.Context) ([]Invoice, error) { - branchID := middleware.BranchIDForQuery(ctx) - query := ` - SELECT i.id, i.order_id, i.customer_id, COALESCE(c.name, ''), i.status, - i.total_amount, COALESCE(i.subtotal, i.total_amount), COALESCE(i.tax_rate, 0), COALESCE(i.tax_amount, 0), - COALESCE(i.payment_terms, 'NET30'), - i.due_date, i.paid_at, i.created_at, i.updated_at, i.branch_id - FROM invoices i - LEFT JOIN customers c ON c.id = i.customer_id - WHERE ($1::uuid IS NULL OR i.branch_id = $1) - ORDER BY i.created_at DESC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, branchID) + var branchArg any + if inv.BranchID != uuid.Nil { + branchArg = inv.BranchID + } else if bid := middleware.BranchIDForQuery(ctx); bid != nil { + branchArg = *bid + inv.BranchID = *bid + } + // The terms, by id, and the dates they give, from the invoice's date. + terms, err := r.TermsFor(ctx, inv.CustomerID) if err != nil { - return nil, fmt.Errorf("failed to list invoices: %w", err) + return err } - defer rows.Close() - - var invoices []Invoice - for rows.Next() { - var inv Invoice - var totalAmountFloat, subtotalFloat, taxAmountFloat float64 - if err := rows.Scan( - &inv.ID, &inv.OrderID, &inv.CustomerID, &inv.CustomerName, &inv.Status, - &totalAmountFloat, &subtotalFloat, &inv.TaxRate, &taxAmountFloat, - &inv.PaymentTerms, - &inv.DueDate, &inv.PaidAt, &inv.CreatedAt, &inv.UpdatedAt, &inv.BranchID, - ); err != nil { - return nil, fmt.Errorf("failed to scan invoice: %w", err) - } - inv.TotalAmount = money.DollarsToCents(totalAmountFloat) - inv.Subtotal = money.DollarsToCents(subtotalFloat) - inv.TaxAmount = money.DollarsToCents(taxAmountFloat) - invoices = append(invoices, inv) - } - return invoices, nil -} - -func (r *PostgresRepository) ListInvoicesPaginated(ctx context.Context, limit, offset int) ([]Invoice, int, error) { - branchID := middleware.BranchIDForQuery(ctx) - // Get total count - countQuery := `SELECT COUNT(*) FROM invoices WHERE ($1::uuid IS NULL OR branch_id = $1)` - var total int - if err := r.db.GetExecutor(ctx).QueryRow(ctx, countQuery, branchID).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("failed to count invoices: %w", err) - } - - query := ` - SELECT i.id, i.order_id, i.customer_id, COALESCE(c.name, ''), i.status, - i.total_amount, COALESCE(i.subtotal, i.total_amount), COALESCE(i.tax_rate, 0), COALESCE(i.tax_amount, 0), - COALESCE(i.payment_terms, 'NET30'), - i.due_date, i.paid_at, i.created_at, i.updated_at, i.branch_id - FROM invoices i - LEFT JOIN customers c ON c.id = i.customer_id - WHERE ($1::uuid IS NULL OR i.branch_id = $1) - ORDER BY i.created_at DESC - LIMIT $2 OFFSET $3 - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, branchID, limit, offset) + var due, discountDue any + if inv.DueDate != nil { + due = inv.DueDate.Format("2006-01-02") + } else { + due = terms.Terms.DueDate(now).Format("2006-01-02") + } + var discountPct any + if dd := terms.Terms.DiscountDueDate(now); dd != nil && terms.Terms.DiscountPercent != nil { + discountDue, discountPct = dd.Format("2006-01-02"), int64(*terms.Terms.DiscountPercent) + } + _, err = exec.Exec(ctx, ` + INSERT INTO invoices (id, order_id, customer_id, status, total_amount, subtotal, tax_rate, tax_amount, + payment_terms_id, due_date, discount_due_date, discount_percent, paid_at, created_at, updated_at, branch_id, origin) + VALUES ($1, NULLIF($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid), $3, $4, $5::numeric / 100, $6::numeric / 100, + $7, $8::numeric / 100, $9, $10::date, $11::date, $12::numeric / 10000, $13, $14, $15, + COALESCE($16::uuid, (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')), + CASE WHEN NULLIF($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid) IS NULL THEN 'POS' ELSE 'ORDER' END)`, + inv.ID, inv.OrderID, inv.CustomerID, string(inv.Status), inv.TotalAmount, inv.Subtotal, inv.TaxRate, inv.TaxAmount, + terms.ID, due, discountDue, discountPct, inv.PaidAt, inv.CreatedAt, inv.UpdatedAt, branchArg) if err != nil { - return nil, 0, fmt.Errorf("failed to list invoices: %w", err) + return fmt.Errorf("failed to insert invoice: %w", err) } - defer rows.Close() - - var invoices []Invoice - for rows.Next() { - var inv Invoice - var totalAmountFloat, subtotalFloat, taxAmountFloat float64 - if err := rows.Scan( - &inv.ID, &inv.OrderID, &inv.CustomerID, &inv.CustomerName, &inv.Status, - &totalAmountFloat, &subtotalFloat, &inv.TaxRate, &taxAmountFloat, - &inv.PaymentTerms, - &inv.DueDate, &inv.PaidAt, &inv.CreatedAt, &inv.UpdatedAt, &inv.BranchID, - ); err != nil { - return nil, 0, fmt.Errorf("failed to scan invoice: %w", err) + queryLine := ` + INSERT INTO invoice_lines (id, invoice_id, product_id, quantity, price_each, created_at, + sku, description, uom, price_uom, uom_qty, price_uom_qty, unit_price, line_total) + SELECT $1, $2, $3, $4::numeric, $5::numeric, $6, + p.sku, COALESCE(p.description, p.sku, ''), p.uom_primary::text, p.uom_primary::text, 1, 1, $5::numeric, + ROUND($4::numeric * $5::numeric, 2) + FROM products p WHERE p.id = $3` + for i := range inv.Lines { + line := &inv.Lines[i] + if line.ID == uuid.Nil { + line.ID = uuid.New() + } + line.InvoiceID = inv.ID + priceEach := float64(line.PriceEach) / 100.0 + tag, err := exec.Exec(ctx, queryLine, line.ID, line.InvoiceID, line.ProductID, line.Quantity, priceEach, now) + if err != nil { + return fmt.Errorf("failed to insert invoice line: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("failed to insert invoice line: product %s does not exist", line.ProductID) } - inv.TotalAmount = money.DollarsToCents(totalAmountFloat) - inv.Subtotal = money.DollarsToCents(subtotalFloat) - inv.TaxAmount = money.DollarsToCents(taxAmountFloat) - invoices = append(invoices, inv) } - return invoices, total, nil + return nil } -func (r *PostgresRepository) UpdateInvoice(ctx context.Context, inv *Invoice) error { - inv.UpdatedAt = time.Now() - query := ` - UPDATE invoices - SET status = $1, paid_at = $2, updated_at = $3 - WHERE id = $4 - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, inv.Status, inv.PaidAt, inv.UpdatedAt, inv.ID) +// --------------------------------------------------------------------------- +// The locks and the numbering of section 11. +// --------------------------------------------------------------------------- + +// LockCustomer takes the customer's row FOR UPDATE (section 11, step 7): the +// AR core's balance_due lock, taken before the gapless counter so no code +// takes the customer after a number. +func (r *PostgresRepository) LockCustomer(ctx context.Context, customerID uuid.UUID) error { + var id uuid.UUID + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT id FROM customers WHERE id = $1 FOR UPDATE`, customerID).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return validationFailed("a referenced record does not exist", httpx.FieldError{Field: "customer_id", Message: "no such customer"}) + } if err != nil { - return fmt.Errorf("failed to update invoice: %w", err) + return fmt.Errorf("failed to lock the customer: %w", err) } return nil } -func (r *PostgresRepository) CreateCreditMemo(ctx context.Context, cm *CreditMemo) error { - if cm.ID == uuid.Nil { - cm.ID = uuid.New() - } - cm.CreatedAt = time.Now() - amountFloat := float64(cm.Amount) / 100.0 - - query := ` - INSERT INTO credit_memos (id, invoice_id, customer_id, amount, reason, status, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - cm.ID, cm.InvoiceID, cm.CustomerID, amountFloat, cm.Reason, cm.Status, cm.CreatedAt, - ) - if err != nil { - return fmt.Errorf("failed to create credit memo: %w", err) +// LockCustomerCredit serializes the acts that add to one customer's credit +// exposure with the order module's transaction scoped advisory lock (section +// 11, step 1a: the same key as the order's confirm, release and fulfilment). +// It is not a row lock, so it adds no edge to the lock order. +func (r *PostgresRepository) LockCustomerCredit(ctx context.Context, customerID uuid.UUID) error { + if _, err := r.db.GetExecutor(ctx).Exec(ctx, + `SELECT pg_advisory_xact_lock(hashtextextended('order-credit:' || $1::text, 0))`, customerID.String()); err != nil { + return fmt.Errorf("failed to serialize the customer's credit acts: %w", err) } return nil } -func (r *PostgresRepository) ListCreditMemos(ctx context.Context, customerID uuid.UUID) ([]CreditMemo, error) { - query := ` - SELECT id, invoice_id, customer_id, amount, reason, status, created_at, applied_at - FROM credit_memos - WHERE customer_id = $1 - ORDER BY created_at DESC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, customerID) +// NextInvoiceNumber mints the next gapless IN- number through the caller's +// transaction (section 11, step 8). +func (r *PostgresRepository) NextInvoiceNumber(ctx context.Context) (string, error) { + return httpx.NextGaplessNumber(ctx, r.db.GetExecutor(ctx), "invoice", "IN", httpx.DefaultDocNumberWidth) +} + +// NextCreditMemoNumber mints the next gapless CM- number. +func (r *PostgresRepository) NextCreditMemoNumber(ctx context.Context) (string, error) { + return httpx.NextGaplessNumber(ctx, r.db.GetExecutor(ctx), "credit_memo", "CM", httpx.DefaultDocNumberWidth) +} + +// CustomerTerms is a customer's payment terms row with its id. +type CustomerTerms struct { + ID uuid.UUID + Terms customer.PaymentTerms +} + +// TermsFor reads the customer's payment terms (ADR 0005 7.2). +func (r *PostgresRepository) TermsFor(ctx context.Context, customerID uuid.UUID) (*CustomerTerms, error) { + var ( + t CustomerTerms + kind string + discount *int64 + netDays, dom *int + discDays *int + ) + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT t.id, t.code, t.name, t.kind, t.net_days, t.day_of_month, ROUND(t.discount_percent * 10000)::bigint, t.discount_days + FROM customers c JOIN payment_terms t ON t.id = c.payment_terms_id + WHERE c.id = $1`, customerID).Scan(&t.ID, &t.Terms.Code, &t.Terms.Name, &kind, &netDays, &dom, &discount, &discDays) + if errors.Is(err, pgx.ErrNoRows) { + return nil, validationFailed("a referenced record does not exist", httpx.FieldError{Field: "customer_id", Message: "no such customer"}) + } if err != nil { - return nil, fmt.Errorf("failed to list credit memos: %w", err) + return nil, fmt.Errorf("failed to read the customer's terms: %w", err) } - defer rows.Close() - - var memos []CreditMemo - for rows.Next() { - var cm CreditMemo - var amountFloat float64 - if err := rows.Scan(&cm.ID, &cm.InvoiceID, &cm.CustomerID, &amountFloat, &cm.Reason, &cm.Status, &cm.CreatedAt, &cm.AppliedAt); err != nil { - return nil, fmt.Errorf("failed to scan credit memo: %w", err) - } - cm.Amount = money.DollarsToCents(amountFloat) - memos = append(memos, cm) + t.Terms.ID = t.ID + t.Terms.Kind = customer.TermsKind(kind) + t.Terms.NetDays, t.Terms.DayOfMonth, t.Terms.DiscountDays = netDays, dom, discDays + if discount != nil { + q := httpx.Quantity(*discount) + t.Terms.DiscountPercent = &q } - return memos, nil + return &t, nil } -func (r *PostgresRepository) UpdateCreditMemo(ctx context.Context, cm *CreditMemo) error { - query := `UPDATE credit_memos SET status = $1, applied_at = $2 WHERE id = $3` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, cm.Status, cm.AppliedAt, cm.ID) - return err +// BranchLocalDate is the branch's local calendar date at the instant (the +// business date of ADR 0005 section 8.1). +func (r *PostgresRepository) BranchLocalDate(ctx context.Context, branchID uuid.UUID, at time.Time) (time.Time, error) { + var d time.Time + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT ($1::timestamptz AT TIME ZONE COALESCE((SELECT timezone FROM locations WHERE id = $2), 'UTC'))::date`, + at, branchID).Scan(&d) + if err != nil { + return time.Time{}, fmt.Errorf("failed to read the branch's date: %w", err) + } + return d, nil } // InsertFulfilmentInvoice writes the header and the lines of an order // fulfilment's invoice in the shape of ADR 0005 6.1 and 2.2, through the -// caller's executor. Money arrives in cents and scale 4 integers and is -// divided in SQL; price_each, kept for the readers C2-3 converts, holds the -// effective price per sale unit (the line total over the quantity). +// caller's executor, the number minted just before (the caller holds the +// customer row). Money arrives in cents and scale 4 integers and is divided +// in SQL; price_each, kept for the readers still on it, holds the effective +// price per sale unit (the line total over the quantity). func (r *PostgresRepository) InsertFulfilmentInvoice(ctx context.Context, in *FulfilmentInvoice) error { exec := r.db.GetExecutor(ctx) + var discountDue any + if in.DiscountDueDate != nil { + discountDue = in.DiscountDueDate.Format("2006-01-02") + } _, err := exec.Exec(ctx, ` - INSERT INTO invoices (id, order_id, customer_id, status, subtotal, tax_rate, tax_amount, total_amount, - payment_terms, due_date, branch_id, currency, delivery_type, picked_up_by, delivery_id, - ship_to_id, ship_to_snapshot, project_id, tax_exempt, tax_source, invoice_date, origin, created_at, updated_at) - VALUES ($1, $2, $3, 'UNPAID', $4::numeric / 100, $5::numeric, $6::numeric / 100, $7::numeric / 100, - 'NET30', ($8::date + 30)::timestamptz, $9, $10, $11, $12, $13, - $14, $15, $16, $17, $18, $8::date, 'ORDER', NOW(), NOW())`, - in.ID, in.OrderID, in.CustomerID, in.SubtotalCents, in.TaxRate, in.TaxCents, in.TotalCents, - in.InvoiceDate, in.BranchID, in.Currency, in.DeliveryType, in.PickedUpBy, in.DeliveryID, - in.ShipToID, in.ShipToSnapshot, in.ProjectID, in.TaxExempt, in.TaxSource) + INSERT INTO invoices (id, number, order_id, customer_id, status, subtotal, tax_rate, tax_amount, total_amount, + payment_terms_id, due_date, discount_due_date, discount_percent, + branch_id, currency, delivery_type, picked_up_by, delivery_id, + ship_to_id, ship_to_snapshot, project_id, tax_exempt, tax_source, invoice_date, origin, paid_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, CASE WHEN $8::bigint = 0 THEN 'PAID' ELSE 'UNPAID' END, + $5::numeric / 100, $6::numeric, $7::numeric / 100, $8::numeric / 100, + $9, $10::date, $11::date, $12::numeric / 10000, + $13, $14, $15, $16, $17, + $18, $19, $20, $21, $22, $23::date, 'ORDER', CASE WHEN $8::bigint = 0 THEN NOW() END, NOW(), NOW())`, + in.ID, in.Number, in.OrderID, in.CustomerID, in.SubtotalCents, in.TaxRate, in.TaxCents, in.TotalCents, + in.PaymentTermsID, in.DueDate.Format("2006-01-02"), discountDue, in.DiscountPercent, + in.BranchID, in.Currency, in.DeliveryType, in.PickedUpBy, in.DeliveryID, + in.ShipToID, in.ShipToSnapshot, in.ProjectID, in.TaxExempt, in.TaxSource, in.InvoiceDate) if err != nil { return fmt.Errorf("failed to insert invoice: %w", err) } @@ -407,17 +581,20 @@ func (r *PostgresRepository) InsertFulfilmentInvoice(ctx context.Context, in *Fu _, err := exec.Exec(ctx, ` INSERT INTO invoice_lines (id, invoice_id, position, line_type, parent_line_id, product_id, charge_code_id, sku, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, price_each, price_source, + priced_unit_price, override_reason, price_adjusted_by, discount_percent, discount_amount, discount_reason, line_total, taxable, revenue_account_code, order_line_id, unit_cost, cost, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::numeric / 10000, $11, $12, $13::numeric / 10000, $14::numeric / 10000, $15::numeric / 10000, - CASE WHEN $10::numeric > 0 AND $20::numeric IS NOT NULL THEN ROUND(($20::numeric / 100) / ($10::numeric / 10000), 4) ELSE $15::numeric / 10000 END, - $16, $17::numeric / 10000, $18::numeric / 100, $19, $20::numeric / 100, $21, $22, - $23, $24::numeric / 10000, $25::numeric / 100, NOW())`, + CASE WHEN $10::numeric > 0 AND $24::numeric IS NOT NULL THEN ROUND(($24::numeric / 100) / ($10::numeric / 10000), 4) ELSE $15::numeric / 10000 END, + $16, $26::numeric / 10000, $27, $28, + $17::numeric / 10000, $18::numeric / 100, $19, $24::numeric / 100, $20, $21, + $22, $23::numeric / 10000, $25::numeric / 100, NOW())`, l.ID, in.ID, l.Position, l.LineType, l.ParentLineID, l.ProductID, l.ChargeCodeID, l.SKU, l.Description, l.Quantity, l.UOM, l.PriceUOM, l.UOMQty, l.PriceUOMQty, l.UnitPrice, l.PriceSource, - l.DiscountPercent, l.DiscountCents, l.DiscountReason, l.LineTotalCents, l.Taxable, l.RevenueAccountCode, - l.OrderLineID, l.UnitCost, l.CostCents) + l.DiscountPercent, l.DiscountCents, l.DiscountReason, l.Taxable, l.RevenueAccountCode, + l.OrderLineID, l.UnitCost, l.LineTotalCents, l.CostCents, + l.PricedUnitPrice, l.OverrideReason, l.PriceAdjustedBy) if err != nil { return fmt.Errorf("failed to insert invoice line: %w", err) } @@ -432,3 +609,79 @@ func (r *PostgresRepository) SetInvoiceGLEntry(ctx context.Context, invoiceID, e } return nil } + +// --------------------------------------------------------------------------- +// The void. +// --------------------------------------------------------------------------- + +// VoidFacts are the documents that stand in the way of a void (ADR 0005 +// 6.2): a payment recorded against the invoice, an applied credit memo +// naming it, any credit memo naming it that is not void. +type VoidFacts struct { + Payments int + AppliedMemos int + LiveMemos int +} + +// VoidFactsFor reads them. The credit memo rows are read under the invoice +// lock the caller holds: a credit memo post locks the invoice first, so the +// check cannot race it. +func (r *PostgresRepository) VoidFactsFor(ctx context.Context, invoiceID uuid.UUID) (VoidFacts, error) { + var f VoidFacts + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT (SELECT count(*) FROM payments WHERE invoice_id = $1), + (SELECT count(*) FROM credit_memos WHERE invoice_id = $1 AND status IN ('APPLIED', 'PARTIAL')), + (SELECT count(*) FROM credit_memos WHERE invoice_id = $1 AND status <> 'VOID')`, invoiceID). + Scan(&f.Payments, &f.AppliedMemos, &f.LiveMemos) + if err != nil { + return f, fmt.Errorf("failed to read what stands in the way of a void: %w", err) + } + return f, nil +} + +// MarkVoid ends the invoice: status VOID, the void columns, the revision. +func (r *PostgresRepository) MarkVoid(ctx context.Context, id uuid.UUID, actor, reason string, voidedOn time.Time) error { + ct, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE invoices SET status = 'VOID', voided_at = NOW(), voided_by = NULLIF($2, ''), void_reason = $3, + voided_on = $4::date, updated_at = NOW(), revision = revision + 1 + WHERE id = $1`, id, actor, reason, voidedOn.Format("2006-01-02")) + if err != nil { + return fmt.Errorf("failed to void the invoice: %w", err) + } + if ct.RowsAffected() == 0 { + return errInvoiceNotFound + } + return nil +} + +// BilledLine is a line an invoice billed against an order line: what a void +// returns to the order (stock back on hand, quantity_fulfilled reduced). +type BilledLine struct { + OrderLineID uuid.UUID + LineType string + ProductID *uuid.UUID + Quantity httpx.Quantity +} + +// BilledLines lists the invoice lines that name an order line. +func (r *PostgresRepository) BilledLines(ctx context.Context, invoiceID uuid.UUID) ([]BilledLine, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` + SELECT order_line_id, line_type, product_id, ROUND(quantity * 10000)::bigint + FROM invoice_lines WHERE invoice_id = $1 AND order_line_id IS NOT NULL AND quantity IS NOT NULL + ORDER BY position, id`, invoiceID) + if err != nil { + return nil, fmt.Errorf("failed to read the billed lines: %w", err) + } + defer rows.Close() + var out []BilledLine + for rows.Next() { + var b BilledLine + var q int64 + if err := rows.Scan(&b.OrderLineID, &b.LineType, &b.ProductID, &q); err != nil { + return nil, fmt.Errorf("failed to scan a billed line: %w", err) + } + b.Quantity = httpx.Quantity(q) + out = append(out, b) + } + return out, rows.Err() +} diff --git a/core/internal/invoice/repository_cm.go b/core/internal/invoice/repository_cm.go new file mode 100644 index 00000000..11ff05c1 --- /dev/null +++ b/core/internal/invoice/repository_cm.go @@ -0,0 +1,520 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +var errCreditNotFound = httpx.NotFound("credit memo not found") + +// creditColumns is the one credit memo header projection. +const creditColumns = ` + cm.id, cm.number, cm.branch_id, cm.customer_id, COALESCE(c.name, ''), cm.invoice_id, cm.pos_return_id, cm.project_id, + cm.ship_to_id, cm.status, cm.revision, cm.currency, cm.reason_code, cm.reason, + ROUND(cm.subtotal * 100)::bigint, ROUND(cm.tax_amount * 100)::bigint, cm.tax_rate::text, ROUND(cm.total_amount * 100)::bigint, + CASE WHEN cm.status IN ('OPEN', 'PARTIAL') THEN ROUND(cm.total_amount * 100)::bigint ELSE 0 END, + cm.gl_entry_id, to_char(cm.memo_date, 'YYYY-MM-DD'), cm.voided_at, cm.voided_by, cm.void_reason, cm.created_at, cm.updated_at` + +const creditFrom = ` + FROM credit_memos cm + LEFT JOIN customers c ON c.id = cm.customer_id` + +func scanCredit(row pgx.Row, s *CreditMemoSummary) error { + var ( + status, reasonCode string + subtotal, tax int64 + total, open int64 + taxRate *string + voided *time.Time + created, updated time.Time + ) + if err := row.Scan(&s.ID, &s.Number, &s.BranchID, &s.CustomerID, &s.CustomerName, &s.InvoiceID, &s.PosReturnID, &s.JobID, + &s.ShipToID, &status, &s.Revision, &s.Currency, &reasonCode, &s.Reason, + &subtotal, &tax, &taxRate, &total, &open, + &s.GLEntryID, &s.MemoDate, &voided, &s.VoidedBy, &s.VoidReason, &created, &updated); err != nil { + return err + } + s.Status, s.ReasonCode = CreditStatus(status), ReasonCode(reasonCode) + s.SubtotalCents, s.TaxCents, s.TotalCents, s.OpenCents = httpx.Cents(subtotal), httpx.Cents(tax), httpx.Cents(total), httpx.Cents(open) + if taxRate != nil { + if pct, err := salesdoc.RateToPercent(*taxRate); err == nil { + s.TaxRatePercent = &pct + } + } + s.VoidedAt = httpx.PtrTimestamp(voided) + s.CreatedAt, s.UpdatedAt = httpx.TimestampOf(created), httpx.TimestampOf(updated) + return nil +} + +// CreditFilter is the credit memo list's filters and keyset position. +type CreditFilter struct { + Statuses []CreditStatus + CustomerID *uuid.UUID + InvoiceID *uuid.UUID + JobID *uuid.UUID + AfterTime *time.Time + AfterID uuid.UUID + Limit int +} + +var creditWhere = "WHERE " + wall("cm", 1, 2) + ` + AND (cardinality($3::text[]) = 0 OR cm.status = ANY($3)) + AND ($4::uuid IS NULL OR cm.customer_id = $4) + AND ($5::uuid IS NULL OR cm.invoice_id = $5) + AND ($6::uuid IS NULL OR cm.project_id = $6)` + +func (r *PostgresRepository) creditArgs(ctx context.Context, f CreditFilter) []any { + statuses := make([]string, len(f.Statuses)) + for i, s := range f.Statuses { + statuses[i] = string(s) + } + return []any{middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx), statuses, f.CustomerID, f.InvoiceID, f.JobID} +} + +// ListCreditMemos answers one page, newest first by (created_at, id). +func (r *PostgresRepository) ListCreditMemos(ctx context.Context, f CreditFilter) ([]CreditMemoSummary, error) { + args := append(r.creditArgs(ctx, f), f.AfterTime, f.AfterID, f.Limit) + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` + SELECT `+creditColumns+creditFrom+creditWhere+` + AND ($7::timestamptz IS NULL OR (cm.created_at, cm.id) < ($7, $8::uuid)) + ORDER BY cm.created_at DESC, cm.id DESC + LIMIT $9`, args...) + if err != nil { + return nil, fmt.Errorf("failed to list credit memos: %w", err) + } + defer rows.Close() + out := []CreditMemoSummary{} + for rows.Next() { + var s CreditMemoSummary + if err := scanCredit(rows, &s); err != nil { + return nil, fmt.Errorf("failed to scan credit memo: %w", err) + } + out = append(out, s) + } + return out, rows.Err() +} + +// CountCreditMemos is the size of the filtered set, for ?include=total. +func (r *PostgresRepository) CountCreditMemos(ctx context.Context, f CreditFilter) (int64, error) { + var n int64 + if err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM credit_memos cm`+creditWhere, r.creditArgs(ctx, f)...).Scan(&n); err != nil { + return 0, fmt.Errorf("failed to count credit memos: %w", err) + } + return n, nil +} + +// GetCreditMemo reads one credit memo with its lines, held to the branch +// wall. +func (r *PostgresRepository) GetCreditMemo(ctx context.Context, id uuid.UUID) (*CreditMemo, error) { + var cm CreditMemo + row := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT `+creditColumns+creditFrom+` + WHERE cm.id = $1 AND `+wall("cm", 2, 3), + id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + if err := scanCredit(row, &cm.CreditMemoSummary); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errCreditNotFound + } + return nil, fmt.Errorf("failed to get credit memo: %w", err) + } + lines, err := r.creditLines(ctx, id) + if err != nil { + return nil, err + } + cm.Lines = lines + return &cm, nil +} + +// LockCreditMemo takes the credit memo row FOR UPDATE (section 11, step 3), +// held to the branch wall, and reads it. +func (r *PostgresRepository) LockCreditMemo(ctx context.Context, id uuid.UUID) (*CreditMemo, error) { + var locked uuid.UUID + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT cm.id FROM credit_memos cm WHERE cm.id = $1 AND `+wall("cm", 2, 3)+` FOR UPDATE`, + id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)).Scan(&locked) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errCreditNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to lock credit memo: %w", err) + } + return r.GetCreditMemo(ctx, id) +} + +// CreditMemoInvoiceID answers the invoice a credit memo names (read without a +// lock, to learn what to lock), held to the branch wall. +func (r *PostgresRepository) CreditMemoInvoiceID(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) { + var inv *uuid.UUID + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT cm.invoice_id FROM credit_memos cm WHERE cm.id = $1 AND `+wall("cm", 2, 3), + id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)).Scan(&inv) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errCreditNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to read the credit memo's invoice: %w", err) + } + return inv, nil +} + +func (r *PostgresRepository) creditLines(ctx context.Context, id uuid.UUID) ([]CreditLine, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` + SELECT `+salesdoc.LineColumns("false, NULL::uuid, NULL::bigint")+`, + l.invoice_line_id, l.restock, ROUND(l.unit_cost * 10000)::bigint, ROUND(l.cost * 100)::bigint + FROM credit_memo_lines l + LEFT JOIN charge_codes cc ON cc.id = l.charge_code_id + WHERE l.credit_memo_id = $1 + ORDER BY l.position, l.created_at, l.id`, id) + if err != nil { + return nil, fmt.Errorf("failed to get credit memo lines: %w", err) + } + defer rows.Close() + out := []CreditLine{} + for rows.Next() { + var ( + l CreditLine + ls salesdoc.LineScan + unitCost *int64 + cost int64 + ) + if err := rows.Scan(append(ls.Dests(&l.Line), &l.InvoiceLineID, &l.Restock, &unitCost, &cost)...); err != nil { + return nil, fmt.Errorf("failed to scan credit memo line: %w", err) + } + ls.Finish(&l.Line) + l.UnitCost = salesdoc.PtrPrice(unitCost) + l.CostCents = httpx.Cents(cost) + out = append(out, l) + } + return out, rows.Err() +} + +// CreditHeader is the stored header of a credit memo for a write. Totals are +// cents and negative; the rate is the decimal string, nil when the invoice's +// provider priced the tax. +type CreditHeader struct { + ID uuid.UUID + CustomerID uuid.UUID + InvoiceID *uuid.UUID + BranchID uuid.UUID + ProjectID *uuid.UUID + ShipToID *uuid.UUID + Currency string + ReasonCode ReasonCode + Reason string + MemoDate time.Time + + SubtotalCents int64 + TaxCents int64 + TotalCents int64 + TaxRate *string +} + +// InsertCreditMemo writes a new draft: no number (it is minted at post). +func (r *PostgresRepository) InsertCreditMemo(ctx context.Context, h *CreditHeader) error { + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + INSERT INTO credit_memos (id, customer_id, invoice_id, branch_id, project_id, ship_to_id, currency, reason_code, reason, + amount, status, memo_date, subtotal, tax_amount, total_amount, tax_rate, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, -($10::numeric / 100), 'DRAFT', $11::date, + $12::numeric / 100, $13::numeric / 100, $10::numeric / 100, $14::numeric, NOW(), NOW())`, + h.ID, h.CustomerID, h.InvoiceID, h.BranchID, h.ProjectID, h.ShipToID, h.Currency, string(h.ReasonCode), h.Reason, + h.TotalCents, h.MemoDate.Format("2006-01-02"), h.SubtotalCents, h.TaxCents, h.TaxRate) + if err != nil { + return mapWriteError(err, "failed to insert credit memo") + } + return nil +} + +// UpdateCreditDraft replaces a draft's header fields and totals and moves the +// revision. +func (r *PostgresRepository) UpdateCreditDraft(ctx context.Context, h *CreditHeader) error { + ct, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE credit_memos SET customer_id = $2, invoice_id = $3, project_id = $4, ship_to_id = $5, reason_code = $6, + reason = $7, memo_date = $8::date, amount = -($9::numeric / 100), subtotal = $10::numeric / 100, + tax_amount = $11::numeric / 100, total_amount = $9::numeric / 100, tax_rate = $12::numeric, + updated_at = NOW(), revision = revision + 1 + WHERE id = $1`, + h.ID, h.CustomerID, h.InvoiceID, h.ProjectID, h.ShipToID, string(h.ReasonCode), h.Reason, + h.MemoDate.Format("2006-01-02"), h.TotalCents, h.SubtotalCents, h.TaxCents, h.TaxRate) + if err != nil { + return mapWriteError(err, "failed to update credit memo") + } + if ct.RowsAffected() == 0 { + return errCreditNotFound + } + return nil +} + +// ReplaceCreditLines deletes the memo's lines and writes the given ones. +func (r *PostgresRepository) ReplaceCreditLines(ctx context.Context, memoID uuid.UUID, lines []CreditLine) error { + exec := r.db.GetExecutor(ctx) + if _, err := exec.Exec(ctx, `DELETE FROM credit_memo_lines WHERE credit_memo_id = $1`, memoID); err != nil { + return fmt.Errorf("failed to replace the credit memo lines: %w", err) + } + for i := range lines { + l := &lines[i] + var qty, uomQty, priceQty, price, priced, discPct, discAmt, total *int64 + if l.Quantity != nil { + qty = i64p(int64(*l.Quantity)) + } + if l.UOMQty != nil { + uomQty = i64p(int64(*l.UOMQty)) + } + if l.PriceUOMQty != nil { + priceQty = i64p(int64(*l.PriceUOMQty)) + } + if l.UnitPrice != nil { + price = i64p(int64(*l.UnitPrice)) + } + if l.PricedUnitPrice != nil { + priced = i64p(int64(*l.PricedUnitPrice)) + } + if l.DiscountPercent != nil { + discPct = i64p(int64(*l.DiscountPercent)) + } + if l.DiscountAmount != nil { + discAmt = i64p(int64(*l.DiscountAmount)) + } + if l.LineTotal != nil { + total = i64p(int64(*l.LineTotal)) + } + var unitCost *int64 + if l.UnitCost != nil { + unitCost = i64p(int64(*l.UnitCost)) + } + _, err := exec.Exec(ctx, ` + INSERT INTO credit_memo_lines (id, credit_memo_id, position, line_type, parent_line_id, product_id, charge_code_id, + invoice_line_id, sku, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, + priced_unit_price, price_source, override_reason, discount_percent, discount_amount, discount_reason, + price_adjusted_by, line_total, taxable, revenue_account_code, restock, unit_cost, cost, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::numeric / 10000, $12, $13, $14::numeric / 10000, + $15::numeric / 10000, $16::numeric / 10000, $17::numeric / 10000, $18, $19, $20::numeric / 10000, + $21::numeric / 100, $22, $23, $24::numeric / 100, $25, $26, $27, $28::numeric / 10000, $29::numeric / 100, NOW())`, + l.ID, memoID, l.Position, string(l.LineType), l.ParentLineID, l.ProductID, l.ChargeCodeID, + l.InvoiceLineID, l.SKU, l.Description, qty, l.UOM, l.PriceUOM, uomQty, priceQty, price, + priced, string(l.PriceSource), l.OverrideReason, discPct, + discAmt, l.DiscountReason, l.PriceAdjustedBy, total, l.Taxable, l.RevenueAccountCode, l.Restock, unitCost, int64(l.CostCents)) + if err != nil { + return mapWriteError(err, "failed to insert credit memo line") + } + } + return nil +} + +func i64p(v int64) *int64 { return &v } + +// PostCredit records the post: the number, OPEN, the entry, the date and the +// totals as recomputed at post, with the revision moved. +func (r *PostgresRepository) PostCredit(ctx context.Context, h *CreditHeader, number string, glEntryID *uuid.UUID) error { + ct, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE credit_memos SET number = $2, status = 'OPEN', gl_entry_id = $3, memo_date = $4::date, + amount = -($5::numeric / 100), subtotal = $6::numeric / 100, tax_amount = $7::numeric / 100, + total_amount = $5::numeric / 100, tax_rate = $8::numeric, updated_at = NOW(), revision = revision + 1 + WHERE id = $1`, + h.ID, number, glEntryID, h.MemoDate.Format("2006-01-02"), h.TotalCents, h.SubtotalCents, h.TaxCents, h.TaxRate) + if err != nil { + return fmt.Errorf("failed to post the credit memo: %w", err) + } + if ct.RowsAffected() == 0 { + return errCreditNotFound + } + return nil +} + +// UpdateCreditLines rewrites the cost and extension the post recomputed. +func (r *PostgresRepository) UpdateCreditLines(ctx context.Context, memoID uuid.UUID, lines []CreditLine) error { + return r.ReplaceCreditLines(ctx, memoID, lines) +} + +// MarkCreditVoid ends the credit memo: status VOID, the void columns, the revision. +func (r *PostgresRepository) MarkCreditVoid(ctx context.Context, id uuid.UUID, actor, reason string, voidedOn time.Time) error { + ct, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE credit_memos SET status = 'VOID', voided_at = NOW(), voided_by = NULLIF($2, ''), void_reason = $3, + voided_on = $4::date, updated_at = NOW(), revision = revision + 1 + WHERE id = $1`, id, actor, reason, voidedOn.Format("2006-01-02")) + if err != nil { + return fmt.Errorf("failed to void the credit memo: %w", err) + } + if ct.RowsAffected() == 0 { + return errCreditNotFound + } + return nil +} + +// Credited is what earlier credit memos returned of one invoice line, as +// positive magnitudes: the quantity (scale 4), the extension and the +// discount share (cents). +type Credited struct { + Quantity int64 + TotalCents int64 + DiscountCents int64 + CostCents int64 +} + +// CreditedAgainst is what the credit memos naming an invoice have already +// credited, per invoice line, with the tax they credited and the taxable base +// it covered (cents, positive). exclude is the memo being written; drafts +// count only when includeDrafts is set (the draft-time guard counts them; +// the post recomputes against what was posted). +type CreditedAgainst struct { + ByLine map[uuid.UUID]Credited + TaxCents int64 + TaxableCents int64 +} + +func (r *PostgresRepository) CreditedAgainstInvoice(ctx context.Context, invoiceID, exclude uuid.UUID, includeDrafts bool) (CreditedAgainst, error) { + out := CreditedAgainst{ByLine: map[uuid.UUID]Credited{}} + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` + SELECT l.invoice_line_id, -ROUND(SUM(l.quantity) * 10000)::bigint, -ROUND(SUM(l.line_total) * 100)::bigint, + ROUND(SUM(COALESCE(l.discount_amount, 0)) * 100)::bigint, -ROUND(SUM(l.cost) * 100)::bigint + FROM credit_memo_lines l JOIN credit_memos cm ON cm.id = l.credit_memo_id + WHERE cm.invoice_id = $1 AND cm.id <> $2 AND cm.status <> 'VOID' AND ($3::boolean OR cm.status <> 'DRAFT') + AND l.invoice_line_id IS NOT NULL AND l.quantity IS NOT NULL + GROUP BY l.invoice_line_id`, invoiceID, exclude, includeDrafts) + if err != nil { + return out, fmt.Errorf("failed to read what the invoice has had credited: %w", err) + } + defer rows.Close() + for rows.Next() { + var id uuid.UUID + var c Credited + if err := rows.Scan(&id, &c.Quantity, &c.TotalCents, &c.DiscountCents, &c.CostCents); err != nil { + return out, fmt.Errorf("failed to scan a credited line: %w", err) + } + out.ByLine[id] = c + } + if err := rows.Err(); err != nil { + return out, err + } + rows.Close() + // The tax credited and the taxable base it covered come from the posted + // memos only (the tax cap of section 3 counts credit memos that are not + // void; a draft's tax is provisional until its post recomputes it). + err = r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT COALESCE(-ROUND(SUM(cm.tax_amount) * 100), 0)::bigint, + COALESCE((SELECT -ROUND(SUM(l.line_total) * 100) FROM credit_memo_lines l JOIN credit_memos c2 ON c2.id = l.credit_memo_id + WHERE c2.invoice_id = $1 AND c2.id <> $2 AND c2.status NOT IN ('DRAFT', 'VOID') AND l.taxable AND l.line_total IS NOT NULL), 0)::bigint + FROM credit_memos cm WHERE cm.invoice_id = $1 AND cm.id <> $2 AND cm.status NOT IN ('DRAFT', 'VOID')`, + invoiceID, exclude).Scan(&out.TaxCents, &out.TaxableCents) + if err != nil { + return out, fmt.Errorf("failed to read the tax already credited: %w", err) + } + return out, nil +} + +// CreditFacts are the facts a credit memo with no invoice resolves its +// currency, branch and tax from. +type CreditFacts struct { + Currency string + PrimaryBranchID uuid.UUID + Exempt bool +} + +// CustomerCreditFacts reads the customer's effective currency (the override, +// else the dealer default), primary branch and tax exemption. +func (r *PostgresRepository) CustomerCreditFacts(ctx context.Context, customerID uuid.UUID) (CreditFacts, error) { + var f CreditFacts + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT COALESCE(c.currency, (SELECT value FROM system_settings WHERE key = 'currency.default'), 'USD'), + c.primary_branch_id, + EXISTS(SELECT 1 FROM tax_exemptions t WHERE t.customer_id = c.id AND t.is_active) + FROM customers c WHERE c.id = $1`, customerID).Scan(&f.Currency, &f.PrimaryBranchID, &f.Exempt) + if errors.Is(err, pgx.ErrNoRows) { + return f, validationFailed("a referenced record does not exist", httpx.FieldError{Field: "customer_id", Message: "no such customer"}) + } + if err != nil { + return f, fmt.Errorf("failed to read the customer: %w", err) + } + return f, nil +} + +// OwnedBy checks that the ship-to and the job named belong to the customer: +// the foreign keys alone would accept another customer's. +func (r *PostgresRepository) OwnedBy(ctx context.Context, customerID uuid.UUID, shipToID, jobID *uuid.UUID) (shipToOK, jobOK bool, err error) { + shipToOK, jobOK = true, true + if shipToID != nil { + if err = r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM customer_ship_tos WHERE id = $1 AND customer_id = $2)`, *shipToID, customerID).Scan(&shipToOK); err != nil { + return false, false, fmt.Errorf("failed to check the ship-to: %w", err) + } + } + if jobID != nil { + if err = r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1 AND customer_id = $2)`, *jobID, customerID).Scan(&jobOK); err != nil { + return false, false, fmt.Errorf("failed to check the job: %w", err) + } + } + return shipToOK, jobOK, nil +} + +// TaxInputs gathers a credit memo with no invoice's rate ingredients +// (section 3): a ship-to's rate, else the branch's. +func (r *PostgresRepository) TaxInputs(ctx context.Context, branchID uuid.UUID, shipToID *uuid.UUID) (shipRate, branchRate *string, err error) { + if shipToID != nil { + if err = r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT tax_rate::text FROM customer_ship_tos WHERE id = $1`, *shipToID).Scan(&shipRate); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, nil, fmt.Errorf("failed to read the ship-to rate: %w", err) + } + } + if err = r.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT default_tax_rate::text FROM locations WHERE id = $1`, branchID).Scan(&branchRate); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, nil, fmt.Errorf("failed to read the branch rate: %w", err) + } + return shipRate, branchRate, nil +} + +// LookupProducts reads the slice of each product a credit line defaults and +// costs from. +func (r *PostgresRepository) LookupProducts(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]salesdoc.ProductRef, error) { + out := map[uuid.UUID]salesdoc.ProductRef{} + if len(ids) == 0 { + return out, nil + } + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` + SELECT id, COALESCE(sku, ''), COALESCE(description, ''), uom_primary::text, + ROUND(base_price * 10000)::bigint, ROUND(COALESCE(average_unit_cost, 0) * 10000)::bigint, + COALESCE(commission_rate, 0), is_kit, taxable + FROM products WHERE id = ANY($1)`, ids) + if err != nil { + return nil, fmt.Errorf("failed to look up products: %w", err) + } + defer rows.Close() + for rows.Next() { + var ( + p salesdoc.ProductRef + base, cost int64 + ) + if err := rows.Scan(&p.ID, &p.SKU, &p.Description, &p.UOMPrimary, &base, &cost, &p.CommissionRate, &p.IsKit, &p.Taxable); err != nil { + return nil, fmt.Errorf("failed to scan product: %w", err) + } + p.BasePrice, p.AverageCost = httpx.Price(base), httpx.Price(cost) + out[p.ID] = p + } + return out, rows.Err() +} + +// ChargeCodeByCode reads an active charge code; false when there is none. +func (r *PostgresRepository) ChargeCodeByCode(ctx context.Context, code string) (salesdoc.ChargeCode, bool, error) { + var c salesdoc.ChargeCode + var def *int64 + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT id, code, name, revenue_account_code, taxable, ROUND(default_unit_price * 10000)::bigint, is_active, revision + FROM charge_codes WHERE code = $1`, code).Scan(&c.ID, &c.Code, &c.Name, &c.RevenueAccountCode, &c.Taxable, &def, &c.IsActive, &c.Revision) + if errors.Is(err, pgx.ErrNoRows) { + return c, false, nil + } + if err != nil { + return c, false, fmt.Errorf("failed to read the charge code: %w", err) + } + c.DefaultUnitPrice = salesdoc.PtrPrice(def) + return c, true, nil +} diff --git a/core/internal/invoice/service.go b/core/internal/invoice/service.go index f922a206..1ccd4267 100644 --- a/core/internal/invoice/service.go +++ b/core/internal/invoice/service.go @@ -5,39 +5,438 @@ package invoice import ( "context" + "encoding/json" + "errors" "fmt" + "net/http" + "sort" "time" "github.com/gablelbm/gable/internal/account" "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/middleware" "github.com/gablelbm/gable/pkg/money" + "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" ) +// EventRecorder writes a domain event into the transactional outbox, as the +// LAST statement of the mutation's transaction (ADR 0003 section 2). +type EventRecorder interface { + Write(ctx context.Context, ev outbox.Event) error +} + +// BranchGuard applies the payload branch rule (ADR 0007 section 2.3). +type BranchGuard interface { + CheckPayloadBranch(ctx context.Context, branch uuid.UUID) error +} + +// Stock is the inventory seam an invoice void and a restocking credit memo +// use: the scale 4 functions of the inventory service, each taking an explicit +// branch and running inside the caller's transaction. +type Stock interface { + RestockQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error + UnrestockQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error +} + +// OrderReopener is the order module's half of an invoice void (ADR 0005 +// 6.2), implemented by order.Service: the invoice module cannot import the +// order module, which imports it. +type OrderReopener interface { + // LockForInvoiceVoid takes the invoice's order row and the customer's + // credit serialization (section 11, steps 1 and 1a), in that order. + LockForInvoiceVoid(ctx context.Context, orderID uuid.UUID) error + // ReturnBilled returns the voided invoice's billed quantities to its + // order: the stock back on hand, quantity_fulfilled reduced, allocation + // re-run for those quantities, the status re-derived. It answers the + // order's own events for the caller to write last, and the order's new + // status for the invoice's event. + ReturnBilled(ctx context.Context, orderID uuid.UUID, billed []BilledLine, actor string) (ReopenResult, error) +} + +// ReopenResult is what ReturnBilled did to the order. +type ReopenResult struct { + Status string // the order's status after the void, lowercase + Events []outbox.Event +} + +// TxRunner runs fn inside one transaction, joining the caller's when ctx +// already carries one. *database.DB satisfies it. +type TxRunner interface { + RunInTx(ctx context.Context, fn func(ctx context.Context) error) error +} + type Service struct { - repo Repository - gl *gl.Service - account account.Service - auditLog *audit.Logger - db *database.DB + repo Repository + gl *gl.Service + account account.Service + auditLog *audit.Logger + db *database.DB + events EventRecorder + branches BranchGuard + inventory Stock + orders OrderReopener + now func() time.Time } func NewService(repo Repository, glService *gl.Service, accountService account.Service, db *database.DB) *Service { - return &Service{repo: repo, gl: glService, account: accountService, db: db} + return &Service{repo: repo, gl: glService, account: accountService, db: db, now: time.Now} } // WithAuditLog sets the audit logger for financial operation tracking. -func (s *Service) WithAuditLog(l *audit.Logger) *Service { - s.auditLog = l - return s +func (s *Service) WithAuditLog(l *audit.Logger) *Service { s.auditLog = l; return s } + +// WithOutbox sets the event writer. +func (s *Service) WithOutbox(e EventRecorder) *Service { s.events = e; return s } + +// WithBranchGuard sets the payload branch rule for the write routes. +func (s *Service) WithBranchGuard(g BranchGuard) *Service { s.branches = g; return s } + +// WithStock sets the inventory seam a void and a restock use. +func (s *Service) WithStock(st Stock) *Service { s.inventory = st; return s } + +// WithOrders sets the order module's half of an invoice void. +func (s *Service) WithOrders(o OrderReopener) *Service { s.orders = o; return s } + +// Store is the repository half the C2-3 acts use; the Postgres repository +// implements it. Unit tests that fake only Repository never reach them. +type Store interface { + Repository + FulfilmentStore + + ListInvoices(ctx context.Context, f ListFilter) ([]InvoiceSummary, error) + CountInvoices(ctx context.Context, f ListFilter) (int64, error) + LockInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) + LockCustomerCredit(ctx context.Context, customerID uuid.UUID) error + BranchLocalDate(ctx context.Context, branchID uuid.UUID, at time.Time) (time.Time, error) + VoidFactsFor(ctx context.Context, invoiceID uuid.UUID) (VoidFacts, error) + MarkVoid(ctx context.Context, id uuid.UUID, actor, reason string, voidedOn time.Time) error + BilledLines(ctx context.Context, invoiceID uuid.UUID) ([]BilledLine, error) + + ListCreditMemos(ctx context.Context, f CreditFilter) ([]CreditMemoSummary, error) + CountCreditMemos(ctx context.Context, f CreditFilter) (int64, error) + GetCreditMemo(ctx context.Context, id uuid.UUID) (*CreditMemo, error) + LockCreditMemo(ctx context.Context, id uuid.UUID) (*CreditMemo, error) + CreditMemoInvoiceID(ctx context.Context, id uuid.UUID) (*uuid.UUID, error) + InsertCreditMemo(ctx context.Context, h *CreditHeader) error + UpdateCreditDraft(ctx context.Context, h *CreditHeader) error + ReplaceCreditLines(ctx context.Context, memoID uuid.UUID, lines []CreditLine) error + PostCredit(ctx context.Context, h *CreditHeader, number string, glEntryID *uuid.UUID) error + MarkCreditVoid(ctx context.Context, id uuid.UUID, actor, reason string, voidedOn time.Time) error + NextCreditMemoNumber(ctx context.Context) (string, error) + CreditedAgainstInvoice(ctx context.Context, invoiceID, exclude uuid.UUID, includeDrafts bool) (CreditedAgainst, error) + CustomerCreditFacts(ctx context.Context, customerID uuid.UUID) (CreditFacts, error) + OwnedBy(ctx context.Context, customerID uuid.UUID, shipToID, jobID *uuid.UUID) (bool, bool, error) + TaxInputs(ctx context.Context, branchID uuid.UUID, shipToID *uuid.UUID) (shipRate, branchRate *string, err error) + LookupProducts(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]salesdoc.ProductRef, error) + ChargeCodeByCode(ctx context.Context, code string) (salesdoc.ChargeCode, bool, error) +} + +func (s *Service) store() (Store, error) { + st, ok := s.repo.(Store) + if !ok { + return nil, errors.New("invoice: the repository cannot run the invoice acts") + } + return st, nil +} + +func (s *Service) inTx(ctx context.Context, fn func(ctx context.Context) error) error { + if s.db == nil { + return fn(ctx) + } + return s.db.RunInTx(ctx, fn) +} + +// Precondition is the client's revision for a transition or an edit (ADR 0001 +// section 11). +type Precondition struct { + IfMatch string + Revision *int64 +} + +func (p Precondition) missing() bool { return p.IfMatch == "" && p.Revision == nil } + +func (p Precondition) check(current int64) error { + return httpx.CheckRevision(current, p.IfMatch, p.Revision) +} + +func conflictBlocker(code, message string) *httpx.Error { + return &httpx.Error{Status: http.StatusConflict, Code: httpx.CodeConflict, Message: message, + Details: []httpx.FieldError{httpx.Blocker(code, message)}} } -// DefaultTaxRate is the default sales tax rate (configurable per jurisdiction) +func notFound(err error) error { + var he *httpx.Error + if errors.As(err, &he) { + return err + } + return httpx.NotFound("not found") +} + +// checkBranch is the record branch rule (ADR 0007 section 2.3) for a document +// a path id addresses on the write routes: the document's branch must be one +// the caller may target, else 403 forbidden naming id. It fails closed. +func (s *Service) checkBranch(ctx context.Context, branch uuid.UUID, what string) error { + if s.branches == nil { + return nil + } + err := s.branches.CheckPayloadBranch(ctx, branch) + if errors.Is(err, middleware.ErrPayloadBranchRefused) { + return &httpx.Error{Status: http.StatusForbidden, Code: httpx.CodeForbidden, + Message: what + " is outside the branches this caller may target", + Details: []httpx.FieldError{{Field: "id", Code: httpx.CodeForbidden, Message: "not a branch this caller may target"}}} + } + return err +} + +// financeRoles may void and post (ADR 0005 6.2 and 6.3). +var financeRoles = map[string]bool{"admin": true, "owner": true, "finance": true} + +// requireFinance holds an act to the finance roles; an empty role (an in +// process caller, a machine key) passes, as the order's release does. +func requireFinance(role, what string) error { + if role != "" && !financeRoles[role] { + return &httpx.Error{Status: http.StatusForbidden, Code: httpx.CodeForbidden, + Message: what + " needs the admin, owner or finance role"} + } + return nil +} + +// record writes one event through the transaction's executor, last. +func (s *Service) record(ctx context.Context, ev outbox.Event) error { + if s.events == nil { + return nil + } + return s.events.Write(ctx, ev) +} + +func marshal(v any) json.RawMessage { + raw, err := json.Marshal(v) + if err != nil { + return json.RawMessage(`{}`) + } + return raw +} + +// --------------------------------------------------------------------------- +// Reads. +// --------------------------------------------------------------------------- + +func (s *Service) GetInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) { + return s.repo.GetInvoice(ctx, id) +} + +// ListInvoices answers one page and whether another follows (the repository +// reads limit+1), with the filtered total on request. +func (s *Service) ListInvoices(ctx context.Context, f ListFilter, wantTotal bool) (items []InvoiceSummary, hasMore bool, total *int64, err error) { + st, err := s.store() + if err != nil { + return nil, false, nil, err + } + probe := f + probe.Limit = f.Limit + 1 + items, err = st.ListInvoices(ctx, probe) + if err != nil { + return nil, false, nil, err + } + if len(items) > f.Limit { + items, hasMore = items[:f.Limit], true + } + if wantTotal { + n, err := st.CountInvoices(ctx, f) + if err != nil { + return nil, false, nil, err + } + total = &n + } + return items, hasMore, total, nil +} + +// GetCustomerOpenBalanceCents returns the customer's live outstanding AR balance +// (sum of open invoices), in cents. +func (s *Service) GetCustomerOpenBalanceCents(ctx context.Context, customerID uuid.UUID) (int64, error) { + return s.repo.SumOpenBalanceCents(ctx, customerID) +} + +// ExistsInvoiceForOrder reports whether the order has an invoice not void. +func (s *Service) ExistsInvoiceForOrder(ctx context.Context, orderID uuid.UUID) (bool, error) { + return s.repo.ExistsInvoiceForOrder(ctx, orderID) +} + +// --------------------------------------------------------------------------- +// The void (ADR 0005 6.2). +// --------------------------------------------------------------------------- + +// Transition is the body of an invoice transition. +type Transition struct { + Reason string + Actor string + Role string +} + +// VoidInvoice voids an unpaid invoice in one transaction, in section 11's +// order: the invoice's order row and the customer's credit serialization, the +// invoice, the stock back on hand and the order's lines and status, the +// customer row for the subledger, the reversal entry, then the events last. +// It reverses the invoice's whole entry (dated the void date), returns its +// billed stock to on hand, reduces the order lines' fulfilled quantities, +// re-runs allocation for those quantities and derives the order status. +func (s *Service) VoidInvoice(ctx context.Context, id uuid.UUID, pre Precondition, body Transition) (*Invoice, error) { + if pre.missing() { + return nil, httpx.PreconditionRequired("this write needs If-Match or a body revision") + } + if err := requireFinance(body.Role, "voiding an invoice"); err != nil { + return nil, err + } + st, err := s.store() + if err != nil { + return nil, err + } + var out *Invoice + err = s.inTx(ctx, func(ctx context.Context) error { + // Read the invoice unlocked to learn its order, the first lock. + head, err := st.GetInvoice(ctx, id) + if err != nil { + return notFound(err) + } + if err := s.checkBranch(ctx, head.BranchID, "invoice"); err != nil { + return err + } + if head.OrderID != nil && s.orders != nil { + if err := s.orders.LockForInvoiceVoid(ctx, *head.OrderID); err != nil { + return err + } + } else if err := st.LockCustomerCredit(ctx, head.CustomerID); err != nil { + return err + } + inv, err := st.LockInvoice(ctx, id) + if err != nil { + return notFound(err) + } + if err := pre.check(inv.Revision); err != nil { + return err + } + if inv.Status == InvoiceStatusVoid { + return httpx.InvalidStateTransition("the invoice is already void") + } + facts, err := st.VoidFactsFor(ctx, id) + if err != nil { + return err + } + switch { + case inv.Status != InvoiceStatusUnpaid || facts.Payments > 0 || facts.AppliedMemos > 0: + // paid, partial, written off, a payment recorded or an applied + // credit memo naming it: reverse them first (C2-4's live + // applications take over this check) + return conflictBlocker("has_applications", "the invoice has payments or applied credit memos: reverse them before voiding it") + case facts.LiveMemos > 0: + return conflictBlocker("has_credit_memos", "a credit memo names the invoice: void the credit memos first") + } + + // Section 11 step 6: the order's stock and lines. + var reopen ReopenResult + if inv.OrderID != nil && s.orders != nil { + billed, err := st.BilledLines(ctx, id) + if err != nil { + return err + } + if reopen, err = s.orders.ReturnBilled(ctx, *inv.OrderID, billed, body.Actor); err != nil { + return err + } + } + + // Step 7 and 9: the customer row (the subledger's lock), then the + // reversal of the invoice's whole entry, dated the void date. + date, err := st.BranchLocalDate(ctx, inv.BranchID, s.now()) + if err != nil { + return err + } + if err := st.LockCustomer(ctx, inv.CustomerID); err != nil { + return err + } + if inv.GLEntryID != nil && s.gl != nil { + if _, err := s.gl.PostReversal(ctx, gl.ReversalInput{EntryID: *inv.GLEntryID, EntryDate: date, + Currency: inv.Currency, Reason: "invoice " + inv.Number + " voided", PostedBy: body.Actor}); err != nil { + return mapPostingError(err) + } + } + if s.account != nil { + invID := inv.ID + if _, err := s.account.PostTransaction(ctx, inv.CustomerID, account.TransactionTypeReversal, -int64(inv.TotalCents), &invID, "Void of invoice "+inv.Number); err != nil { + return fmt.Errorf("failed to reverse the invoice in the account ledger: %w", err) + } + } + if err := st.MarkVoid(ctx, id, body.Actor, body.Reason, date); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "invoice.voided", EntityType: "invoice", EntityID: id, UserID: body.Actor, + Changes: map[string]interface{}{"number": inv.Number, "reason": body.Reason, "total_cents": int64(inv.TotalCents)}, + }); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + if out, err = st.GetInvoice(ctx, id); err != nil { + return err + } + + // Step 10: the events, last. + data := map[string]any{ + "number": out.Number, "customer_id": out.CustomerID, "status": out.Status.Status(), + "from_status": inv.Status.Status(), "revision": out.Revision, "currency": out.Currency, + "total_cents": int64(out.TotalCents), + } + if out.OrderID != nil { + data["order_id"] = out.OrderID + if reopen.Status != "" { + data["order_status"] = reopen.Status + } + } + branch := out.BranchID + if err := s.record(ctx, outbox.Event{Type: EventInvoiceVoided, EntityType: "invoice", EntityID: out.ID, + BranchID: &branch, Data: marshal(data)}); err != nil { + return err + } + for _, ev := range reopen.Events { + if err := s.record(ctx, ev); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// mapPostingError maps the ledger's refusals to the wire: a closed period is +// a 409 with the blocker period_closed (ADR 0005 8.1). +func mapPostingError(err error) error { + if errors.Is(err, gl.ErrPeriodClosed) { + return conflictBlocker("period_closed", "the entry would be dated into a closed fiscal period") + } + return err +} + +// --------------------------------------------------------------------------- +// The legacy writers (the counter's account charge; C2-5 retires them). +// --------------------------------------------------------------------------- + +// DefaultTaxRate is the fallback sales tax rate of the counter's legacy account +// charge when its branch has none configured (C2-5 retires it with the path; +// the orders and the credit memos refuse instead, ADR 0005 section 3). const DefaultTaxRate = 0.0825 // 8.25% -func (s *Service) CreateInvoice(ctx context.Context, inv *Invoice) error { +// CreateInvoice is the counter's account charge (the legacy path): the +// repository locks the customer, numbers the invoice through the counter and +// derives the due date from the customer's terms. +func (s *Service) CreateInvoice(ctx context.Context, inv *LegacyInvoice) error { if len(inv.Lines) == 0 { return fmt.Errorf("invoice must have lines") } @@ -45,35 +444,23 @@ func (s *Service) CreateInvoice(ctx context.Context, inv *Invoice) error { inv.Status = InvoiceStatusUnpaid } - // C3: Calculate tax if not already set callerSuppliedSubtotal := inv.Subtotal != 0 if inv.Subtotal == 0 { var subtotal int64 for _, line := range inv.Lines { // Round, never truncate: 100 cents x 8.29 is exactly 829 in - // decimal but 828.99999999999989 in float64, and a truncating - // cast would drop a whole cent. order/service.go rounds the same - // extension, so truncating here made an order and the invoice - // generated from it disagree on the same line. + // decimal but 828.99999999999989 in float64. subtotal += money.RoundToCents(float64(line.PriceEach) * line.Quantity) } inv.Subtotal = subtotal } - // Callers that pre-computed the invoice (e.g. POS account-charge sales, - // where the register already ran the exemption-aware calculation) supply - // BOTH the subtotal and the tax-inclusive total, and are honored as-is. - // - // A total on its own is not evidence that anyone calculated tax: order - // fulfilment passed the order's PRE-TAX total that way, which silently - // produced an invoice with TaxRate 0, TaxAmount 0 and a pre-tax total — - // and that untaxed figure was what PostInvoiceToLedger booked to the GL - // and the AR subledger. So a total without a subtotal is recomputed. + // Callers that pre-computed the invoice (POS account-charge sales, where + // the register already ran the exemption-aware calculation) supply BOTH + // the subtotal and the tax-inclusive total, and are honored as-is. A total + // on its own is not evidence that anyone calculated tax, so a total without + // a subtotal is recomputed. if inv.TotalAmount == 0 || !callerSuppliedSubtotal { if inv.TaxRate == 0 { - // Source the rate from the invoice's branch (locations.default_tax_rate), - // so app-created invoices match the jurisdiction (e.g. 0.12 in BC) instead - // of the hardcoded DefaultTaxRate fallback. branchID may be nil → the repo - // falls back to the active/default branch. var branchID *uuid.UUID if inv.BranchID != uuid.Nil { branchID = &inv.BranchID @@ -84,30 +471,16 @@ func (s *Service) CreateInvoice(ctx context.Context, inv *Invoice) error { inv.TaxRate = DefaultTaxRate } } - // Round half up: $10.00 at 8.25% is exactly 82.5 cents, and truncating - // it to 82 under-collects on every invoice whose tax lands on a - // fractional cent — a systematic downward bias, not a wash. + // Round half up: $10.00 at 8.25% is exactly 82.5 cents. inv.TaxAmount = money.RoundToCents(float64(inv.Subtotal) * inv.TaxRate) inv.TotalAmount = inv.Subtotal + inv.TaxAmount } - // C5: Calculate due date from payment terms - if inv.PaymentTerms == "" { - inv.PaymentTerms = TermsNet30 - } - if inv.DueDate == nil { - dueDate := calcDueDate(time.Now(), inv.PaymentTerms) - inv.DueDate = &dueDate - } - - if err := s.db.RunInTx(ctx, func(txCtx context.Context) error { + return s.inTx(ctx, func(txCtx context.Context) error { if err := s.repo.CreateInvoice(txCtx, inv); err != nil { return err } - // Audit log: inside the transaction, so it shares the invoice's fate - // — a rolled back invoice leaves no audit row. When CreateInvoice is - // itself called inside a caller's transaction (order fulfilment, POS - // account charges), RunInTx joins it and this row rides along. + // Audit log: inside the transaction, so it shares the invoice's fate. if s.auditLog != nil { if err := s.auditLog.Log(txCtx, audit.Entry{ Action: "invoice.created", @@ -123,84 +496,15 @@ func (s *Service) CreateInvoice(ctx context.Context, inv *Invoice) error { return fmt.Errorf("failed to write audit log: %w", err) } } - return nil - }); err != nil { - return err - } - - return nil -} - -func calcDueDate(from time.Time, terms string) time.Time { - switch terms { - case TermsCOD, TermsDueOnReceipt: - return from - case TermsNet30: - return from.AddDate(0, 0, 30) - case TermsNet60: - return from.AddDate(0, 0, 60) - case TermsNet90: - return from.AddDate(0, 0, 90) - default: - return from.AddDate(0, 0, 30) - } -} - -func (s *Service) GetInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) { - return s.repo.GetInvoice(ctx, id) -} - -func (s *Service) ListInvoices(ctx context.Context) ([]Invoice, error) { - return s.repo.ListInvoices(ctx) -} - -func (s *Service) ListInvoicesPaginated(ctx context.Context, limit, offset int) ([]Invoice, int, error) { - return s.repo.ListInvoicesPaginated(ctx, limit, offset) -} - -func (s *Service) FinalizeInvoice(ctx context.Context, id uuid.UUID) error { - inv, err := s.repo.GetInvoice(ctx, id) - if err != nil { - return err - } - - return s.db.RunInTx(ctx, func(txCtx context.Context) error { - // Post to GL - if err := s.gl.SyncInvoice(txCtx, inv.ID.String(), inv.TotalAmount); err != nil { - return fmt.Errorf("failed to sync to GL: %w", err) - } - - // Post to Account Ledger (Debit) - _, err := s.account.PostTransaction(txCtx, inv.CustomerID, account.TransactionTypeInvoice, inv.TotalAmount, &inv.ID, "Invoice #"+inv.ID.String()) - if err != nil { - return fmt.Errorf("failed to post to account ledger: %w", err) - } - return nil }) } -// ExistsInvoiceForOrder reports whether the order already has an invoice. -func (s *Service) ExistsInvoiceForOrder(ctx context.Context, orderID uuid.UUID) (bool, error) { - return s.repo.ExistsInvoiceForOrder(ctx, orderID) -} - -// GetCustomerOpenBalanceCents returns the customer's live outstanding AR balance -// (sum of open invoices), in cents. Use this for credit-limit checks instead of -// the unmaintained customers.balance_due column. -func (s *Service) GetCustomerOpenBalanceCents(ctx context.Context, customerID uuid.UUID) (int64, error) { - return s.repo.SumOpenBalanceCents(ctx, customerID) -} - -// PostInvoiceToLedger posts an already-created invoice to the GL (DR Accounts -// Receivable / CR Sales Revenue) and the customer AR subledger (a debit + a -// customer_transactions row). It is intended to run inside the caller's -// transaction so the invoice, GL entry, and subledger commit atomically — both -// postings are required (the chart of accounts is seeded by migration 025). -// This is the single AR writer for the order-fulfilment path, replacing the old -// raw, pre-tax customers.balance_due bump that left the recorded balance -// disagreeing with the tax-inclusive invoice total. -func (s *Service) PostInvoiceToLedger(ctx context.Context, inv *Invoice) error { +// PostInvoiceToLedger posts an already-created legacy invoice to the GL (DR +// Accounts Receivable / CR Sales Revenue) and the customer AR subledger. It +// runs inside the caller's transaction so the invoice, entry and subledger +// commit atomically. +func (s *Service) PostInvoiceToLedger(ctx context.Context, inv *LegacyInvoice) error { if s.gl != nil { if err := s.gl.SyncInvoice(ctx, inv.ID.String(), inv.TotalAmount); err != nil { return fmt.Errorf("failed to post invoice to GL: %w", err) @@ -214,10 +518,8 @@ func (s *Service) PostInvoiceToLedger(ctx context.Context, inv *Invoice) error { return nil } -// PostCashSaleToGL posts a POS cash sale to the GL (DR Cash / CR Sales Revenue). -// POS already depends on invoice.Service, so this lets the till book revenue -// without taking a direct GL dependency. Intended to be called best-effort -// AFTER the sale commits (a GL failure must never block a till transaction). +// PostCashSaleToGL posts a POS cash sale to the GL (DR Cash / CR Sales +// Revenue), best effort AFTER the sale commits (C2-5 moves it inside). func (s *Service) PostCashSaleToGL(ctx context.Context, posTxID string, amountCents int64) error { if s.gl == nil { return nil @@ -225,12 +527,9 @@ func (s *Service) PostCashSaleToGL(ctx context.Context, posTxID string, amountCe return s.gl.SyncCashSale(ctx, posTxID, amountCents) } -// PostCashReturnToGL posts a POS cash refund to the GL (DR Sales Revenue / -// CR Cash), the exact mirror of PostCashSaleToGL. Lets the till book a refund -// without taking a direct GL dependency; best-effort, called AFTER the return -// commits (a GL hiccup must never block a completed refund). Returns the GL -// entry ID so the return row can link back to its ledger entry (uuid.Nil when -// no GL is wired). +// PostCashReturnToGL posts a POS cash refund to the GL (DR Sales Revenue / CR +// Cash), the mirror of PostCashSaleToGL; best effort, after the return +// commits. It returns the entry ID (uuid.Nil when no GL is wired). func (s *Service) PostCashReturnToGL(ctx context.Context, returnID string, amountCents int64) (uuid.UUID, error) { if s.gl == nil { return uuid.Nil, nil @@ -240,10 +539,8 @@ func (s *Service) PostCashReturnToGL(ctx context.Context, returnID string, amoun // PostAccountReturnToLedger books a POS return refunded as store credit: the // GL leg (DR Sales Revenue / CR Accounts Receivable) plus a balance-reducing -// subledger entry, the mirror of PostInvoiceToLedger. Store credit lowers what -// the customer owes, so the subledger amount is negative (payment-shaped). -// Best-effort at the POS layer, same policy as PostCashReturnToGL. Returns the -// GL entry ID (uuid.Nil when no GL is wired). +// subledger entry. Best-effort at the POS layer (C2-5 turns it into a credit +// memo). Returns the GL entry ID (uuid.Nil when no GL is wired). func (s *Service) PostAccountReturnToLedger(ctx context.Context, customerID, returnID uuid.UUID, amountCents int64) (uuid.UUID, error) { var glEntryID uuid.UUID if s.gl != nil { @@ -261,99 +558,17 @@ func (s *Service) PostAccountReturnToLedger(ctx context.Context, customerID, ret return glEntryID, nil } -// C2: Credit memo workflow -func (s *Service) CreateCreditMemo(ctx context.Context, customerID uuid.UUID, invoiceID *uuid.UUID, amountCents int64, reason string) (*CreditMemo, error) { - if amountCents <= 0 { - return nil, fmt.Errorf("amount_cents must be positive") - } - - cm := &CreditMemo{ - CustomerID: customerID, - InvoiceID: invoiceID, - Amount: amountCents, - Reason: reason, - Status: "PENDING", - } - - if err := s.repo.CreateCreditMemo(ctx, cm); err != nil { - return nil, err - } - - return cm, nil -} - -func (s *Service) ApplyCreditMemo(ctx context.Context, memoID uuid.UUID) error { - // We need to get the memo from the DB. For now, use a simple approach. - // The caller passes the memo ID; we'll fetch memos by looking up via service. - // Since we don't have a GetCreditMemo, we'll add a lightweight approach. - // Actually, let's just post the refund to the account ledger. - - // For the MVP, the handler will pass the credit memo details directly - return nil -} - -func (s *Service) ApplyCreditMemoFull(ctx context.Context, cm *CreditMemo) error { - now := time.Now() - cm.Status = "APPLIED" - cm.AppliedAt = &now - - return s.db.RunInTx(ctx, func(txCtx context.Context) error { - if err := s.repo.UpdateCreditMemo(txCtx, cm); err != nil { - return fmt.Errorf("failed to update credit memo: %w", err) - } - - // Post negative amount (credit) to customer account - _, err := s.account.PostTransaction(txCtx, cm.CustomerID, account.TransactionTypeRefund, -cm.Amount, &cm.ID, "Credit Memo: "+cm.Reason) - if err != nil { - return fmt.Errorf("failed to post credit to account: %w", err) - } - - return nil - }) -} - -// CreateAndApplyCreditMemo atomically creates a credit memo and applies it in a single transaction. -func (s *Service) CreateAndApplyCreditMemo(ctx context.Context, customerID uuid.UUID, invoiceID *uuid.UUID, amountCents int64, reason string) (*CreditMemo, error) { - if amountCents <= 0 { - return nil, fmt.Errorf("amount_cents must be positive") - } - - cm := &CreditMemo{ - CustomerID: customerID, - InvoiceID: invoiceID, - Amount: amountCents, - Reason: reason, - Status: "PENDING", - } - - err := s.db.RunInTx(ctx, func(txCtx context.Context) error { - if err := s.repo.CreateCreditMemo(txCtx, cm); err != nil { - return fmt.Errorf("failed to create credit memo: %w", err) +// sortedProductIDs returns the distinct product ids of the lines in id order +// (section 11, step 6: inventory rows are taken in product id order). +func sortedProductIDs(ids []uuid.UUID) []uuid.UUID { + seen := map[uuid.UUID]bool{} + var out []uuid.UUID + for _, id := range ids { + if !seen[id] { + seen[id] = true + out = append(out, id) } - - now := time.Now() - cm.Status = "APPLIED" - cm.AppliedAt = &now - - if err := s.repo.UpdateCreditMemo(txCtx, cm); err != nil { - return fmt.Errorf("failed to update credit memo: %w", err) - } - - // Post negative amount (credit) to customer account - _, err := s.account.PostTransaction(txCtx, cm.CustomerID, account.TransactionTypeRefund, -cm.Amount, &cm.ID, "Credit Memo: "+cm.Reason) - if err != nil { - return fmt.Errorf("failed to post credit to account: %w", err) - } - - return nil - }) - if err != nil { - return nil, err } - - return cm, nil -} - -func (s *Service) ListCreditMemos(ctx context.Context, customerID uuid.UUID) ([]CreditMemo, error) { - return s.repo.ListCreditMemos(ctx, customerID) + sort.Slice(out, func(a, b int) bool { return out[a].String() < out[b].String() }) + return out } diff --git a/core/internal/invoice/service_audit_tx_test.go b/core/internal/invoice/service_audit_tx_test.go index bfdd083f..f7e5859b 100644 --- a/core/internal/invoice/service_audit_tx_test.go +++ b/core/internal/invoice/service_audit_tx_test.go @@ -49,10 +49,10 @@ func TestCreateInvoiceAuditRollsBackWithFailedMutation(t *testing.T) { t.Fatalf("insert product: %v", err) } - inv := &invoice.Invoice{ + inv := &invoice.LegacyInvoice{ ID: uuid.New(), CustomerID: custID, - Lines: []invoice.InvoiceLine{{ProductID: productID, Quantity: 1, PriceEach: 7500}}, + Lines: []invoice.LegacyLine{{ProductID: productID, Quantity: 1, PriceEach: 7500}}, } err := db.RunInTx(ctx, func(txCtx context.Context) error { diff --git a/core/internal/invoice/service_cm.go b/core/internal/invoice/service_cm.go new file mode 100644 index 00000000..c0b330c6 --- /dev/null +++ b/core/internal/invoice/service_cm.go @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +// creditContext is what a draft resolves from its request: the invoice it +// names, the customer, branch, currency, ship-to, job and exemption. +type creditContext struct { + source *Invoice + customerID uuid.UUID + branchID uuid.UUID + currency string + shipToID *uuid.UUID + jobID *uuid.UUID + exempt bool +} + +// branchRefused is the payload branch rule's 403 naming the field. +func (s *Service) checkPayloadBranch(ctx context.Context, branch uuid.UUID, field string) error { + if s.branches == nil { + return nil + } + err := s.branches.CheckPayloadBranch(ctx, branch) + if errors.Is(err, middleware.ErrPayloadBranchRefused) { + return &httpx.Error{Status: http.StatusForbidden, Code: httpx.CodeForbidden, + Message: field + " is outside the branches this caller may target", + Details: []httpx.FieldError{{Field: field, Code: httpx.CodeForbidden, Message: "not a branch this caller may target"}}} + } + return err +} + +// resolveCredit settles the draft's context. stored is the memo being edited +// (nil on create): its customer and invoice are fixed once the memo exists. +func (s *Service) resolveCredit(ctx context.Context, st Store, d *CreditInput, stored *CreditMemo) (*creditContext, error) { + cc := &creditContext{} + var invoiceID *uuid.UUID = d.InvoiceID + if stored != nil { + if d.InvoiceID != nil && (stored.InvoiceID == nil || *stored.InvoiceID != *d.InvoiceID) { + return nil, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: "invoice_id", Message: "cannot be changed by an edit: it is fixed when the credit memo is created"}) + } + if d.CustomerID != nil && *d.CustomerID != stored.CustomerID { + return nil, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: "customer_id", Message: "cannot be changed by an edit: it is fixed when the credit memo is created"}) + } + invoiceID = stored.InvoiceID + } + if invoiceID != nil { + inv, err := st.GetInvoice(ctx, *invoiceID) + if err != nil { + var he *httpx.Error + if errors.As(err, &he) && he.Status == http.StatusNotFound { + return nil, validationFailed("a referenced record does not exist", + httpx.FieldError{Field: "invoice_id", Message: "no such invoice"}) + } + return nil, err + } + if inv.Status == InvoiceStatusVoid { + return nil, conflictBlocker("invoice_void", "the invoice is void: a credit memo cannot name it") + } + if d.CustomerID != nil && *d.CustomerID != inv.CustomerID { + return nil, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: "customer_id", Message: "is not the invoice's customer"}) + } + if d.BranchID != nil && *d.BranchID != inv.BranchID { + return nil, validationFailed("one or more fields failed validation", + httpx.FieldError{Field: "branch_id", Message: "is not the invoice's branch"}) + } + cc.source, cc.customerID, cc.branchID, cc.currency = inv, inv.CustomerID, inv.BranchID, inv.Currency + cc.shipToID, cc.jobID = inv.ShipToID, inv.JobID + } else { + cc.customerID = *d.CustomerID + if stored != nil { + cc.customerID = stored.CustomerID + cc.branchID, cc.currency = stored.BranchID, stored.Currency + } + } + facts, err := st.CustomerCreditFacts(ctx, cc.customerID) + if err != nil { + return nil, err + } + cc.exempt = facts.Exempt + if cc.source == nil && stored == nil { + cc.currency = facts.Currency + switch { + case d.BranchID != nil: + cc.branchID = *d.BranchID + case middleware.BranchIDForQuery(ctx) != nil: + cc.branchID = *middleware.BranchIDForQuery(ctx) + default: + cc.branchID = facts.PrimaryBranchID + } + } + if d.ShipToID != nil { + cc.shipToID = d.ShipToID + } + if d.JobID != nil { + cc.jobID = d.JobID + } + shipOK, jobOK, err := st.OwnedBy(ctx, cc.customerID, d.ShipToID, d.JobID) + if err != nil { + return nil, err + } + var bad []httpx.FieldError + if !shipOK { + bad = append(bad, httpx.FieldError{Field: "ship_to_id", Message: "is not a ship-to of this customer"}) + } + if !jobOK { + bad = append(bad, httpx.FieldError{Field: "job_id", Message: "is not a job of this customer"}) + } + if len(bad) > 0 { + return nil, validationFailed("one or more fields failed validation", bad...) + } + return cc, nil +} + +func (s *Service) recordCredit(ctx context.Context, cm *CreditMemo, eventType, from string) error { + data := map[string]any{ + "number": cm.Number, "customer_id": cm.CustomerID, "status": cm.Status.Status(), "revision": cm.Revision, + "currency": cm.Currency, "total_cents": int64(cm.TotalCents), + } + if from != "" { + data["from_status"] = from + } + if cm.InvoiceID != nil { + data["invoice_id"] = cm.InvoiceID + } + branch := cm.BranchID + return s.record(ctx, outbox.Event{Type: eventType, EntityType: "credit_memo", EntityID: cm.ID, + BranchID: &branch, Data: marshal(data)}) +} + +func (s *Service) GetCreditMemo(ctx context.Context, id uuid.UUID) (*CreditMemo, error) { + st, err := s.store() + if err != nil { + return nil, err + } + return st.GetCreditMemo(ctx, id) +} + +// ListCreditMemos answers one page and whether another follows. +func (s *Service) ListCreditMemos(ctx context.Context, f CreditFilter, wantTotal bool) (items []CreditMemoSummary, hasMore bool, total *int64, err error) { + st, err := s.store() + if err != nil { + return nil, false, nil, err + } + probe := f + probe.Limit = f.Limit + 1 + items, err = st.ListCreditMemos(ctx, probe) + if err != nil { + return nil, false, nil, err + } + if len(items) > f.Limit { + items, hasMore = items[:f.Limit], true + } + if wantTotal { + n, err := st.CountCreditMemos(ctx, f) + if err != nil { + return nil, false, nil, err + } + total = &n + } + return items, hasMore, total, nil +} + +// CreateCreditMemo writes a credit memo draft (ADR 0005 6.3): no number, the +// lines built and totalled, the event last. +func (s *Service) CreateCreditMemo(ctx context.Context, d *CreditInput, actor string) (*CreditMemo, error) { + st, err := s.store() + if err != nil { + return nil, err + } + var out *CreditMemo + err = s.inTx(ctx, func(ctx context.Context) error { + if d.BranchID != nil { + if err := s.checkPayloadBranch(ctx, *d.BranchID, "branch_id"); err != nil { + return err + } + } + cc, err := s.resolveCredit(ctx, st, d, nil) + if err != nil { + return err + } + if err := s.checkPayloadBranch(ctx, cc.branchID, "branch_id"); err != nil { + return err + } + id := uuid.New() + b, err := s.buildForDraft(ctx, st, d, cc, uuid.Nil, nil) + if err != nil { + return err + } + date, err := st.BranchLocalDate(ctx, cc.branchID, s.now()) + if err != nil { + return err + } + h := &CreditHeader{ID: id, CustomerID: cc.customerID, InvoiceID: d.InvoiceID, BranchID: cc.branchID, ProjectID: cc.jobID, + ShipToID: cc.shipToID, Currency: cc.currency, ReasonCode: d.ReasonCode, Reason: d.Reason, MemoDate: date, + SubtotalCents: b.subtotal, TaxCents: b.tax, TotalCents: b.total(), TaxRate: b.taxRate} + if err := st.InsertCreditMemo(ctx, h); err != nil { + return err + } + if err := st.ReplaceCreditLines(ctx, id, b.lines); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{Action: "credit_memo.created", EntityType: "credit_memo", EntityID: id, UserID: actor, + Changes: map[string]interface{}{"customer_id": cc.customerID, "invoice_id": d.InvoiceID, "total_cents": b.total()}}); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + if out, err = st.GetCreditMemo(ctx, id); err != nil { + return err + } + return s.recordCredit(ctx, out, EventCreditCreated, "") + }) + if err != nil { + return nil, err + } + return out, nil +} + +// buildForDraft builds a draft's lines against what the invoice's other +// credit memos have credited: the extensions telescope against the posted +// ones, the exceeds_billed guard counts the drafts too. +func (s *Service) buildForDraft(ctx context.Context, st Store, d *CreditInput, cc *creditContext, exclude uuid.UUID, current []CreditLine) (*creditBuild, error) { + var amounts, guard CreditedAgainst + if cc.source != nil { + var err error + if amounts, err = st.CreditedAgainstInvoice(ctx, cc.source.ID, exclude, false); err != nil { + return nil, err + } + if guard, err = st.CreditedAgainstInvoice(ctx, cc.source.ID, exclude, true); err != nil { + return nil, err + } + } + ids := map[uuid.UUID]bool{} + for i := range current { + ids[current[i].ID] = true + } + return s.buildCredit(ctx, st, d, cc.source, amounts, guard, ids, cc.branchID, cc.shipToID, cc.exempt) +} + +// UpdateCreditMemo replaces a draft's header and lines on the client's +// revision; any other status is 409 credit_memo_not_draft. +func (s *Service) UpdateCreditMemo(ctx context.Context, id uuid.UUID, d *CreditInput, pre Precondition, actor string) (*CreditMemo, error) { + if pre.missing() { + return nil, httpx.PreconditionRequired("this write needs If-Match or a body revision") + } + st, err := s.store() + if err != nil { + return nil, err + } + var out *CreditMemo + err = s.inTx(ctx, func(ctx context.Context) error { + cm, err := st.LockCreditMemo(ctx, id) + if err != nil { + return notFound(err) + } + if err := s.checkBranch(ctx, cm.BranchID, "credit memo"); err != nil { + return err + } + if err := pre.check(cm.Revision); err != nil { + return err + } + if cm.Status != CreditDraft { + return conflictBlocker("credit_memo_not_draft", "only a draft credit memo can be edited: it is "+cm.Status.Status()) + } + cc, err := s.resolveCredit(ctx, st, d, cm) + if err != nil { + return err + } + b, err := s.buildForDraft(ctx, st, d, cc, id, cm.Lines) + if err != nil { + return err + } + date, err := st.BranchLocalDate(ctx, cc.branchID, s.now()) + if err != nil { + return err + } + h := &CreditHeader{ID: id, CustomerID: cc.customerID, InvoiceID: cm.InvoiceID, BranchID: cc.branchID, ProjectID: cc.jobID, + ShipToID: cc.shipToID, Currency: cc.currency, ReasonCode: d.ReasonCode, Reason: d.Reason, MemoDate: date, + SubtotalCents: b.subtotal, TaxCents: b.tax, TotalCents: b.total(), TaxRate: b.taxRate} + if err := st.UpdateCreditDraft(ctx, h); err != nil { + return err + } + if err := st.ReplaceCreditLines(ctx, id, b.lines); err != nil { + return err + } + if out, err = st.GetCreditMemo(ctx, id); err != nil { + return err + } + return s.recordCredit(ctx, out, EventCreditUpdated, "") + }) + if err != nil { + return nil, err + } + return out, nil +} + +// TransitionCreditMemo runs the credit memo lifecycle's transitions (ADR 0005 +// 6.3): draft to open (the post) and draft or open to void. The statuses past +// open arrive with C2-4's applications and are not reachable by a client. +func (s *Service) TransitionCreditMemo(ctx context.Context, id uuid.UUID, to CreditStatus, pre Precondition, body Transition) (*CreditMemo, error) { + if pre.missing() { + return nil, httpx.PreconditionRequired("this write needs If-Match or a body revision") + } + switch to { + case CreditOpen: + return s.postCredit(ctx, id, pre, body) + case CreditVoid: + return s.voidCredit(ctx, id, pre, body) + } + return nil, httpx.InvalidStateTransition("a credit memo moves to open (post) or void; " + to.Status() + " is reached by applications and refunds") +} + +// postCredit posts a draft: it locks the invoice it names (so an invoice +// void's has_credit_memos check cannot race it), rebuilds the lines against +// what was posted, restocks, mints the gapless number late, posts the entry +// and the subledger credit, and writes credit_memo.posted last. +func (s *Service) postCredit(ctx context.Context, id uuid.UUID, pre Precondition, body Transition) (*CreditMemo, error) { + if err := requireFinance(body.Role, "posting a credit memo"); err != nil { + return nil, err + } + st, err := s.store() + if err != nil { + return nil, err + } + var out *CreditMemo + err = s.inTx(ctx, func(ctx context.Context) error { + cm, err := st.LockCreditMemo(ctx, id) // step 3 + if err != nil { + return notFound(err) + } + if err := s.checkBranch(ctx, cm.BranchID, "credit memo"); err != nil { + return err + } + if err := pre.check(cm.Revision); err != nil { + return err + } + if cm.Status != CreditDraft { + return httpx.InvalidStateTransition("only a draft credit memo can be posted: it is " + cm.Status.Status()) + } + var source *Invoice + if cm.InvoiceID != nil { + if source, err = st.LockInvoice(ctx, *cm.InvoiceID); err != nil { // step 4 + return notFound(err) + } + if source.Status == InvoiceStatusVoid { + return conflictBlocker("invoice_void", "the invoice is void: a credit memo cannot be posted against it") + } + } + facts, err := st.CustomerCreditFacts(ctx, cm.CustomerID) + if err != nil { + return err + } + d := draftFromStored(cm) + var amounts CreditedAgainst + if source != nil { + if amounts, err = st.CreditedAgainstInvoice(ctx, source.ID, cm.ID, false); err != nil { + return err + } + } + ids := map[uuid.UUID]bool{} + for i := range cm.Lines { + ids[cm.Lines[i].ID] = true + } + b, err := s.buildCredit(ctx, st, d, source, amounts, amounts, ids, cm.BranchID, cm.ShipToID, facts.Exempt) + if err != nil { + return err + } + total := b.total() + if total >= 0 { + return conflictBlocker("empty_credit_memo", "the credit memo credits nothing: add a priced line") + } + + // Step 6: the restocked goods back on hand, in (product id, line id) order. + if err := s.restockLines(ctx, cm.BranchID, b.lines, true); err != nil { + return err + } + + // Steps 7 and 8: the customer row, then the gapless counter, late. + if err := st.LockCustomer(ctx, cm.CustomerID); err != nil { + return err + } + number, err := st.NextCreditMemoNumber(ctx) + if err != nil { + return err + } + date, err := st.BranchLocalDate(ctx, cm.BranchID, s.now()) + if err != nil { + return err + } + + // Step 9: the entry. Revenue and tax come back, the receivable falls, + // and a restocked line returns its cost to inventory at the cost that + // left (COGS reverses at the original cost). + legs := []gl.Leg{{AccountCode: gl.AccountCodeAR, Description: "Accounts Receivable", Credit: -total}} + for _, g := range salesdoc.RevenueGroups(creditLinesAsDoc(b.lines)) { + legs = append(legs, gl.Leg{AccountCode: g.AccountCode, Description: "Revenue " + g.AccountCode, Debit: -int64(g.Cents)}) + } + restocked := restockSum(b.lines) + legs = append(legs, + gl.Leg{AccountCode: gl.AccountCodeSalesTax, Description: "Sales Tax Payable", Debit: -b.tax}, + gl.Leg{AccountCode: gl.AccountCodeInventory, Description: "Inventory", Debit: restocked}, + gl.Leg{AccountCode: gl.AccountCodeCOGS, Description: "Cost of Goods Sold", Credit: restocked}) + cmID := cm.ID + var glID *uuid.UUID + if s.gl != nil { + entry, err := s.gl.PostEntry(ctx, gl.PostingInput{EntryDate: date, Memo: "Credit memo " + number, Source: gl.SourceCreditMemo, + SourceRefID: &cmID, Currency: cm.Currency, PostedBy: body.Actor, Legs: legs}) + if err != nil { + return mapPostingError(fmt.Errorf("failed to post the credit memo entry: %w", err)) + } + if entry != nil { + glID = &entry.ID + } + } + if s.account != nil { + if _, err := s.account.PostTransaction(ctx, cm.CustomerID, account.TransactionTypeCreditMemo, total, &cmID, "Credit memo "+number); err != nil { + return fmt.Errorf("failed to post the credit memo to the account ledger: %w", err) + } + } + h := &CreditHeader{ID: cm.ID, MemoDate: date, SubtotalCents: b.subtotal, TaxCents: b.tax, TotalCents: total, TaxRate: b.taxRate} + if err := st.ReplaceCreditLines(ctx, cm.ID, b.lines); err != nil { + return err + } + if err := st.PostCredit(ctx, h, number, glID); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{Action: "credit_memo.posted", EntityType: "credit_memo", EntityID: cm.ID, UserID: body.Actor, + Changes: map[string]interface{}{"number": number, "customer_id": cm.CustomerID, "invoice_id": cm.InvoiceID, "total_cents": total}}); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + if out, err = st.GetCreditMemo(ctx, cm.ID); err != nil { + return err + } + return s.recordCredit(ctx, out, EventCreditPosted, CreditDraft.Status()) + }) + if err != nil { + return nil, err + } + return out, nil +} + +// restockLines moves the restocked lines' quantities into (add) or out of +// (not add) on hand, in (product id, line id) order (section 11 step 6). +func (s *Service) restockLines(ctx context.Context, branchID uuid.UUID, lines []CreditLine, add bool) error { + var idx []int + for i := range lines { + if lines[i].Restock && lines[i].ProductID != nil && lines[i].Quantity != nil { + idx = append(idx, i) + } + } + if len(idx) == 0 { + return nil + } + if s.inventory == nil { + return errors.New("invoice: the stock seam is not wired") + } + sort.SliceStable(idx, func(a, b int) bool { + la, lb := &lines[idx[a]], &lines[idx[b]] + if la.ProductID.String() != lb.ProductID.String() { + return la.ProductID.String() < lb.ProductID.String() + } + return la.ID.String() < lb.ID.String() + }) + for _, i := range idx { + l := &lines[i] + qty := -*l.Quantity + if add { + if err := s.inventory.RestockQty(ctx, *l.ProductID, branchID, qty); err != nil { + return err + } + continue + } + if err := s.inventory.UnrestockQty(ctx, *l.ProductID, branchID, qty); err != nil { + if errors.Is(err, inventory.ErrInsufficientAvailable) { + return conflictBlocker("restock_consumed", "the restocked goods are no longer on hand: the credit memo cannot be voided") + } + return err + } + } + return nil +} + +func creditLinesAsDoc(lines []CreditLine) []salesdoc.Line { + out := make([]salesdoc.Line, len(lines)) + for i := range lines { + out[i] = lines[i].Line + } + return out +} + +// voidCredit voids a draft (it consumes no number) or an open credit memo +// with no application: it reverses the entry and the restock, and puts the +// credit back on the receivable. +func (s *Service) voidCredit(ctx context.Context, id uuid.UUID, pre Precondition, body Transition) (*CreditMemo, error) { + st, err := s.store() + if err != nil { + return nil, err + } + var out *CreditMemo + err = s.inTx(ctx, func(ctx context.Context) error { + head, err := st.GetCreditMemo(ctx, id) // unlocked: learn the customer + if err != nil { + return notFound(err) + } + if head.Status != CreditDraft { + if err := requireFinance(body.Role, "voiding a posted credit memo"); err != nil { + return err + } + // Voiding a credit memo adds to the customer's exposure: it takes + // the customer's credit serialization (section 11 step 1a). + if err := st.LockCustomerCredit(ctx, head.CustomerID); err != nil { + return err + } + } + cm, err := st.LockCreditMemo(ctx, id) // step 3 + if err != nil { + return notFound(err) + } + if err := s.checkBranch(ctx, cm.BranchID, "credit memo"); err != nil { + return err + } + if err := pre.check(cm.Revision); err != nil { + return err + } + switch cm.Status { + case CreditVoid: + return httpx.InvalidStateTransition("the credit memo is already void") + case CreditPartial, CreditApplied: + return conflictBlocker("has_applications", "the credit memo has been used: reverse its applications and refunds before voiding it") + } + date, err := st.BranchLocalDate(ctx, cm.BranchID, s.now()) + if err != nil { + return err + } + from := cm.Status + if cm.Status == CreditOpen { + if err := s.restockLines(ctx, cm.BranchID, cm.Lines, false); err != nil { // step 6 + return err + } + if err := st.LockCustomer(ctx, cm.CustomerID); err != nil { // step 7 + return err + } + if cm.GLEntryID != nil && s.gl != nil { // step 9 + if _, err := s.gl.PostReversal(ctx, gl.ReversalInput{EntryID: *cm.GLEntryID, EntryDate: date, Currency: cm.Currency, + Reason: "credit memo voided", PostedBy: body.Actor}); err != nil { + return mapPostingError(err) + } + } + if s.account != nil { + cmID := cm.ID + num := "" + if cm.Number != nil { + num = *cm.Number + } + if _, err := s.account.PostTransaction(ctx, cm.CustomerID, account.TransactionTypeReversal, -int64(cm.TotalCents), &cmID, "Void of credit memo "+num); err != nil { + return fmt.Errorf("failed to reverse the credit memo in the account ledger: %w", err) + } + } + } + if err := st.MarkCreditVoid(ctx, id, body.Actor, body.Reason, date); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{Action: "credit_memo.voided", EntityType: "credit_memo", EntityID: id, UserID: body.Actor, + Changes: map[string]interface{}{"previous_status": from.Status(), "reason": body.Reason, "total_cents": int64(cm.TotalCents)}}); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + if out, err = st.GetCreditMemo(ctx, id); err != nil { + return err + } + return s.recordCredit(ctx, out, EventCreditVoided, from.Status()) + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/core/internal/invoice/service_test.go b/core/internal/invoice/service_test.go index 2fc669b1..e73ce06b 100644 --- a/core/internal/invoice/service_test.go +++ b/core/internal/invoice/service_test.go @@ -25,7 +25,7 @@ import ( // --- fakes ------------------------------------------------------------------- type fakeRepo struct { - created []Invoice + created []LegacyInvoice createErr error branchRate float64 @@ -34,13 +34,9 @@ type fakeRepo struct { openBalance int64 existsOrder bool - - memos []CreditMemo - memoErr error - updateCM error } -func (f *fakeRepo) CreateInvoice(_ context.Context, inv *Invoice) error { +func (f *fakeRepo) CreateInvoice(_ context.Context, inv *LegacyInvoice) error { if f.createErr != nil { return f.createErr } @@ -51,11 +47,7 @@ func (f *fakeRepo) CreateInvoice(_ context.Context, inv *Invoice) error { return nil } func (f *fakeRepo) GetInvoice(context.Context, uuid.UUID) (*Invoice, error) { return nil, nil } -func (f *fakeRepo) ListInvoices(context.Context) ([]Invoice, error) { return nil, nil } -func (f *fakeRepo) ListInvoicesPaginated(context.Context, int, int) ([]Invoice, int, error) { - return nil, 0, nil -} -func (f *fakeRepo) UpdateInvoice(context.Context, *Invoice) error { return nil } +func (f *fakeRepo) UpdateInvoice(context.Context, *Invoice) error { return nil } func (f *fakeRepo) ExistsInvoiceForOrder(context.Context, uuid.UUID) (bool, error) { return f.existsOrder, nil } @@ -66,20 +58,6 @@ func (f *fakeRepo) GetBranchTaxRate(_ context.Context, branchID *uuid.UUID) (flo f.branchSeen = append(f.branchSeen, branchID) return f.branchRate, f.branchOK } -func (f *fakeRepo) CreateCreditMemo(_ context.Context, cm *CreditMemo) error { - if f.memoErr != nil { - return f.memoErr - } - if cm.ID == uuid.Nil { - cm.ID = uuid.New() - } - f.memos = append(f.memos, *cm) - return nil -} -func (f *fakeRepo) ListCreditMemos(context.Context, uuid.UUID) ([]CreditMemo, error) { - return f.memos, nil -} -func (f *fakeRepo) UpdateCreditMemo(context.Context, *CreditMemo) error { return f.updateCM } // fakeAccount records AR subledger postings. type fakeAccount struct { @@ -111,8 +89,8 @@ func (f *fakeAccount) GetTransactions(context.Context, uuid.UUID) ([]account.Cus func txCtx() context.Context { return testutil.TxContext(context.Background()) } -func line(priceEach int64, qty float64) InvoiceLine { - return InvoiceLine{ProductID: uuid.New(), PriceEach: priceEach, Quantity: qty} +func line(priceEach int64, qty float64) LegacyLine { + return LegacyLine{ProductID: uuid.New(), PriceEach: priceEach, Quantity: qty} } // --- subtotal accumulation --------------------------------------------------- @@ -122,26 +100,26 @@ func line(priceEach int64, qty float64) InvoiceLine { func TestCreateInvoice_SubtotalAccumulation(t *testing.T) { tests := []struct { name string - lines []InvoiceLine + lines []LegacyLine want int64 }{ - {"single whole-unit line", []InvoiceLine{line(1299, 1)}, 1299}, - {"quantity multiplies the unit price", []InvoiceLine{line(1299, 4)}, 5196}, - {"three lines accumulate", []InvoiceLine{line(1000, 2), line(250, 4), line(99, 1)}, 3099}, - {"fractional board feet", []InvoiceLine{line(100, 12.5)}, 1250}, - {"many small lines do not drift", []InvoiceLine{ + {"single whole-unit line", []LegacyLine{line(1299, 1)}, 1299}, + {"quantity multiplies the unit price", []LegacyLine{line(1299, 4)}, 5196}, + {"three lines accumulate", []LegacyLine{line(1000, 2), line(250, 4), line(99, 1)}, 3099}, + {"fractional board feet", []LegacyLine{line(100, 12.5)}, 1250}, + {"many small lines do not drift", []LegacyLine{ line(1, 1), line(1, 1), line(1, 1), line(1, 1), line(1, 1), line(1, 1), line(1, 1), line(1, 1), line(1, 1), line(1, 1), }, 10}, // CHARACTERIZATION: a zero-quantity line contributes nothing and is // neither rejected nor dropped. CreateInvoice performs no line-level // validation at all. - {"zero quantity contributes zero", []InvoiceLine{line(5000, 0), line(2500, 2)}, 5000}, - {"zero price contributes zero", []InvoiceLine{line(0, 99), line(2500, 2)}, 5000}, + {"zero quantity contributes zero", []LegacyLine{line(5000, 0), line(2500, 2)}, 5000}, + {"zero price contributes zero", []LegacyLine{line(0, 99), line(2500, 2)}, 5000}, // CHARACTERIZATION: negative quantities are accepted and reduce the // subtotal. There is no guard against a credit-shaped line arriving on // a regular invoice. - {"negative quantity subtracts", []InvoiceLine{line(1000, 5), line(1000, -2)}, 3000}, + {"negative quantity subtracts", []LegacyLine{line(1000, 5), line(1000, -2)}, 3000}, } for _, tc := range tests { @@ -149,7 +127,7 @@ func TestCreateInvoice_SubtotalAccumulation(t *testing.T) { repo := &fakeRepo{branchRate: 0, branchOK: false} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{CustomerID: uuid.New(), Lines: tc.lines} + inv := &LegacyInvoice{CustomerID: uuid.New(), Lines: tc.lines} if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) } @@ -166,10 +144,10 @@ func TestCreateInvoice_PrecomputedSubtotalIsPreserved(t *testing.T) { repo := &fakeRepo{branchRate: 0.10, branchOK: true} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{ + inv := &LegacyInvoice{ CustomerID: uuid.New(), Subtotal: 9999, // deliberately disagrees with the lines - Lines: []InvoiceLine{line(100, 1)}, + Lines: []LegacyLine{line(100, 1)}, } if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) @@ -211,7 +189,7 @@ func TestCreateInvoice_LineExtensionRounding(t *testing.T) { t.Run(tc.name, func(t *testing.T) { repo := &fakeRepo{} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{CustomerID: uuid.New(), Lines: []InvoiceLine{line(tc.priceEach, tc.qty)}} + inv := &LegacyInvoice{CustomerID: uuid.New(), Lines: []LegacyLine{line(tc.priceEach, tc.qty)}} if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) } @@ -234,7 +212,7 @@ func TestCreateInvoice_TaxRateResolution(t *testing.T) { repo := &fakeRepo{branchRate: 0.12, branchOK: true} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{CustomerID: uuid.New(), BranchID: branchID, Lines: []InvoiceLine{line(10000, 1)}} + inv := &LegacyInvoice{CustomerID: uuid.New(), BranchID: branchID, Lines: []LegacyLine{line(10000, 1)}} if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) } @@ -256,7 +234,7 @@ func TestCreateInvoice_TaxRateResolution(t *testing.T) { repo := &fakeRepo{branchOK: false} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{CustomerID: uuid.New(), Lines: []InvoiceLine{line(100000, 1)}} + inv := &LegacyInvoice{CustomerID: uuid.New(), Lines: []LegacyLine{line(100000, 1)}} if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) } @@ -275,7 +253,7 @@ func TestCreateInvoice_TaxRateResolution(t *testing.T) { repo := &fakeRepo{branchRate: 0.12, branchOK: true} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{CustomerID: uuid.New(), TaxRate: 0.05, Lines: []InvoiceLine{line(10000, 1)}} + inv := &LegacyInvoice{CustomerID: uuid.New(), TaxRate: 0.05, Lines: []LegacyLine{line(10000, 1)}} if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) } @@ -302,11 +280,11 @@ func TestCreateInvoice_TotalEqualsSubtotalPlusTax(t *testing.T) { for _, sub := range subtotals { repo := &fakeRepo{} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{ + inv := &LegacyInvoice{ CustomerID: uuid.New(), Subtotal: sub, TaxRate: rate, - Lines: []InvoiceLine{line(1, 1)}, + Lines: []LegacyLine{line(1, 1)}, } if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) @@ -347,11 +325,11 @@ func TestCreateInvoice_TaxHalfCentRounding(t *testing.T) { for _, tc := range tests { repo := &fakeRepo{} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{ + inv := &LegacyInvoice{ CustomerID: uuid.New(), Subtotal: tc.subtotal, TaxRate: tc.rate, - Lines: []InvoiceLine{line(1, 1)}, + Lines: []LegacyLine{line(1, 1)}, } if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) @@ -377,13 +355,13 @@ func TestCreateInvoice_PresetTotalSuppressesTax(t *testing.T) { repo := &fakeRepo{branchRate: 0.12, branchOK: true} svc := NewService(repo, nil, nil, nil) - inv := &Invoice{ + inv := &LegacyInvoice{ CustomerID: uuid.New(), Subtotal: 10000, TaxAmount: 1200, TaxRate: 0.12, TotalAmount: 11200, - Lines: []InvoiceLine{line(10000, 1)}, + Lines: []LegacyLine{line(10000, 1)}, } if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) @@ -423,13 +401,13 @@ func TestCreateInvoice_OrderFulfilmentPathIsTaxed(t *testing.T) { const orderTotalCents = 10000 // what order.CreateOrder computed, pre-tax // Exactly how order.FulfillOrder builds the invoice. - inv := &Invoice{ + inv := &LegacyInvoice{ OrderID: uuid.New(), CustomerID: uuid.New(), BranchID: uuid.New(), TotalAmount: orderTotalCents, Status: InvoiceStatusUnpaid, - Lines: []InvoiceLine{line(10000, 1)}, + Lines: []LegacyLine{line(10000, 1)}, } if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) @@ -455,7 +433,7 @@ func TestCreateInvoice_RejectsEmptyLines(t *testing.T) { repo := &fakeRepo{} svc := NewService(repo, nil, nil, nil) - err := svc.CreateInvoice(txCtx(), &Invoice{CustomerID: uuid.New()}) + err := svc.CreateInvoice(txCtx(), &LegacyInvoice{CustomerID: uuid.New()}) if err == nil { t.Fatal("want an error for an invoice with no lines") } @@ -464,31 +442,19 @@ func TestCreateInvoice_RejectsEmptyLines(t *testing.T) { } } -// CORRECTNESS: unset status and terms get safe defaults, and a due date is -// always stamped. +// CORRECTNESS: an unset status defaults to unpaid (the repository stamps the +// terms and the due date from the customer's payment terms by id). func TestCreateInvoice_Defaults(t *testing.T) { repo := &fakeRepo{} svc := NewService(repo, nil, nil, nil) - before := time.Now() - inv := &Invoice{CustomerID: uuid.New(), Lines: []InvoiceLine{line(1000, 1)}} + inv := &LegacyInvoice{CustomerID: uuid.New(), Lines: []LegacyLine{line(1000, 1)}} if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) } - if inv.Status != InvoiceStatusUnpaid { t.Errorf("Status = %q, want %q", inv.Status, InvoiceStatusUnpaid) } - if inv.PaymentTerms != TermsNet30 { - t.Errorf("PaymentTerms = %q, want %q", inv.PaymentTerms, TermsNet30) - } - if inv.DueDate == nil { - t.Fatal("DueDate must be set") - } - wantEarliest := before.AddDate(0, 0, 30) - if inv.DueDate.Before(wantEarliest.Add(-time.Minute)) { - t.Errorf("DueDate = %v, want roughly 30 days out (%v)", inv.DueDate, wantEarliest) - } } // CORRECTNESS: a caller-supplied status and due date are respected. @@ -497,12 +463,11 @@ func TestCreateInvoice_PreservesCallerStatusAndDueDate(t *testing.T) { svc := NewService(repo, nil, nil, nil) due := time.Date(2027, 1, 15, 0, 0, 0, 0, time.UTC) - inv := &Invoice{ - CustomerID: uuid.New(), - Status: InvoiceStatusPartial, - PaymentTerms: TermsCOD, - DueDate: &due, - Lines: []InvoiceLine{line(1000, 1)}, + inv := &LegacyInvoice{ + CustomerID: uuid.New(), + Status: InvoiceStatusPartial, + DueDate: &due, + Lines: []LegacyLine{line(1000, 1)}, } if err := svc.CreateInvoice(txCtx(), inv); err != nil { t.Fatalf("CreateInvoice: %v", err) @@ -520,85 +485,12 @@ func TestCreateInvoice_RepositoryErrorPropagates(t *testing.T) { repo := &fakeRepo{createErr: errors.New("insert failed")} svc := NewService(repo, nil, nil, nil) - err := svc.CreateInvoice(txCtx(), &Invoice{CustomerID: uuid.New(), Lines: []InvoiceLine{line(1000, 1)}}) + err := svc.CreateInvoice(txCtx(), &LegacyInvoice{CustomerID: uuid.New(), Lines: []LegacyLine{line(1000, 1)}}) if err == nil { t.Fatal("want the repository error to propagate") } } -// CORRECTNESS: payment terms map to the documented net periods; anything -// unrecognised defaults to 30 days rather than to "due immediately". -func TestCalcDueDate(t *testing.T) { - from := time.Date(2026, 1, 31, 12, 0, 0, 0, time.UTC) - - tests := []struct { - terms string - want time.Time - }{ - {TermsCOD, from}, - {TermsDueOnReceipt, from}, - {TermsNet30, from.AddDate(0, 0, 30)}, - {TermsNet60, from.AddDate(0, 0, 60)}, - {TermsNet90, from.AddDate(0, 0, 90)}, - {"NET45", from.AddDate(0, 0, 30)}, - {"", from.AddDate(0, 0, 30)}, - } - - for _, tc := range tests { - t.Run(tc.terms, func(t *testing.T) { - if got := calcDueDate(from, tc.terms); !got.Equal(tc.want) { - t.Errorf("calcDueDate(%v, %q) = %v, want %v", from, tc.terms, got, tc.want) - } - }) - } -} - -// --- credit memos ------------------------------------------------------------ - -// CORRECTNESS: a credit memo must carry a positive amount. A zero or negative -// memo would become a debit when applied (ApplyCreditMemoFull posts -Amount). -func TestCreateCreditMemo_RejectsNonPositiveAmounts(t *testing.T) { - tests := []struct { - name string - amount int64 - wantErr bool - }{ - {"positive is accepted", 1, false}, - {"zero is rejected", 0, true}, - {"negative is rejected", -500, true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - repo := &fakeRepo{} - svc := NewService(repo, nil, nil, nil) - - cm, err := svc.CreateCreditMemo(context.Background(), uuid.New(), nil, tc.amount, "damaged goods") - if tc.wantErr { - if err == nil { - t.Fatalf("want an error for amount %d", tc.amount) - } - if len(repo.memos) != 0 { - t.Error("a rejected memo must not be persisted") - } - return - } - if err != nil { - t.Fatalf("CreateCreditMemo: %v", err) - } - if cm.Status != "PENDING" { - t.Errorf("Status = %q, want PENDING — a new memo is not yet applied", cm.Status) - } - if cm.Amount != tc.amount { - t.Errorf("Amount = %d, want %d", cm.Amount, tc.amount) - } - if cm.AppliedAt != nil { - t.Error("AppliedAt must be nil on a pending memo") - } - }) - } -} - // --- ledger posting (double entry) ------------------------------------------ // CORRECTNESS: posting an invoice books a balanced GL entry (DR AR / CR Sales @@ -609,7 +501,7 @@ func TestPostInvoiceToLedger_BalancedAndAgreeing(t *testing.T) { acct := &fakeAccount{} svc := NewService(&fakeRepo{}, newGLService(glRepo), acct, nil) - inv := &Invoice{ID: uuid.New(), CustomerID: uuid.New(), TotalAmount: 11200} + inv := &LegacyInvoice{ID: uuid.New(), CustomerID: uuid.New(), TotalAmount: 11200} if err := svc.PostInvoiceToLedger(context.Background(), inv); err != nil { t.Fatalf("PostInvoiceToLedger: %v", err) } @@ -660,7 +552,7 @@ func TestPostInvoiceToLedger_GLFailureBlocksSubledger(t *testing.T) { acct := &fakeAccount{} svc := NewService(&fakeRepo{}, newGLService(glRepo), acct, nil) - err := svc.PostInvoiceToLedger(context.Background(), &Invoice{ID: uuid.New(), CustomerID: uuid.New(), TotalAmount: 5000}) + err := svc.PostInvoiceToLedger(context.Background(), &LegacyInvoice{ID: uuid.New(), CustomerID: uuid.New(), TotalAmount: 5000}) if err == nil { t.Fatal("want an error when the GL posting fails") } diff --git a/core/internal/order/alloc.go b/core/internal/order/alloc.go index 431705e6..5b8c00f9 100644 --- a/core/internal/order/alloc.go +++ b/core/internal/order/alloc.go @@ -24,6 +24,8 @@ type Inventory interface { AllocateQty(ctx context.Context, productID, branchID uuid.UUID, want httpx.Quantity) (httpx.Quantity, error) ReleaseQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error FulfillQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error + // RestockQty returns a voided invoice's billed quantity to on hand. + RestockQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error } // Event types of the stock side of the lifecycle (ADR 0005 section 12). @@ -117,7 +119,7 @@ func (s *Service) allocateLines(ctx context.Context, cur *Order) (backordered bo continue } kitDone[kitID] = true - planKit(cur.Lines, kitIdx[kitID], kitOf[kitID], avail, take) + planKit(cur.Lines, kitIdx[kitID], kitOf[kitID], avail, take, nil) continue } need := unallocated(l) @@ -166,7 +168,7 @@ func (s *Service) allocateLines(ctx context.Context, cur *Order) (backordered bo // n = the most whole kits that are still unallocated and that every // component's available stock covers, each component taking n times its // per kit quantity (the component line's quantity over the kit line's). -func planKit(lines []OrderLine, kitAt int, comps []int, avail map[uuid.UUID]int64, take map[int]int64) { +func planKit(lines []OrderLine, kitAt int, comps []int, avail map[uuid.UUID]int64, take map[int]int64, limit *big.Int) { kit := &lines[kitAt] if kit.Quantity == nil || *kit.Quantity <= 0 { return @@ -191,6 +193,9 @@ func planKit(lines []OrderLine, kitAt int, comps []int, avail map[uuid.UUID]int6 best = n } } + if best != nil && limit != nil && limit.Cmp(best) < 0 { + best = limit // an invoice void gives back only the kits it returned + } if best == nil || best.Sign() <= 0 { return } diff --git a/core/internal/order/fulfil.go b/core/internal/order/fulfil.go index f31b08cd..a3db0898 100644 --- a/core/internal/order/fulfil.go +++ b/core/internal/order/fulfil.go @@ -520,6 +520,10 @@ func (s *Service) fulfil(ctx context.Context, id uuid.UUID, pre *Precondition, r ID: uuid.New(), Position: n, LineType: string(b.LineType), ProductID: b.ProductID, ChargeCodeID: b.ChargeCodeID, SKU: b.SKU, Description: b.Description, UOM: b.UOM, PriceUOM: b.PriceUOM, PriceSource: string(b.PriceSource), Taxable: b.Taxable, RevenueAccountCode: b.RevenueAccountCode, + OverrideReason: b.OverrideReason, PriceAdjustedBy: b.PriceAdjustedBy, + } + if b.PricedUnitPrice != nil { + fl.PricedUnitPrice = i64(int64(*b.PricedUnitPrice)) } olID := ol.ID fl.OrderLineID = &olID @@ -538,8 +542,12 @@ func (s *Service) fulfil(ctx context.Context, id uuid.UUID, pre *Precondition, r fl.DiscountPercent = i64(int64(*b.DiscountPercent)) } if b.DiscountAmount != nil { - // the invoice line's share of the prorated amount discount - share := salesdocDiscountShare(&ol.Line, ol.QuantityFulfilled, ol.QuantityFulfilled+*b.Quantity) + // the invoice line's share of the amount discount: its gross piece + // less its net, so the shares sum to the order line's discount + share, err := salesdoc.BilledDiscount(&ol.Line, ol.QuantityFulfilled, ol.QuantityFulfilled+*b.Quantity) + if err != nil { + return err + } fl.DiscountCents = i64(int64(share)) } fl.DiscountReason = b.DiscountReason @@ -614,24 +622,6 @@ func derefPrice(p *httpx.Price) httpx.Price { return *p } -// salesdocDiscountShare is an invoice line's share of its order line's amount -// discount: the difference of the prorated cumulative discounts. -func salesdocDiscountShare(l *salesdoc.Line, before, after httpx.Quantity) httpx.Cents { - if l.DiscountAmount == nil || l.Quantity == nil || *l.Quantity <= 0 { - return 0 - } - cum := func(q httpx.Quantity) int64 { - if q >= *l.Quantity { - return int64(*l.DiscountAmount) - } - n := new(big.Int).Mul(big.NewInt(int64(*l.DiscountAmount)), big.NewInt(int64(q))) - d := big.NewInt(int64(*l.Quantity)) - n.Add(n, new(big.Int).Rsh(d, 1)) - return new(big.Int).Div(n, d).Int64() - } - return httpx.Cents(cum(after) - cum(before)) -} - // checkFulfilFields holds the request fields to the order: a will-call // (pickup) fulfilment names who collected (1 to 200 characters), a delivery // fulfilment must not; a delivery id must be one of this order's deliveries. @@ -801,13 +791,19 @@ func nonStockRelief(rc NonStockReceipts, billedBefore, qty httpx.Quantity) httpx return httpx.Cents(n.Int64()) } -// recordInvoice writes invoice.created through the transaction's executor. +// recordInvoice writes invoice.created through the transaction's executor, and +// invoice.paid after it for a zero total (ADR 0005 6.2: an invoice with +// nothing owed is created paid). func (s *Service) recordInvoice(ctx context.Context, o *Order, inv *invoice.FulfilmentInvoice) error { if s.events == nil { return nil } + status := "unpaid" + if inv.TotalCents == 0 { + status = "paid" + } raw, err := jsonMarshal(map[string]any{ - "customer_id": inv.CustomerID, "order_id": inv.OrderID, "status": "unpaid", + "number": inv.Number, "customer_id": inv.CustomerID, "order_id": inv.OrderID, "status": status, "currency": inv.Currency, "total_cents": inv.TotalCents, "subtotal_cents": inv.SubtotalCents, "tax_cents": inv.TaxCents, "delivery_id": inv.DeliveryID, }) @@ -815,8 +811,23 @@ func (s *Service) recordInvoice(ctx context.Context, o *Order, inv *invoice.Fulf return err } branch := o.BranchID - return s.events.Write(ctx, outbox.Event{ + if err := s.events.Write(ctx, outbox.Event{ Type: EventInvoiceCreated, EntityType: "invoice", EntityID: inv.ID, BranchID: &branch, Data: raw, + }); err != nil { + return err + } + if inv.TotalCents != 0 { + return nil + } + paid, err := jsonMarshal(map[string]any{ + "number": inv.Number, "customer_id": inv.CustomerID, "order_id": inv.OrderID, "status": "paid", + "from_status": "unpaid", "currency": inv.Currency, "total_cents": inv.TotalCents, + }) + if err != nil { + return err + } + return s.events.Write(ctx, outbox.Event{ + Type: "invoice.paid", EntityType: "invoice", EntityID: inv.ID, BranchID: &branch, Data: paid, }) } diff --git a/core/internal/order/invoice_void.go b/core/internal/order/invoice_void.go new file mode 100644 index 00000000..0b43d58d --- /dev/null +++ b/core/internal/order/invoice_void.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package order + +import ( + "context" + "fmt" + "math/big" + "sort" + + "github.com/gablelbm/gable/internal/invoice" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +// The order's half of an invoice void (ADR 0005 6.2), called by the invoice +// module inside the void's transaction. + +// LockForInvoiceVoid takes the order row (section 11, step 1) and the +// customer's credit serialization (step 1a): a void re-opens billed quantity +// on the order, which adds to the exposure the credit check reads. +func (s *Service) LockForInvoiceVoid(ctx context.Context, orderID uuid.UUID) error { + if err := s.repo.LockOrder(ctx, orderID); err != nil { + return notFound(err) + } + cur, err := s.repo.GetOrder(ctx, orderID) + if err != nil { + return notFound(err) + } + return s.repo.LockCustomerCredit(ctx, cur.CustomerID) +} + +// ReturnBilled returns a voided invoice's billed quantities to its order: the +// stock back on hand (in (product id, line id) order, step 6), each line's +// quantity_fulfilled reduced, allocation re-run for exactly those quantities +// (what stock cannot cover goes back on order as a back order), and the +// status re-derived for an order still open or fulfilled. The order's own +// events come back for the invoice module to write last. +func (s *Service) ReturnBilled(ctx context.Context, orderID uuid.UUID, billed []invoice.BilledLine, actor string) (invoice.ReopenResult, error) { + var res invoice.ReopenResult + cur, err := s.repo.GetOrder(ctx, orderID) + if err != nil { + return res, notFound(err) + } + byID := map[uuid.UUID]int{} + for i := range cur.Lines { + byID[cur.Lines[i].ID] = i + } + scope := map[uuid.UUID]int64{} // order line id -> the quantity the void returns + for _, b := range billed { + i, ok := byID[b.OrderLineID] + if !ok { + continue // the order line was removed with its draft edit; nothing to return + } + l := &cur.Lines[i] + if l.QuantityFulfilled < b.Quantity { + return res, fmt.Errorf("order %s: line %s was fulfilled %s, the void returns %s", cur.ID, l.ID, l.QuantityFulfilled.DecimalString(), b.Quantity.DecimalString()) + } + l.QuantityFulfilled -= b.Quantity + scope[l.ID] += int64(b.Quantity) + } + + // Step 6: the billed stock back on hand, then allocated again. + if s.inventory != nil { + for _, i := range stockedIndexes(cur.Lines) { + l := &cur.Lines[i] + if q := scope[l.ID]; q > 0 { + if err := s.inventory.RestockQty(ctx, *l.ProductID, cur.BranchID, httpx.Quantity(q)); err != nil { + return res, err + } + } + } + if err := s.allocateReturned(ctx, cur, scope); err != nil { + return res, err + } + } + + from := cur.Status + next := from + switch from { + case StatusConfirmed, StatusBackordered, StatusFulfilled: + next = deriveStatus(cur) + } + cur.Status = next + if err := s.repo.SaveLineQuantities(ctx, cur.Lines); err != nil { + return res, err + } + if err := s.repo.SaveTransition(ctx, cur); err != nil { // moves the revision + return res, err + } + updated, err := s.repo.GetOrder(ctx, cur.ID) + if err != nil { + return res, err + } + res.Status = updated.Status.Status() + if from != StatusBackordered && next == StatusBackordered { + ev, err := s.eventFor(updated, EventBackordered, from.Status()) + if err != nil { + return res, err + } + res.Events = append(res.Events, ev) + } + return res, nil +} + +// allocateReturned allocates the returned quantities of the stocked lines in +// (product id, line id) order, min(available, returned) for a plain line and +// whole kits for a kit's components, and records the rest as back ordered. It +// touches no other quantity: a line's closed remainder (after a close short) +// stays closed, and an older back order is neither served nor lost. +func (s *Service) allocateReturned(ctx context.Context, cur *Order, scope map[uuid.UUID]int64) error { + var order []int + for _, i := range stockedIndexes(cur.Lines) { + if scope[cur.Lines[i].ID] > 0 { + order = append(order, i) + } + } + if len(order) == 0 { + return nil + } + avail := map[uuid.UUID]int64{} + var products []uuid.UUID + for _, i := range order { + p := *cur.Lines[i].ProductID + if _, seen := avail[p]; !seen { + a, err := s.inventory.AvailableQty(ctx, p, cur.BranchID) + if err != nil { + return err + } + avail[p] = int64(a) + products = append(products, p) + } + } + sort.Slice(products, func(a, b int) bool { return products[a].String() < products[b].String() }) + + kitOf := map[uuid.UUID][]int{} + kitIdx := map[uuid.UUID]int{} + for i := range cur.Lines { + l := &cur.Lines[i] + if l.LineType == salesdoc.LineKit { + kitIdx[l.ID] = i + } + if l.LineType == salesdoc.LineComponent && l.ParentLineID != nil { + kitOf[*l.ParentLineID] = append(kitOf[*l.ParentLineID], i) + } + } + take := map[int]int64{} + kitDone := map[uuid.UUID]bool{} + for _, i := range order { + l := &cur.Lines[i] + if l.LineType == salesdoc.LineComponent && l.ParentLineID != nil { + kitID := *l.ParentLineID + if kitDone[kitID] { + continue + } + kitDone[kitID] = true + ki, ok := kitIdx[kitID] + if !ok { + continue + } + // the whole kits the void returned + limit := new(big.Int).Div(big.NewInt(scope[kitID]), big.NewInt(int64(salesdoc.One))) + planKit(cur.Lines, ki, kitOf[kitID], avail, take, limit) + continue + } + t := scope[l.ID] + if a := avail[*l.ProductID]; a < t { + t = a + } + if t > 0 { + take[i] = t + avail[*l.ProductID] -= t + } + } + for _, i := range order { + l := &cur.Lines[i] + got := int64(0) + if t := take[i]; t > 0 { + g, err := s.inventory.AllocateQty(ctx, *l.ProductID, cur.BranchID, httpx.Quantity(t)) + if err != nil { + return err + } + if int64(g) != t { + return fmt.Errorf("inventory allocated %d of the %d planned for line %s: the rows were not held", g, t, l.ID) + } + l.QuantityAllocated += g + got = int64(g) + } + l.QuantityBackordered += httpx.Quantity(scope[l.ID] - got) + } + return nil +} + +// eventFor builds the order's event for the caller to write. +func (s *Service) eventFor(o *Order, eventType, fromStatus string) (outbox.Event, error) { + data := map[string]any{ + "number": o.Number, "customer_id": o.CustomerID, "status": o.Status.Status(), "revision": o.Revision, + "currency": o.Currency, "total_cents": int64(o.TotalCents), + } + if fromStatus != "" { + data["from_status"] = fromStatus + } + raw, err := jsonMarshal(data) + if err != nil { + return outbox.Event{}, err + } + branch := o.BranchID + return outbox.Event{Type: eventType, EntityType: "order", EntityID: o.ID, BranchID: &branch, Data: raw}, nil +} diff --git a/core/internal/order/model.go b/core/internal/order/model.go index 5e4bd06c..fd90cc80 100644 --- a/core/internal/order/model.go +++ b/core/internal/order/model.go @@ -79,21 +79,9 @@ const ( // MarshalText writes the lowercase wire name. func (h HoldReason) MarshalText() ([]byte, error) { return []byte(strings.ToLower(string(h))), nil } -// ShipToSnapshot is the address captured at confirm (ADR 0005 section 5.1), -// stored as JSONB on the order and read back as the wire's ship_to object. -type ShipToSnapshot struct { - ID uuid.UUID `json:"id"` - Code string `json:"code"` - Name string `json:"name"` - Line1 string `json:"line1"` - Line2 *string `json:"line2"` - City string `json:"city"` - Region string `json:"region"` - PostalCode string `json:"postal_code"` - Country *string `json:"country"` - Phone *string `json:"phone"` - DeliveryInstructions *string `json:"delivery_instructions"` -} +// ShipToSnapshot is the address captured at confirm (ADR 0005 section 5.1); +// the invoice copies it, so the type is the shared one. +type ShipToSnapshot = salesdoc.ShipToSnapshot // OrderSummary is the head of an order: a list item, and the summary of the // full document. Money is integer cents; optional fields are present as diff --git a/core/internal/order/repository.go b/core/internal/order/repository.go index 51ea9a34..f3f43aba 100644 --- a/core/internal/order/repository.go +++ b/core/internal/order/repository.go @@ -233,22 +233,7 @@ func (r *PostgresRepository) scanSummary(row pgx.Row, s *OrderSummary, extra ... // RateToPercent widens a stored rate ("0.088750") to the wire's percent // string ("8.875"): the rate is scale 6, the percent at most 4 fraction // digits, the shift exact. -func RateToPercent(rate string) (string, error) { - scaled, _, err := salesdoc.ParseTaxRate(rate) - if err != nil { - return "", err - } - // percent = rate x 100, so the percent's scale 4 integer IS the rate's - // scale 6 integer: 0.088750 is 88750 at both (8.8750 percent). - out := trimFixed(fmt.Sprintf("%d.%04d", scaled/10000, abs64(scaled)%10000)) - if out == "" { - out = "0" - } - if scaled < 0 && out != "0" { - out = "-" + out - } - return out, nil -} +func RateToPercent(rate string) (string, error) { return salesdoc.RateToPercent(rate) } // PercentToRate narrows a wire percent ("8.875") to the stored rate string // ("8.875000" percent = 0.08875). The percent carries at most 4 fraction @@ -966,13 +951,13 @@ func (r *PostgresRepository) OpenReceivableCents(ctx context.Context, customerID var open int64 // The open receivable: each open invoice's total less the payments // recorded against it (in C2-2 and C2-3 the sum over the customer's - // invoices in UNPAID, PARTIAL or OVERDUE). + // invoices in UNPAID or PARTIAL; OVERDUE is no longer a status). err := r.db.GetExecutor(ctx).QueryRow(ctx, ` SELECT COALESCE(SUM(ROUND(i.total_amount * 100)::bigint - COALESCE((SELECT SUM(ROUND(p.amount * 100)::bigint) FROM payments p WHERE p.invoice_id = i.id), 0)), 0) FROM invoices i - WHERE i.customer_id = $1 AND i.status IN ('UNPAID', 'PARTIAL', 'OVERDUE')`, customerID).Scan(&open) + WHERE i.customer_id = $1 AND i.status IN ('UNPAID', 'PARTIAL')`, customerID).Scan(&open) if err != nil { return 0, fmt.Errorf("failed to sum the open receivable: %w", err) } @@ -1151,7 +1136,8 @@ func (r *PostgresRepository) NonStockReceiptsFor(ctx context.Context, orderLineI err := r.db.GetExecutor(ctx).QueryRow(ctx, ` SELECT COALESCE(SUM(ROUND(qty_received * cost, 2) * 100), 0)::bigint, COALESCE(SUM(ROUND(qty_received * 10000)), 0)::bigint, - (SELECT COALESCE(SUM(ROUND(il.cost * 100)), 0)::bigint FROM invoice_lines il WHERE il.order_line_id = $1) + (SELECT COALESCE(SUM(ROUND(il.cost * 100)), 0)::bigint FROM invoice_lines il + JOIN invoices iv ON iv.id = il.invoice_id AND iv.status <> 'VOID' WHERE il.order_line_id = $1) FROM purchase_order_lines WHERE linked_so_line_id = $1 AND COALESCE(qty_received, 0) > 0`, orderLineID).Scan(&posted, &received, &relieved) if err != nil { diff --git a/core/internal/order/tax_provider_test.go b/core/internal/order/tax_provider_test.go index 5b6e22b8..0289abe1 100644 --- a/core/internal/order/tax_provider_test.go +++ b/core/internal/order/tax_provider_test.go @@ -181,9 +181,9 @@ func TestOrderCreditExposureCountsOverdueInvoices(t *testing.T) { } seedInvoice := func(status string, total float64) { t.Helper() - _, err := db.Pool.Exec(ctx, `INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_amount, due_date, payment_terms) + _, err := db.Pool.Exec(ctx, `INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_amount, due_date) VALUES (gen_random_uuid(), (SELECT id FROM orders WHERE customer_id = $1 LIMIT 1), $1, - (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'), $2, $3, $3, 0, CURRENT_DATE - 40, 'NET30')`, + (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'), $2, $3, $3, 0, CURRENT_DATE - 40)`, f.customerID, status, total) if err != nil { t.Fatal(err) diff --git a/core/internal/order/wire_test.go b/core/internal/order/wire_test.go index a953c150..76511277 100644 --- a/core/internal/order/wire_test.go +++ b/core/internal/order/wire_test.go @@ -939,8 +939,8 @@ func TestOrderTransitionsTable(t *testing.T) { } // The reopen is refused once the order is billed: confirm it, bill it, // then try to walk it back to draft. - if _, err := db.Pool.Exec(ctx, `INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_amount, due_date, payment_terms) - VALUES (gen_random_uuid(), $1, $2, (SELECT branch_id FROM orders WHERE id = $1), 'UNPAID', 100, 100, 0, CURRENT_DATE + 30, 'NET30')`, + if _, err := db.Pool.Exec(ctx, `INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_amount, due_date) + VALUES (gen_random_uuid(), $1, $2, (SELECT branch_id FROM orders WHERE id = $1), 'UNPAID', 100, 100, 0, CURRENT_DATE + 30)`, str(t, other.body, "id"), f.customerID); err != nil { t.Fatal(err) } diff --git a/core/internal/payment/handler.go b/core/internal/payment/handler.go index 11042f4d..0a183d6b 100644 --- a/core/internal/payment/handler.go +++ b/core/internal/payment/handler.go @@ -5,6 +5,7 @@ package payment import ( "encoding/json" + "errors" "net/http" "github.com/gablelbm/gable/pkg/httputil" @@ -49,6 +50,10 @@ func (h *Handler) CreatePayment(w http.ResponseWriter, r *http.Request) { payment, err := h.service.ProcessPayment(r.Context(), req.InvoiceID, req.Amount, req.Method, req.Reference, req.Notes) if err != nil { + if errors.Is(err, ErrInvoiceVoid) { + httputil.RespondError(w, r, err.Error(), http.StatusConflict, err) + return + } httputil.RespondError(w, r, "payment processing failed", http.StatusInternalServerError, err) return } @@ -96,6 +101,10 @@ func (h *Handler) ProcessCardPayment(w http.ResponseWriter, r *http.Request) { payment, err := h.service.ProcessCardPayment(r.Context(), req.InvoiceID, req.TokenID, req.Amount, req.Notes) if err != nil { + if errors.Is(err, ErrInvoiceVoid) { + httputil.RespondError(w, r, err.Error(), http.StatusConflict, err) + return + } httputil.RespondError(w, r, "card payment failed", http.StatusPaymentRequired, err) return } diff --git a/core/internal/payment/service.go b/core/internal/payment/service.go index 9149e413..0566d2bf 100644 --- a/core/internal/payment/service.go +++ b/core/internal/payment/service.go @@ -5,12 +5,14 @@ package payment import ( "context" + "errors" "fmt" "log/slog" "time" "github.com/gablelbm/gable/internal/account" "github.com/gablelbm/gable/internal/invoice" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" "github.com/google/uuid" @@ -78,6 +80,32 @@ func (s *Service) GetPublicKey() string { return s.publicKey } +// ErrInvoiceVoid is the refusal to record a payment against a void invoice. +var ErrInvoiceVoid = errors.New("the invoice is void: it takes no payment") + +// lockInvoice reads the invoice under its row lock (ADR 0005 section 11, +// step 4) so a payment and an invoice void serialize: the void checks for +// payments under the same lock, and a payment never lands on a void invoice. +// C2-4 moves this into the AR core with the rest of the payment acts. +func (s *Service) lockInvoice(ctx context.Context, id uuid.UUID) (*invoice.Invoice, error) { + var inv *invoice.Invoice + var err error + if l, ok := s.invoiceRepo.(interface { + LockInvoice(ctx context.Context, id uuid.UUID) (*invoice.Invoice, error) + }); ok { + inv, err = l.LockInvoice(ctx, id) + } else { + inv, err = s.invoiceRepo.GetInvoice(ctx, id) + } + if err != nil { + return nil, err + } + if inv.Status == invoice.InvoiceStatusVoid { + return nil, ErrInvoiceVoid + } + return inv, nil +} + // ProcessPayment handles cash, check, and account payments (non-gateway). func (s *Service) ProcessPayment(ctx context.Context, invoiceID uuid.UUID, amountCents int64, method PaymentMethod, ref, notes string) (*Payment, error) { if amountCents <= 0 { @@ -87,7 +115,10 @@ func (s *Service) ProcessPayment(ctx context.Context, invoiceID uuid.UUID, amoun var p *Payment err := s.db.RunInTx(ctx, func(ctx context.Context) error { - inv, err := s.invoiceRepo.GetInvoice(ctx, invoiceID) + inv, err := s.lockInvoice(ctx, invoiceID) + if errors.Is(err, ErrInvoiceVoid) { + return err + } if err != nil { return fmt.Errorf("invoice not found: %w", err) } @@ -149,6 +180,12 @@ func (s *Service) ProcessCardPayment(ctx context.Context, invoiceID uuid.UUID, t return nil, fmt.Errorf("payment gateway not configured — set RUN_PAYMENTS_API_KEY") } + // A void invoice takes no payment: refuse before the card is charged (the + // locked read inside the transaction below repeats the check). + if pre, err := s.invoiceRepo.GetInvoice(ctx, invoiceID); err == nil && pre.Status == invoice.InvoiceStatusVoid { + return nil, ErrInvoiceVoid + } + // 1. Charge through Run Payments result, err := s.gateway.Charge(ctx, ChargeRequest{ TokenID: tokenID, @@ -171,7 +208,10 @@ func (s *Service) ProcessCardPayment(ctx context.Context, invoiceID uuid.UUID, t // 2. Record payment in our DB within a transaction var p *Payment err = s.db.RunInTx(ctx, func(ctx context.Context) error { - inv, err := s.invoiceRepo.GetInvoice(ctx, invoiceID) + inv, err := s.lockInvoice(ctx, invoiceID) + if errors.Is(err, ErrInvoiceVoid) { + return err + } if err != nil { return fmt.Errorf("invoice not found: %w", err) } @@ -347,10 +387,10 @@ func (s *Service) updateInvoiceStatus(ctx context.Context, invoiceID uuid.UUID, totalPaid += pay.Amount } - if totalPaid >= inv.TotalAmount { + if totalPaid >= int64(inv.TotalCents) { inv.Status = invoice.InvoiceStatusPaid if inv.PaidAt == nil { - now := time.Now() + now := httpx.TimestampOf(time.Now()) inv.PaidAt = &now } } else if totalPaid > 0 { @@ -367,7 +407,7 @@ func (s *Service) updateInvoiceStatus(ctx context.Context, invoiceID uuid.UUID, // Notify FB Brain's financial engine when an invoice is fully paid. if inv.Status == invoice.InvoiceStatusPaid && s.brainNotifier != nil { - s.brainNotifier.notifyInvoicePaid(s.brainOrgID, inv.ID, inv.TotalAmount) + s.brainNotifier.notifyInvoicePaid(s.brainOrgID, inv.ID, int64(inv.TotalCents)) } return nil diff --git a/core/internal/payment/service_audit_tx_test.go b/core/internal/payment/service_audit_tx_test.go index cd1bd5b9..b70a1a31 100644 --- a/core/internal/payment/service_audit_tx_test.go +++ b/core/internal/payment/service_audit_tx_test.go @@ -51,10 +51,10 @@ func TestProcessPaymentAuditRollsBackWithFailedMutation(t *testing.T) { t.Fatalf("insert product: %v", err) } - inv := &invoice.Invoice{ + inv := &invoice.LegacyInvoice{ ID: uuid.New(), CustomerID: custID, - Lines: []invoice.InvoiceLine{{ProductID: productID, Quantity: 2, PriceEach: 5000}}, + Lines: []invoice.LegacyLine{{ProductID: productID, Quantity: 2, PriceEach: 5000}}, } if err := invoiceSvc.CreateInvoice(ctx, inv); err != nil { t.Fatalf("create invoice: %v", err) @@ -122,7 +122,7 @@ func (approvingGateway) Refund(_ context.Context, _ string, _ int64) (*payment.G // cardPaymentFixture stands up the real invoice/account/payment stack with an // approving gateway and one unpaid invoice, the shared setup of the card-path // audit tests. -func cardPaymentFixture(t *testing.T) (*payment.Service, *invoice.Invoice, *database.DB) { +func cardPaymentFixture(t *testing.T) (*payment.Service, *invoice.LegacyInvoice, *database.DB) { t.Helper() db := testutil.RequireDB(t) ctx := context.Background() @@ -152,10 +152,10 @@ func cardPaymentFixture(t *testing.T) (*payment.Service, *invoice.Invoice, *data // The invoice is created through its own service so its GL and account // side effects exist exactly as in production. invoiceSvc := invoice.NewService(invoice.NewRepository(db), glSvc, accountSvc, db) - inv := &invoice.Invoice{ + inv := &invoice.LegacyInvoice{ ID: uuid.New(), CustomerID: custID, - Lines: []invoice.InvoiceLine{{ProductID: productID, Quantity: 2, PriceEach: 5000}}, + Lines: []invoice.LegacyLine{{ProductID: productID, Quantity: 2, PriceEach: 5000}}, } if err := invoiceSvc.CreateInvoice(ctx, inv); err != nil { t.Fatalf("create invoice: %v", err) diff --git a/core/internal/portal/model.go b/core/internal/portal/model.go index b3eb7980..56e44f8f 100644 --- a/core/internal/portal/model.go +++ b/core/internal/portal/model.go @@ -137,6 +137,8 @@ type PortalLineDTO struct { // TODO: align with int64 cents — TotalAmount, Subtotal, TaxAmount are float64 dollars type PortalInvoiceDTO struct { ID uuid.UUID `json:"id"` + Number string `json:"number"` + IsOverdue bool `json:"is_overdue"` OrderID uuid.UUID `json:"order_id"` Status string `json:"status"` TotalAmount float64 `json:"total_amount"` diff --git a/core/internal/portal/repository.go b/core/internal/portal/repository.go index dd4f5e6f..09ed32f8 100644 --- a/core/internal/portal/repository.go +++ b/core/internal/portal/repository.go @@ -143,7 +143,7 @@ func (r *PostgresRepository) GetPortalConfig(ctx context.Context) (*PortalConfig // GetCustomerARSummary fetches balance, credit limit, and past-due amount. // -// Balance is computed live from the invoices table (sum of all UNPAID/OVERDUE +// Balance is computed live from the invoices table (sum of all UNPAID/PARTIAL // invoice totals) rather than read from the denormalized customers.balance_due // column. The stored column is not maintained by the seed pipeline or the // invoice write paths, so reading it produced stale zeros while past_due @@ -166,7 +166,7 @@ func (r *PostgresRepository) GetCustomerARSummary(ctx context.Context, customerI SELECT COALESCE(SUM(total_amount), 0)::float8 FROM invoices WHERE customer_id = $1 - AND status IN ('UNPAID', 'PARTIAL', 'OVERDUE') + AND status IN ('UNPAID', 'PARTIAL') ` err = r.db.GetExecutor(ctx).QueryRow(ctx, balanceQuery, customerID).Scan(&balance) if err != nil { @@ -178,8 +178,8 @@ func (r *PostgresRepository) GetCustomerARSummary(ctx context.Context, customerI SELECT COALESCE(SUM(total_amount), 0)::float8 FROM invoices WHERE customer_id = $1 - AND status IN ('UNPAID', 'OVERDUE') - AND due_date < NOW() + AND status IN ('UNPAID', 'PARTIAL') + AND due_date < CURRENT_DATE ` err = r.db.GetExecutor(ctx).QueryRow(ctx, pastDueQuery, customerID).Scan(&pastDue) if err != nil { @@ -259,10 +259,12 @@ func (r *PostgresRepository) GetOrderByIDAndCustomer(ctx context.Context, orderI // ListInvoicesByCustomer fetches invoices for a customer. func (r *PostgresRepository) ListInvoicesByCustomer(ctx context.Context, customerID uuid.UUID) ([]PortalInvoiceDTO, error) { query := ` - SELECT id, order_id, status, total_amount, subtotal, tax_amount, payment_terms, due_date, paid_at, created_at - FROM invoices - WHERE customer_id = $1 - ORDER BY created_at DESC + SELECT i.id, i.order_id, i.status, i.total_amount, i.subtotal, i.tax_amount, + (SELECT pt.code FROM payment_terms pt WHERE pt.id = i.payment_terms_id), i.due_date, i.paid_at, i.created_at, + i.number, (i.status IN ('UNPAID', 'PARTIAL') AND i.due_date < CURRENT_DATE) + FROM invoices i + WHERE i.customer_id = $1 + ORDER BY i.created_at DESC LIMIT 50 ` rows, err := r.db.GetExecutor(ctx).Query(ctx, query, customerID) @@ -277,7 +279,7 @@ func (r *PostgresRepository) ListInvoicesByCustomer(ctx context.Context, custome if err := rows.Scan( &inv.ID, &inv.OrderID, &inv.Status, &inv.TotalAmount, &inv.Subtotal, &inv.TaxAmount, &inv.PaymentTerms, - &inv.DueDate, &inv.PaidAt, &inv.CreatedAt, + &inv.DueDate, &inv.PaidAt, &inv.CreatedAt, &inv.Number, &inv.IsOverdue, ); err != nil { return nil, fmt.Errorf("failed to scan invoice: %w", err) } @@ -294,15 +296,17 @@ func (r *PostgresRepository) ListInvoicesByCustomer(ctx context.Context, custome // GetInvoiceByIDAndCustomer fetches a single invoice scoped to a customer. func (r *PostgresRepository) GetInvoiceByIDAndCustomer(ctx context.Context, invoiceID, customerID uuid.UUID) (*PortalInvoiceDTO, error) { query := ` - SELECT id, order_id, status, total_amount, subtotal, tax_amount, payment_terms, due_date, paid_at, created_at - FROM invoices - WHERE id = $1 AND customer_id = $2 + SELECT i.id, i.order_id, i.status, i.total_amount, i.subtotal, i.tax_amount, + (SELECT pt.code FROM payment_terms pt WHERE pt.id = i.payment_terms_id), i.due_date, i.paid_at, i.created_at, + i.number, (i.status IN ('UNPAID', 'PARTIAL') AND i.due_date < CURRENT_DATE) + FROM invoices i + WHERE i.id = $1 AND i.customer_id = $2 ` var inv PortalInvoiceDTO err := r.db.GetExecutor(ctx).QueryRow(ctx, query, invoiceID, customerID).Scan( &inv.ID, &inv.OrderID, &inv.Status, &inv.TotalAmount, &inv.Subtotal, &inv.TaxAmount, &inv.PaymentTerms, - &inv.DueDate, &inv.PaidAt, &inv.CreatedAt, + &inv.DueDate, &inv.PaidAt, &inv.CreatedAt, &inv.Number, &inv.IsOverdue, ) if err != nil { if err == pgx.ErrNoRows { diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index 93e42a38..d1f1d057 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -410,16 +410,15 @@ func (s *Service) CompleteTransaction(ctx context.Context, txID uuid.UUID, tende // cart lines; a split sale gets a single summary line (the tax math on a // partial allocation is a per-line proration problem deferred until a // dealer needs split account tenders). -func (s *Service) buildAccountInvoice(tx *POSTransaction, accountPortionCents int64) *invoice.Invoice { - inv := &invoice.Invoice{ - BranchID: tx.BranchID, - CustomerID: *tx.CustomerID, - Status: invoice.InvoiceStatusUnpaid, - PaymentTerms: invoice.TermsNet30, +func (s *Service) buildAccountInvoice(tx *POSTransaction, accountPortionCents int64) *invoice.LegacyInvoice { + inv := &invoice.LegacyInvoice{ + BranchID: tx.BranchID, + CustomerID: *tx.CustomerID, + Status: invoice.InvoiceStatusUnpaid, } if accountPortionCents >= tx.Total && len(tx.LineItems) > 0 { for _, item := range tx.LineItems { - inv.Lines = append(inv.Lines, invoice.InvoiceLine{ + inv.Lines = append(inv.Lines, invoice.LegacyLine{ ProductID: item.ProductID, Quantity: item.Quantity, PriceEach: item.UnitPrice, @@ -434,7 +433,7 @@ func (s *Service) buildAccountInvoice(tx *POSTransaction, accountPortionCents in } else { // Split tender: one summary line for the on-account portion, // tax-inclusive (the register already charged the tax). - inv.Lines = []invoice.InvoiceLine{{ + inv.Lines = []invoice.LegacyLine{{ ProductID: tx.LineItems[0].ProductID, Quantity: 1, PriceEach: accountPortionCents, diff --git a/core/internal/reporting/repository.go b/core/internal/reporting/repository.go index 57e5aab5..4818f735 100644 --- a/core/internal/reporting/repository.go +++ b/core/internal/reporting/repository.go @@ -120,7 +120,7 @@ func (r *PostgresRepository) GetSalesSummary(ctx context.Context, start, end tim queryOutstanding := ` SELECT COALESCE(SUM(total_amount), 0) FROM invoices - WHERE created_at >= $1 AND created_at < $2 AND status IN ('UNPAID', 'PARTIAL', 'OVERDUE') + WHERE created_at >= $1 AND created_at < $2 AND status IN ('UNPAID', 'PARTIAL') ` // Note: accurate AR needs to subtract partial payments. // Let's refine: Total Invoiced in Period - Total Payments applied to *those* invoices (even if payment is outside period? Mixed bag). @@ -155,7 +155,7 @@ func (r *PostgresRepository) GetARAgingReport(ctx context.Context) (*ARAgingRepo COALESCE(SUM(i.total_amount), 0) AS total FROM invoices i LEFT JOIN customers c ON c.id = i.customer_id - WHERE i.status IN ('UNPAID', 'PARTIAL', 'OVERDUE') + WHERE i.status IN ('UNPAID', 'PARTIAL') GROUP BY i.customer_id, c.name ORDER BY total DESC ` diff --git a/core/internal/salesdoc/billing.go b/core/internal/salesdoc/billing.go index e43b6030..426953c9 100644 --- a/core/internal/salesdoc/billing.go +++ b/core/internal/salesdoc/billing.go @@ -71,6 +71,40 @@ func BilledTotal(l *Line, before, after httpx.Quantity) (httpx.Cents, error) { return hi - lo, nil } +// BilledDiscount is the amount discount a billed piece of a line gave, in +// cents: the gross extension of the piece (Extend over the cumulative +// quantities, the telescoping difference like BilledTotal) less the piece's +// net. Defining the share as gross less net, rather than prorating the +// discount on its own, makes every invoice line satisfy line total + discount +// = its gross piece to the cent, and the pieces of a line sum to the order +// line's whole discount exactly (the carried C2-2b item). +func BilledDiscount(l *Line, before, after httpx.Quantity) (httpx.Cents, error) { + if l.DiscountAmount == nil { + return 0, nil + } + uq, pq := derefQty(l.UOMQty), derefQty(l.PriceUOMQty) + price := derefPrice(l.UnitPrice) + gross := func(q httpx.Quantity) (httpx.Cents, error) { + if q <= 0 { + return 0, nil + } + return httpx.Extend(q, uq, pq, price) + } + hi, err := gross(after) + if err != nil { + return 0, err + } + lo, err := gross(before) + if err != nil { + return 0, err + } + net, err := BilledTotal(l, before, after) + if err != nil { + return 0, err + } + return (hi - lo) - net, nil +} + // RevenueAccountProduct is the account product, kit and non stock lines post // their totals to (ADR 0005 section 8.3). const RevenueAccountProduct = "4010" diff --git a/core/internal/salesdoc/rate.go b/core/internal/salesdoc/rate.go new file mode 100644 index 00000000..e4c3782d --- /dev/null +++ b/core/internal/salesdoc/rate.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package salesdoc + +import ( + "fmt" + "strings" + + "github.com/google/uuid" +) + +// RateToPercent widens a stored rate ("0.088750") to the wire's percent +// string ("8.875"): the rate is scale 6, the percent at most 4 fraction +// digits, the shift exact. Orders, invoices and credit memos all read their +// rate this way. +func RateToPercent(rate string) (string, error) { + scaled, _, err := ParseTaxRate(rate) + if err != nil { + return "", err + } + // percent = rate x 100, so the percent's scale 4 integer IS the rate's + // scale 6 integer: 0.088750 is 88750 at both (8.8750 percent). + abs := scaled + if abs < 0 { + abs = -abs + } + out := fmt.Sprintf("%d.%04d", abs/10000, abs%10000) + if strings.Contains(out, ".") { + out = strings.TrimSuffix(strings.TrimRight(out, "0"), ".") + } + if out == "" { + out = "0" + } + if scaled < 0 && out != "0" { + out = "-" + out + } + return out, nil +} + +// ShipToSnapshot is the address captured at confirm (ADR 0005 section 5.1), +// stored as JSONB on the order and copied to its invoices, and read back as +// the wire's ship_to object. +type ShipToSnapshot struct { + ID uuid.UUID `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Line1 string `json:"line1"` + Line2 *string `json:"line2"` + City string `json:"city"` + Region string `json:"region"` + PostalCode string `json:"postal_code"` + Country *string `json:"country"` + Phone *string `json:"phone"` + DeliveryInstructions *string `json:"delivery_instructions"` +} diff --git a/core/internal/salesdoc/scan.go b/core/internal/salesdoc/scan.go new file mode 100644 index 00000000..a6fbd048 --- /dev/null +++ b/core/internal/salesdoc/scan.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package salesdoc + +import ( + "time" + + "github.com/gablelbm/gable/internal/platform/httpx" +) + +// LineColumns is the shared projection of a sales document line table, for +// the invoice and credit memo reads: the table aliased `l`, charge_codes +// joined as `cc`. Quantities and prices leave SQL as scale 4 integers and +// amounts as cents (recipe step 6: scaled in SQL, never through float64). +// special is the three expressions for is_special_order, vendor_id and the +// special order cost, which only an order line owns; a table without them +// passes `false, NULL::uuid, NULL::bigint`. +func LineColumns(special string) string { + return `l.id, l.position, l.line_type, l.parent_line_id, l.product_id, l.charge_code_id, cc.code, l.sku, l.description, + ROUND(l.quantity * 10000)::bigint, l.uom, l.price_uom, ROUND(l.uom_qty * 10000)::bigint, ROUND(l.price_uom_qty * 10000)::bigint, + ROUND(l.unit_price * 10000)::bigint, ROUND(l.priced_unit_price * 10000)::bigint, l.price_source, l.override_reason, + ROUND(l.discount_percent * 10000)::bigint, ROUND(l.discount_amount * 100)::bigint, l.discount_reason, l.price_adjusted_by, + ROUND(l.line_total * 100)::bigint, l.taxable, l.revenue_account_code, ` + special + `, l.created_at` +} + +// LineScan holds the nullable scale 4 integers a LineColumns row scans into +// before Finish writes them onto the Line. +type LineScan struct { + quantity, uomQty, priceUOMQty, unitPrice, priced *int64 + discountPct, discountAmt, lineTotal, specialCost *int64 + source string + created time.Time +} + +// Dests are the scan targets for one LineColumns row, in column order. +func (s *LineScan) Dests(l *Line) []any { + return []any{ + &l.ID, &l.Position, &l.LineType, &l.ParentLineID, &l.ProductID, &l.ChargeCodeID, &l.ChargeCode, &l.SKU, &l.Description, + &s.quantity, &l.UOM, &l.PriceUOM, &s.uomQty, &s.priceUOMQty, + &s.unitPrice, &s.priced, &s.source, &l.OverrideReason, + &s.discountPct, &s.discountAmt, &l.DiscountReason, &l.PriceAdjustedBy, + &s.lineTotal, &l.Taxable, &l.RevenueAccountCode, &l.IsSpecialOrder, &l.VendorID, &s.specialCost, &s.created, + } +} + +// Finish moves the scanned integers onto the line's typed fields. +func (s *LineScan) Finish(l *Line) { + l.PriceSource = PriceSource(s.source) + l.Quantity = PtrQuantity(s.quantity) + l.UOMQty, l.PriceUOMQty = PtrQuantity(s.uomQty), PtrQuantity(s.priceUOMQty) + l.UnitPrice, l.PricedUnitPrice = PtrPrice(s.unitPrice), PtrPrice(s.priced) + l.DiscountPercent = PtrQuantity(s.discountPct) + l.DiscountAmount = PtrCents(s.discountAmt) + l.LineTotal = PtrCents(s.lineTotal) + l.SpecialOrderCost = PtrPrice(s.specialCost) + l.CreatedAt = httpx.TimestampOf(s.created) +} + +// PtrQuantity, PtrPrice and PtrCents lift a nullable scaled integer into its +// wire type, nil staying nil. +func PtrQuantity(v *int64) *httpx.Quantity { + if v == nil { + return nil + } + q := httpx.Quantity(*v) + return &q +} + +func PtrPrice(v *int64) *httpx.Price { + if v == nil { + return nil + } + p := httpx.Price(*v) + return &p +} + +func PtrCents(v *int64) *httpx.Cents { + if v == nil { + return nil + } + c := httpx.Cents(*v) + return &c +} From e75fce2c33fbe31844b02e2b1eb4d71919a48b59 Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 13:22:06 -0700 Subject: [PATCH 06/25] invoices and credit memos: contract fragment, goldens, order void interlocks, seed, portal readers, document print wall kept Co-Authored-By: Claude Sonnet 5.5 --- core/api/ROUTES.txt | 8 +- core/api/fragments/accounts.yaml | 2 + core/api/fragments/gl.yaml | 2 +- core/api/fragments/invoice.yaml | 1746 +++++++++++++-- core/api/fragments/portal.yaml | 9 +- core/api/openapi.yaml | 1995 ++++++++++++++--- core/internal/apicontract/model_shape_test.go | 3 + core/internal/app/seed/seed.go | 13 +- .../characterization/fixtures_clock_test.go | 4 +- .../characterization/scenarios_r1b_b_test.go | 6 +- .../characterization/scenarios_sales_test.go | 61 +- .../testdata/goldens/account.json | 28 +- .../testdata/goldens/clockwindow.json | 16 +- .../testdata/goldens/document_invoice.json | 4 +- .../testdata/goldens/gl_accounts_entries.json | 66 +- .../testdata/goldens/invoice.json | 1155 +++++++++- .../testdata/goldens/portal_billing.json | 107 +- .../testdata/goldens/reporting_reports.json | 38 +- core/internal/document/handler.go | 4 +- core/internal/invoice/repository.go | 29 +- core/internal/invoice/repository_cm.go | 2 +- core/internal/invoice/service.go | 12 + core/internal/order/tax_provider_test.go | 17 +- 23 files changed, 4619 insertions(+), 708 deletions(-) diff --git a/core/api/ROUTES.txt b/core/api/ROUTES.txt index fda68d8a..bd713cab 100644 --- a/core/api/ROUTES.txt +++ b/core/api/ROUTES.txt @@ -131,7 +131,11 @@ POST /api/v1/configurator/validate internal/configurator h.handleValidate DELETE /api/v1/contacts/{id} internal/customer h.HandleDeleteContact GET /api/v1/contacts/{id} internal/customer h.HandleGetContact PUT /api/v1/contacts/{id} internal/customer h.HandleUpdateContact -GET /api/v1/credit-memos/{customerId} internal/invoice h.HandleListCreditMemos +GET /api/v1/credit-memos internal/invoice h.HandleListCreditMemos +POST /api/v1/credit-memos internal/invoice h.HandleCreateCreditMemo +GET /api/v1/credit-memos/{id} internal/invoice h.HandleGetCreditMemo +PUT /api/v1/credit-memos/{id} internal/invoice h.HandleUpdateCreditMemo +POST /api/v1/credit-memos/{id}/transitions internal/invoice h.HandleCreditTransition GET /api/v1/customers internal/customer h.HandleListCustomers POST /api/v1/customers internal/customer h.HandleCreateCustomer GET /api/v1/customers/{customerId}/activities internal/crm h.HandleListActivities @@ -213,9 +217,9 @@ POST /api/v1/inventory/adjust internal/inventory h.AdjustStock POST /api/v1/inventory/transfer internal/inventory h.MoveStock GET /api/v1/invoices internal/invoice h.HandleList GET /api/v1/invoices/{id} internal/invoice h.HandleGet -POST /api/v1/invoices/{id}/credit-memo internal/invoice h.HandleCreateCreditMemo POST /api/v1/invoices/{id}/email internal/document h.HandleEmailInvoice GET /api/v1/invoices/{id}/payments internal/payment h.GetPaymentHistory +POST /api/v1/invoices/{id}/transitions internal/invoice h.HandleTransition GET /api/v1/locations internal/location list POST /api/v1/locations internal/location create DELETE /api/v1/locations/{id} internal/location h.DeleteLocation diff --git a/core/api/fragments/accounts.yaml b/core/api/fragments/accounts.yaml index e2de5e71..6cb06161 100644 --- a/core/api/fragments/accounts.yaml +++ b/core/api/fragments/accounts.yaml @@ -121,6 +121,8 @@ components: - PAYMENT - ADJUSTMENT - REFUND + - CREDIT_MEMO + - REVERSAL amount: type: integer format: int64 diff --git a/core/api/fragments/gl.yaml b/core/api/fragments/gl.yaml index f0bc1ad9..83a8d466 100644 --- a/core/api/fragments/gl.yaml +++ b/core/api/fragments/gl.yaml @@ -607,7 +607,7 @@ components: type: string source: type: string - description: MANUAL, INVOICE, PAYMENT, ADJUSTMENT, CLOSING, VENDOR_INVOICE, VENDOR_PAYMENT, REVERSAL, RETURN or DEPOSIT. + description: MANUAL, INVOICE, PAYMENT, ADJUSTMENT, CLOSING, VENDOR_INVOICE, VENDOR_PAYMENT, REVERSAL, RETURN, DEPOSIT, CREDIT_MEMO or WRITE_OFF. source_ref_id: type: string format: uuid diff --git a/core/api/fragments/invoice.yaml b/core/api/fragments/invoice.yaml index e649c067..3d0ab6bd 100644 --- a/core/api/fragments/invoice.yaml +++ b/core/api/fragments/invoice.yaml @@ -1,201 +1,707 @@ # SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 # SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors # -# The invoice module, transcribed from internal/invoice/handler.go and -# model.go as they behave today: cents money, an UPPERCASE status -# vocabulary, due_date and paid_at always present and null while unset, -# and credit memo lists as a bare nullable array. +# The invoice and credit memo modules on the wire contract (ADR 0001; ADR 0005 +# sections 4, 6 and 8, item C2-3): the cursor list envelope with working +# filters, the one error envelope, a lowercase status with the transitions +# route (an invoice is only voided by a client; its other statuses are derived +# from payments and credit memos), integer _cents money, the scaled unit price, +# quantities and the conversion pair as decimal strings, the shared sales line +# shape of ADR 0005 section 2 on invoice and credit memo lines, a gapless +# document number and a revision with its ETag, the job and ship-to the invoice +# carries, the tax with its source, and the computed is_overdue flag in place of +# the OVERDUE status. The document print and email routes keep their paths. paths: /api/v1/invoices: get: operationId: invoiceList - tags: [invoice] + tags: + - invoice summary: List invoices + description: The cursor list envelope, newest first (created_at, then id). status filters on the lowercase + lifecycle vocabulary (a comma separated list); customer_id, job_id, ship_to_id and order_id filter on their + document; overdue=true lists the open invoices past their due date and overdue=false the rest. total appears + only under include=total. A parameter the route does not declare, a status outside the vocabulary (overdue + is not one), a malformed cursor or an out of range limit is a 400. parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - $ref: "#/components/parameters/XBranchId" + - name: status + in: query + required: false + description: Comma separated lowercase statuses (unpaid, partial, paid, void, written_off). + schema: + type: string + - name: customer_id + in: query + required: false + description: Filter on the customer. + schema: + type: string + format: uuid + - name: job_id + in: query + required: false + description: Filter on the job (project). + schema: + type: string + format: uuid + - name: ship_to_id + in: query + required: false + description: Filter on the ship-to. + schema: + type: string + format: uuid + - name: order_id + in: query + required: false + description: Filter on the order. + schema: + type: string + format: uuid + - name: overdue + in: query + required: false + description: 'true: open and past due; false: everything else.' + schema: + type: string + enum: + - 'true' + - 'false' + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Include' + - $ref: '#/components/parameters/XBranchId' responses: - "200": + '200': description: The page of invoices. content: application/json: schema: - $ref: "#/components/schemas/InvoicePage" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/InvoicePage' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': &id002 + $ref: '#/components/responses/Unauthorized' + '403': &id003 + $ref: '#/components/responses/ForbiddenEither' + '500': &id004 + $ref: '#/components/responses/InternalError' /api/v1/invoices/{id}: get: operationId: invoiceGet - tags: [invoice] + tags: + - invoice summary: Get one invoice with lines + description: The invoice with every line (id, description, quantity, unit price, line total, cost), its status + and open amount, and its ETag. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: "#/components/parameters/XBranchId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/XBranchId' responses: - "200": + '200': description: The invoice. + headers: &id001 + ETag: &id005 + description: The document's revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/Invoice" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "404": - $ref: "#/components/responses/NotFound" - /api/v1/invoices/{id}/credit-memo: + $ref: '#/components/schemas/Invoice' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/WireNotFound' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + /api/v1/invoices/{id}/transitions: post: - operationId: invoiceCreateCreditMemo - tags: [invoice] - summary: Create and apply a credit memo against an invoice + operationId: invoiceTransition + tags: + - invoice + summary: Void an invoice + description: 'The only client transition is void (roles admin, owner, finance; a reason is required). unpaid, + partial, paid and written_off are derived by payments and credit memos, so asking for one is 409 invalid_state_transition. + A void is refused with the blocker has_applications while a payment is recorded against the invoice or an + applied credit memo names it, and with has_credit_memos while a credit memo that is not void names it. It + runs in one transaction: it locks the invoice''s order row first, reverses the invoice''s whole journal + entry (dated the void date), returns its billed stock to on hand, reduces the order lines'' fulfilled quantities, + re-runs allocation for those quantities and derives the order status; the subledger is credited back. The + number is kept; nothing deletes an invoice. Writes invoice.voided (and order.backordered when the order + cannot re-allocate) last.' parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: "#/components/parameters/XBranchId" - - $ref: "#/components/parameters/IdempotencyKey" + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreditMemoCreateRequest" + $ref: '#/components/schemas/InvoiceTransitionRequest' responses: - "201": - description: The created credit memo. + '200': + description: The voided invoice. + headers: *id001 content: application/json: schema: - $ref: "#/components/schemas/CreditMemo" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/Invoice' + '400': + $ref: '#/components/responses/BadRequestEither' + '404': + $ref: '#/components/responses/WireNotFound' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '428': + $ref: '#/components/responses/WirePreconditionRequired' + '401': *id002 + '403': *id003 + '500': *id004 /api/v1/invoices/{id}/email: post: operationId: invoiceEmail - tags: [invoice] + tags: + - invoice summary: Email invoice to customer - description: >- - Queues an email to the customer with the invoice PDF attached. Returns 202 - Accepted immediately; the email is dispatched asynchronously. Fails if the - customer has no email address on file. + description: Queues an email to the customer with the invoice PDF attached. Returns 202 Accepted immediately; + the email is dispatched asynchronously. Fails if the customer has no email address on file. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: "#/components/parameters/XBranchId" - - $ref: "#/components/parameters/IdempotencyKey" + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IdempotencyKey' responses: - "202": + '202': description: Email queued. content: application/json: schema: - $ref: "#/components/schemas/InvoiceEmailResponse" - "400": - description: >- - Customer has no email address on file (the standard error - envelope), or the Idempotency-Key header is malformed (the - ADR 0001 envelope). + $ref: '#/components/schemas/InvoiceEmailResponse' + '400': + description: Customer has no email address on file (the standard error envelope), or the Idempotency-Key + header is malformed (the ADR 0001 envelope). content: application/json: schema: oneOf: - - $ref: "#/components/schemas/Error" - - $ref: "#/components/schemas/WireError" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "500": - $ref: "#/components/responses/InternalError" - /api/v1/credit-memos/{customerId}: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/WireError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/IdempotencyConflict' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/credit-memos: + get: + operationId: creditMemoList + tags: + - credit-memo + summary: List credit memos + description: The cursor list envelope, newest first. status filters on the lowercase vocabulary (draft, open, + partial, applied, void; a comma separated list); customer_id, invoice_id and job_id filter on their document. + total appears only under include=total. + parameters: + - name: status + in: query + required: false + description: Comma separated lowercase statuses. + schema: + type: string + - name: customer_id + in: query + required: false + description: Filter on the customer. + schema: + type: string + format: uuid + - name: invoice_id + in: query + required: false + description: Filter on the invoice credited. + schema: + type: string + format: uuid + - name: job_id + in: query + required: false + description: Filter on the job. + schema: + type: string + format: uuid + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Include' + - $ref: '#/components/parameters/XBranchId' + responses: + '200': + description: The page of credit memos. + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemoPage' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': *id002 + '403': *id003 + '500': *id004 + post: + operationId: creditMemoCreate + tags: + - credit-memo + summary: Create a draft credit memo + description: 'Creates the credit memo in draft: no number (it is minted at post, so a voided draft consumes + none), lines built and totalled, tax at the invoice''s rate and never more than the invoice charged less + what earlier credit memos credited. A line that names an invoice line cannot credit more than it billed + less what earlier credit memos returned (409 exceeds_billed). The branch is held to the caller''s branch + (403 naming branch_id). Writes credit_memo.created.' + parameters: + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemoRequest' + responses: + '201': + description: The draft credit memo. + headers: + ETag: *id005 + Location: + description: The path of the created document. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemo' + '400': + $ref: '#/components/responses/BadRequestEither' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '401': *id002 + '403': *id003 + '500': *id004 + /api/v1/credit-memos/{id}: get: - operationId: invoiceListCreditMemos - tags: [invoice] - summary: List a customer's credit memos + operationId: creditMemoGet + tags: + - credit-memo + summary: Get one credit memo with lines + description: The credit memo with every line and its ETag. parameters: - - name: customerId - in: path - required: true - schema: - type: string - format: uuid - - $ref: "#/components/parameters/XBranchId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The credit memos, a bare array that is null when the customer has none. + '200': + description: The credit memo. + headers: *id001 content: application/json: schema: - $ref: "#/components/schemas/CreditMemoList" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/CreditMemo' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/WireNotFound' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + put: + operationId: creditMemoUpdate + tags: + - credit-memo + summary: Replace a draft credit memo + description: 'Replaces the header fields and the lines, in draft only: in any other status it is 409 with + the blocker credit_memo_not_draft. The customer and the invoice are fixed at create. The revision precondition + applies (If-Match or the body revision). Writes credit_memo.updated.' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemoRequest' + responses: + '200': + description: The edited credit memo. + headers: *id001 + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemo' + '400': + $ref: '#/components/responses/BadRequestEither' + '404': + $ref: '#/components/responses/WireNotFound' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '428': + $ref: '#/components/responses/WirePreconditionRequired' + '401': *id002 + '403': *id003 + '500': *id004 + /api/v1/credit-memos/{id}/transitions: + post: + operationId: creditMemoTransition + tags: + - credit-memo + summary: Post or void a credit memo + description: 'draft to open posts the credit memo (roles admin, owner, finance): it locks the invoice it names + (409 invoice_void when that invoice is void), recomputes its lines against what was posted, restocks the + lines that restock, mints the gapless CM- number, posts the balanced entry (revenue and tax back, receivable + down, and for a restocked line the cost back to inventory and out of cost of goods sold at the original + cost) and the subledger credit, and writes credit_memo.posted last. draft to void ends a draft and consumes + no number; open to void (roles admin, owner, finance; a reason is required) reverses the entry and the restock + and puts the credit back on the receivable. The statuses past open (partial, applied) arrive with the payment + and AR item and are not reachable here (409 invalid_state_transition). Writes credit_memo.posted or credit_memo.voided.' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemoTransitionRequest' + responses: + '200': + description: The credit memo after the transition. + headers: *id001 + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemo' + '400': + $ref: '#/components/responses/BadRequestEither' + '404': + $ref: '#/components/responses/WireNotFound' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '428': + $ref: '#/components/responses/WirePreconditionRequired' + '401': *id002 + '403': *id003 + '500': *id004 components: schemas: InvoiceStatus: type: string - enum: [UNPAID, PARTIAL, PAID, VOID, OVERDUE] - PaymentTerms: + enum: + - unpaid + - partial + - paid + - void + - written_off + description: 'The lifecycle, lowercase on the wire (ADR 0001 section 6; ADR 0005 section 6.2). overdue is + not a status: it is the is_overdue flag and the overdue list filter.' + CreditMemoStatus: type: string - enum: [COD, DUE_ON_RECEIPT, NET30, NET60, NET90] - Invoice: + enum: + - draft + - open + - partial + - applied + - void + description: The credit memo lifecycle, lowercase on the wire (ADR 0005 section 6.3). + InvoiceSummary: type: object - required: [id, branch_id, order_id, customer_id, status, subtotal, tax_rate, tax_amount, total_amount, payment_terms, due_date, paid_at, created_at, updated_at] + description: An invoice header, used as a list item and as the head of the full document. + required: + - id + - number + - branch_id + - customer_id + - customer_name + - order_id + - job_id + - ship_to_id + - status + - revision + - currency + - origin + - delivery_type + - picked_up_by + - delivery_id + - invoice_date + - due_date + - payment_terms_id + - discount_due_date + - discount_percent + - subtotal_cents + - tax_cents + - tax_rate_percent + - tax_exempt + - tax_source + - total_cents + - open_cents + - is_overdue + - paid_at + - gl_entry_id + - voided_at + - voided_by + - void_reason + - created_at + - updated_at properties: id: type: string format: uuid + number: + type: string + description: The gapless IN- number, minted when the invoice is created (ADR 0005 4.1). A void invoice + keeps it. branch_id: type: string format: uuid + customer_id: + type: string + format: uuid + customer_name: + type: string order_id: + type: + - string + - 'null' + format: uuid + description: The order this invoice bills; null on a counter account charge. + job_id: + type: + - string + - 'null' + format: uuid + description: The job (project) the invoice carries, from its order (ADR 0005 7.1). + ship_to_id: + type: + - string + - 'null' + format: uuid + status: + $ref: '#/components/schemas/InvoiceStatus' + revision: + type: integer + format: int64 + currency: + type: string + pattern: ^[A-Z]{3}$ + description: ISO 4217, copied from the order, never sent. + origin: + type: string + enum: + - order + - pos + delivery_type: + type: string + enum: + - pickup + - delivery + picked_up_by: + type: + - string + - 'null' + description: Who collected a will-call order. + delivery_id: + type: + - string + - 'null' + format: uuid + invoice_date: + type: string + format: date + description: The business date in the branch's local calendar. + due_date: + type: + - string + - 'null' + format: date + description: From the customer's payment terms at create. + payment_terms_id: + type: string + format: uuid + discount_due_date: + type: + - string + - 'null' + format: date + description: The last day the early payment discount applies; null when the terms carry none. + discount_percent: + type: + - string + - 'null' + description: The early payment discount percent at scale 4 as a decimal string. + subtotal_cents: + type: integer + format: int64 + tax_cents: + type: integer + format: int64 + tax_rate_percent: + type: + - string + - 'null' + description: A percent with at most 4 fraction digits ("8.875"); null when the tax provider answered. + tax_exempt: + type: boolean + tax_source: + $ref: '#/components/schemas/OrderTaxSource' + total_cents: + type: integer + format: int64 + open_cents: + type: integer + format: int64 + description: 'What is still owed: the total less the payments recorded against the invoice while it is + unpaid or partial, 0 when paid, written off or void.' + is_overdue: + type: boolean + description: 'Computed: open and its due_date is before the branch''s today. OVERDUE is not a status.' + paid_at: + type: + - string + - 'null' + format: date-time + gl_entry_id: + type: + - string + - 'null' + format: uuid + description: The invoice entry in the general ledger. + voided_at: + type: + - string + - 'null' + format: date-time + voided_by: + type: + - string + - 'null' + void_reason: + type: + - string + - 'null' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + Invoice: + type: object + description: The full invoice document with its lines (IN-1.8). + required: + - id + - number + - branch_id + - customer_id + - customer_name + - order_id + - job_id + - ship_to_id + - status + - revision + - currency + - origin + - delivery_type + - picked_up_by + - delivery_id + - invoice_date + - due_date + - payment_terms_id + - discount_due_date + - discount_percent + - subtotal_cents + - tax_cents + - tax_rate_percent + - tax_exempt + - tax_source + - total_cents + - open_cents + - is_overdue + - paid_at + - gl_entry_id + - voided_at + - voided_by + - void_reason + - created_at + - updated_at + - ship_to + - lines + properties: + id: + type: string + format: uuid + number: + type: string + description: The gapless IN- number, minted when the invoice is created (ADR 0005 4.1). A void invoice + keeps it. + branch_id: type: string format: uuid customer_id: @@ -203,129 +709,1003 @@ components: format: uuid customer_name: type: string + order_id: + type: + - string + - 'null' + format: uuid + description: The order this invoice bills; null on a counter account charge. + job_id: + type: + - string + - 'null' + format: uuid + description: The job (project) the invoice carries, from its order (ADR 0005 7.1). + ship_to_id: + type: + - string + - 'null' + format: uuid status: - $ref: "#/components/schemas/InvoiceStatus" - subtotal: + $ref: '#/components/schemas/InvoiceStatus' + revision: type: integer format: int64 - description: Cents. - tax_rate: - type: number - description: "A fraction such as 0.0825 for 8.25 percent." - tax_amount: + currency: + type: string + pattern: ^[A-Z]{3}$ + description: ISO 4217, copied from the order, never sent. + origin: + type: string + enum: + - order + - pos + delivery_type: + type: string + enum: + - pickup + - delivery + picked_up_by: + type: + - string + - 'null' + description: Who collected a will-call order. + delivery_id: + type: + - string + - 'null' + format: uuid + invoice_date: + type: string + format: date + description: The business date in the branch's local calendar. + due_date: + type: + - string + - 'null' + format: date + description: From the customer's payment terms at create. + payment_terms_id: + type: string + format: uuid + discount_due_date: + type: + - string + - 'null' + format: date + description: The last day the early payment discount applies; null when the terms carry none. + discount_percent: + type: + - string + - 'null' + description: The early payment discount percent at scale 4 as a decimal string. + subtotal_cents: type: integer format: int64 - description: Cents. - total_amount: + tax_cents: type: integer format: int64 - description: Cents, subtotal plus tax. - payment_terms: - $ref: "#/components/schemas/PaymentTerms" - due_date: - type: [string, "null"] - format: date-time - description: Null while the terms set no date. + tax_rate_percent: + type: + - string + - 'null' + description: A percent with at most 4 fraction digits ("8.875"); null when the tax provider answered. + tax_exempt: + type: boolean + tax_source: + $ref: '#/components/schemas/OrderTaxSource' + total_cents: + type: integer + format: int64 + open_cents: + type: integer + format: int64 + description: 'What is still owed: the total less the payments recorded against the invoice while it is + unpaid or partial, 0 when paid, written off or void.' + is_overdue: + type: boolean + description: 'Computed: open and its due_date is before the branch''s today. OVERDUE is not a status.' paid_at: - type: [string, "null"] + type: + - string + - 'null' + format: date-time + gl_entry_id: + type: + - string + - 'null' + format: uuid + description: The invoice entry in the general ledger. + voided_at: + type: + - string + - 'null' format: date-time - description: Null until a payment completes the invoice. + voided_by: + type: + - string + - 'null' + void_reason: + type: + - string + - 'null' created_at: type: string format: date-time updated_at: type: string format: date-time + ship_to: + type: + - object + - 'null' + description: The delivery address captured at confirm (ADR 0005 section 5.1); null on an order that never + confirmed or a pickup without one. + properties: + id: + type: string + format: uuid + code: + type: string + name: + type: string + line1: + type: string + line2: + type: + - string + - 'null' + city: + type: string + region: + type: string + postal_code: + type: string + country: + type: + - string + - 'null' + phone: + type: + - string + - 'null' + delivery_instructions: + type: + - string + - 'null' lines: type: array items: - $ref: "#/components/schemas/InvoiceLine" + $ref: '#/components/schemas/InvoiceLine' InvoiceLine: type: object - required: [id, invoice_id, product_id, quantity, price_each, created_at] + description: 'A sales line on an invoice: the shared shape of ADR 0005 section 2.2 for the quantity billed, + with the order line it bills and the cost that left.' + required: + - id + - position + - line_type + - parent_line_id + - product_id + - charge_code_id + - charge_code + - sku + - description + - quantity + - uom + - price_uom + - uom_qty + - price_uom_qty + - unit_price_ten_thousandths + - priced_unit_price_ten_thousandths + - price_source + - override_reason + - discount_percent + - discount_cents + - discount_reason + - price_adjusted_by + - line_total_cents + - taxable + - revenue_account_code + - is_special_order + - vendor_id + - special_order_unit_cost_ten_thousandths + - created_at + - order_line_id + - unit_cost_ten_thousandths + - cost_cents properties: id: type: string format: uuid - invoice_id: - type: string + position: + type: integer + description: The line's place on the document; a kit's components follow it. + line_type: + $ref: '#/components/schemas/SalesLineType' + parent_line_id: + type: + - string + - 'null' format: uuid + description: Set on a component line only, naming its kit line. product_id: - type: string + type: + - string + - 'null' format: uuid - product_sku: - type: string - product_name: + description: Required on kit and component lines, optional on a product line (null is a non stock item), + null on charge and text lines. + charge_code_id: + type: + - string + - 'null' + format: uuid + description: Set on a charge line only. + charge_code: + type: + - string + - 'null' + description: The charge code's code text, read. + sku: + type: + - string + - 'null' + description: A snapshot of the product's SKU. + description: type: string + description: A snapshot; required on every line. quantity: - type: number - price_each: - type: integer + type: + - string + - 'null' + description: The quantity as a decimal string with its unit; null only on a text line. + uom: + type: + - string + - 'null' + description: The sale unit. On a stocked line it is the product's stocking unit (ADR 0005 section 1). + price_uom: + type: + - string + - 'null' + description: The unit the price is per; equals uom unless the pair says otherwise. + uom_qty: + type: + - string + - 'null' + description: One side of the conversion pair; 1 and 1 when the units agree (any other pair on equal units + is a 400). + price_uom_qty: + type: + - string + - 'null' + description: The other side of the conversion pair. + unit_price_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: The price charged per price unit, after any override, before any discount. + priced_unit_price_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: What the pricing engine resolved, read only, so an override is visible as the difference; + null on text, charge and non stock lines. + price_source: + $ref: '#/components/schemas/SalesPriceSource' + override_reason: + type: + - string + - 'null' + description: Required when price_source is override. + discount_percent: + type: + - string + - 'null' + description: A percent at scale 4 as a decimal string, greater than 0 and at most 100; never with discount_cents. + discount_cents: + type: + - integer + - 'null' + format: int64 + description: A discount amount in cents, positive; never with discount_percent. + discount_reason: + type: + - string + - 'null' + description: Required with either discount. + price_adjusted_by: + type: + - string + - 'null' + description: The actor of the last override or discount; the audit row carries the rest. + line_total_cents: + type: + - integer + - 'null' format: int64 - description: Cents. + description: The extension, rounded once (ADR 0005 section 2.4); null only on a text line. + taxable: + type: boolean + revenue_account_code: + type: + - string + - 'null' + description: The charge code's account, snapshotted at create; null on every other line type. + is_special_order: + type: boolean + vendor_id: + type: + - string + - 'null' + format: uuid + special_order_unit_cost_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: A unit cost at scale 4. created_at: type: string format: date-time + order_line_id: + type: + - string + - 'null' + format: uuid + description: The order line this invoice line bills; null on a note and on a counter charge. + unit_cost_ten_thousandths: &id006 + type: + - integer + - 'null' + format: int64 + description: 'The cost that left (ADR 0005 8.4) at scale 4, shown to the roles that see margin (admin, + owner, finance, sales: the roles that reach these routes). Null when the line carries no cost.' + cost_cents: + type: integer + format: int64 + description: The cost of goods relieved from inventory for this line, in cents (ADR 0005 8.4); 0 when + none. InvoicePage: type: object - required: [data, total, limit, offset] + required: + - items + - next_cursor + - limit properties: - data: + items: type: array items: - $ref: "#/components/schemas/Invoice" + $ref: '#/components/schemas/InvoiceSummary' + next_cursor: + type: + - string + - 'null' + limit: + type: integer total: type: integer - limit: + description: Only under include=total. + InvoiceTransitionRequest: + type: object + required: + - to + description: 'The body of POST /invoices/{id}/transitions: to void, the revision and the reason.' + properties: + to: + $ref: '#/components/schemas/InvoiceStatus' + revision: + type: integer + format: int64 + description: The precondition, beside If-Match. + reason: + type: string + maxLength: 500 + description: Required by a void. + InvoiceEmailResponse: + type: object + required: + - status + properties: + status: + type: string + enum: + - queued + CreditMemoSummary: + type: object + description: A credit memo header, used as a list item and as the head of the full document. + required: + - id + - number + - branch_id + - customer_id + - customer_name + - invoice_id + - pos_return_id + - job_id + - ship_to_id + - status + - revision + - currency + - reason_code + - reason + - subtotal_cents + - tax_cents + - tax_rate_percent + - total_cents + - open_cents + - gl_entry_id + - memo_date + - voided_at + - voided_by + - void_reason + - created_at + - updated_at + properties: + id: + type: string + format: uuid + number: + type: + - string + - 'null' + description: 'The gapless CM- number, minted when the memo is posted: null while it is a draft, and a + voided draft never had one.' + branch_id: + type: string + format: uuid + customer_id: + type: string + format: uuid + customer_name: + type: string + invoice_id: + type: + - string + - 'null' + format: uuid + description: The invoice it credits, optional. + pos_return_id: + type: + - string + - 'null' + format: uuid + description: Set by a counter return (C2-5). + job_id: + type: + - string + - 'null' + format: uuid + ship_to_id: + type: + - string + - 'null' + format: uuid + status: + $ref: '#/components/schemas/CreditMemoStatus' + revision: + type: integer + format: int64 + currency: + type: string + pattern: ^[A-Z]{3}$ + description: The invoice's, else the customer's effective currency; copied, never sent. + reason_code: + type: string + enum: + - return + - price_adjustment + - damage + - other + reason: + type: string + subtotal_cents: + type: integer + format: int64 + description: 'NEGATIVE: lines, totals and the open amount are negative (ADR 0001 section 7a).' + tax_cents: + type: integer + format: int64 + description: NEGATIVE; never more than the invoice charged less the tax earlier credit memos credited. + tax_rate_percent: + type: + - string + - 'null' + description: The invoice's rate (or the resolved one); null when the invoice's tax came from the provider. + total_cents: type: integer - offset: + format: int64 + description: 'NEGATIVE: subtotal plus tax.' + open_cents: type: integer + format: int64 + description: 'The credit not yet used: the total while the memo is open, 0 for a draft or a void memo. + NEGATIVE like the total.' + gl_entry_id: + type: + - string + - 'null' + format: uuid + memo_date: + type: string + format: date + description: The business date in the branch's local calendar; set again at post. + voided_at: + type: + - string + - 'null' + format: date-time + voided_by: + type: + - string + - 'null' + void_reason: + type: + - string + - 'null' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time CreditMemo: type: object - required: [id, customer_id, amount, reason, status, created_at] + description: The full credit memo document with its lines. + required: + - id + - number + - branch_id + - customer_id + - customer_name + - invoice_id + - pos_return_id + - job_id + - ship_to_id + - status + - revision + - currency + - reason_code + - reason + - subtotal_cents + - tax_cents + - tax_rate_percent + - total_cents + - open_cents + - gl_entry_id + - memo_date + - voided_at + - voided_by + - void_reason + - created_at + - updated_at + - lines properties: id: type: string format: uuid - invoice_id: + number: + type: + - string + - 'null' + description: 'The gapless CM- number, minted when the memo is posted: null while it is a draft, and a + voided draft never had one.' + branch_id: type: string format: uuid customer_id: type: string format: uuid - amount: + customer_name: + type: string + invoice_id: + type: + - string + - 'null' + format: uuid + description: The invoice it credits, optional. + pos_return_id: + type: + - string + - 'null' + format: uuid + description: Set by a counter return (C2-5). + job_id: + type: + - string + - 'null' + format: uuid + ship_to_id: + type: + - string + - 'null' + format: uuid + status: + $ref: '#/components/schemas/CreditMemoStatus' + revision: type: integer format: int64 - description: Cents. + currency: + type: string + pattern: ^[A-Z]{3}$ + description: The invoice's, else the customer's effective currency; copied, never sent. + reason_code: + type: string + enum: + - return + - price_adjustment + - damage + - other reason: type: string - status: + subtotal_cents: + type: integer + format: int64 + description: 'NEGATIVE: lines, totals and the open amount are negative (ADR 0001 section 7a).' + tax_cents: + type: integer + format: int64 + description: NEGATIVE; never more than the invoice charged less the tax earlier credit memos credited. + tax_rate_percent: + type: + - string + - 'null' + description: The invoice's rate (or the resolved one); null when the invoice's tax came from the provider. + total_cents: + type: integer + format: int64 + description: 'NEGATIVE: subtotal plus tax.' + open_cents: + type: integer + format: int64 + description: 'The credit not yet used: the total while the memo is open, 0 for a draft or a void memo. + NEGATIVE like the total.' + gl_entry_id: + type: + - string + - 'null' + format: uuid + memo_date: type: string - enum: [PENDING, APPLIED, VOID] + format: date + description: The business date in the branch's local calendar; set again at post. + voided_at: + type: + - string + - 'null' + format: date-time + voided_by: + type: + - string + - 'null' + void_reason: + type: + - string + - 'null' created_at: type: string format: date-time - applied_at: + updated_at: type: string format: date-time - CreditMemoCreateRequest: + lines: + type: array + items: + $ref: '#/components/schemas/CreditMemoLine' + CreditMemoLine: type: object - required: [amount_cents, reason] + description: 'A line on a credit memo: the shared shape with a NEGATIVE quantity and line total (ADR 0001 + section 7a; a plain sum is the AR effect), the invoice line it returns and whether it restocks.' + required: + - id + - position + - line_type + - parent_line_id + - product_id + - charge_code_id + - charge_code + - sku + - description + - quantity + - uom + - price_uom + - uom_qty + - price_uom_qty + - unit_price_ten_thousandths + - priced_unit_price_ten_thousandths + - price_source + - override_reason + - discount_percent + - discount_cents + - discount_reason + - price_adjusted_by + - line_total_cents + - taxable + - revenue_account_code + - is_special_order + - vendor_id + - special_order_unit_cost_ten_thousandths + - created_at + - invoice_line_id + - restock + - unit_cost_ten_thousandths + - cost_cents properties: - amount_cents: + id: + type: string + format: uuid + position: type: integer + description: The line's place on the document; a kit's components follow it. + line_type: + $ref: '#/components/schemas/SalesLineType' + parent_line_id: + type: + - string + - 'null' + format: uuid + description: Set on a component line only, naming its kit line. + product_id: + type: + - string + - 'null' + format: uuid + description: Required on kit and component lines, optional on a product line (null is a non stock item), + null on charge and text lines. + charge_code_id: + type: + - string + - 'null' + format: uuid + description: Set on a charge line only. + charge_code: + type: + - string + - 'null' + description: The charge code's code text, read. + sku: + type: + - string + - 'null' + description: A snapshot of the product's SKU. + description: + type: string + description: A snapshot; required on every line. + quantity: + type: + - string + - 'null' + description: The quantity as a decimal string with its unit; null only on a text line. + uom: + type: + - string + - 'null' + description: The sale unit. On a stocked line it is the product's stocking unit (ADR 0005 section 1). + price_uom: + type: + - string + - 'null' + description: The unit the price is per; equals uom unless the pair says otherwise. + uom_qty: + type: + - string + - 'null' + description: One side of the conversion pair; 1 and 1 when the units agree (any other pair on equal units + is a 400). + price_uom_qty: + type: + - string + - 'null' + description: The other side of the conversion pair. + unit_price_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: The price charged per price unit, after any override, before any discount. + priced_unit_price_ten_thousandths: + type: + - integer + - 'null' format: int64 - description: Cents; must be positive. + description: What the pricing engine resolved, read only, so an override is visible as the difference; + null on text, charge and non stock lines. + price_source: + $ref: '#/components/schemas/SalesPriceSource' + override_reason: + type: + - string + - 'null' + description: Required when price_source is override. + discount_percent: + type: + - string + - 'null' + description: A percent at scale 4 as a decimal string, greater than 0 and at most 100; never with discount_cents. + discount_cents: + type: + - integer + - 'null' + format: int64 + description: A discount amount in cents, positive; never with discount_percent. + discount_reason: + type: + - string + - 'null' + description: Required with either discount. + price_adjusted_by: + type: + - string + - 'null' + description: The actor of the last override or discount; the audit row carries the rest. + line_total_cents: + type: + - integer + - 'null' + format: int64 + description: The extension, rounded once (ADR 0005 section 2.4); null only on a text line. + taxable: + type: boolean + revenue_account_code: + type: + - string + - 'null' + description: The charge code's account, snapshotted at create; null on every other line type. + is_special_order: + type: boolean + vendor_id: + type: + - string + - 'null' + format: uuid + special_order_unit_cost_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: A unit cost at scale 4. + created_at: + type: string + format: date-time + invoice_line_id: + type: + - string + - 'null' + format: uuid + description: The invoice line this credit line returns; null on a free line (a price adjustment, a fee + given back, a note). + restock: + type: boolean + description: Whether the goods go back on hand when the credit memo is posted. + unit_cost_ten_thousandths: *id006 + cost_cents: + type: integer + format: int64 + description: 'The cost that comes back to inventory, in cents and NEGATIVE like the line (ADR 0005 8.4): + COGS reverses at the original cost; 0 on a line that does not restock.' + CreditMemoLineRequest: + type: object + description: 'One credit memo line. A line that names an invoice line (invoice_line_id) sends only the negative + quantity credited, restock and optionally a description: its price, pair and discount come from the invoice + line, and it cannot credit more than that line billed less what earlier credit memos returned (409 exceeds_billed). + A free line names a product, a charge code or only a description and sends its own price; quantities are + negative.' + properties: + id: + type: string + format: uuid + description: Keeps a line across edits. + invoice_line_id: + type: string + format: uuid + line_type: + type: string + enum: + - product + - charge + - text + product_id: + type: string + format: uuid + charge_code: + type: string + description: + type: string + quantity: + type: string + description: 'A NEGATIVE decimal string: the quantity returned or credited.' + uom: + type: string + unit_price_ten_thousandths: + type: integer + format: int64 + description: Free lines only; never negative. + taxable: + type: boolean + restock: + type: boolean + description: Return the goods to stock at post (a stocked product line). + CreditMemoRequest: + type: object + description: The body of POST /credit-memos and PUT /credit-memos/{id}. A create names an invoice (the customer, + branch, currency, job and ship-to follow it) or a customer. branch_id, if sent, is held to the caller's + branch like every payload branch. currency is never sent. + required: + - reason_code + - reason + - lines + properties: + branch_id: + type: string + format: uuid + customer_id: + type: string + format: uuid + invoice_id: + type: string + format: uuid + job_id: + type: string + format: uuid + ship_to_id: + type: string + format: uuid + reason_code: + type: string + enum: + - return + - price_adjustment + - damage + - other reason: type: string - CreditMemoList: - type: [array, "null"] - items: - $ref: "#/components/schemas/CreditMemo" - InvoiceEmailResponse: + minLength: 1 + maxLength: 500 + revision: + type: integer + format: int64 + description: The precondition on a PUT, beside If-Match. + lines: + type: array + items: + $ref: '#/components/schemas/CreditMemoLineRequest' + CreditMemoPage: type: object - required: [status] + required: + - items + - next_cursor + - limit properties: - status: + items: + type: array + items: + $ref: '#/components/schemas/CreditMemoSummary' + next_cursor: + type: + - string + - 'null' + limit: + type: integer + total: + type: integer + description: Only under include=total. + CreditMemoTransitionRequest: + type: object + required: + - to + description: 'The body of POST /credit-memos/{id}/transitions: to open (post) or void, the revision and the + reason.' + properties: + to: + $ref: '#/components/schemas/CreditMemoStatus' + revision: + type: integer + format: int64 + description: The precondition, beside If-Match. + reason: type: string - enum: [queued] + maxLength: 500 + description: Required by a void. diff --git a/core/api/fragments/portal.yaml b/core/api/fragments/portal.yaml index 5401ce83..609a4112 100644 --- a/core/api/fragments/portal.yaml +++ b/core/api/fragments/portal.yaml @@ -1319,16 +1319,23 @@ components: description: Float dollars. PortalInvoice: type: object - required: [id, order_id, status, total_amount, subtotal, tax_amount, payment_terms, due_date, paid_at, created_at, lines] + required: [id, number, is_overdue, order_id, status, total_amount, subtotal, tax_amount, payment_terms, due_date, paid_at, created_at, lines] properties: id: type: string format: uuid + number: + type: string + description: The invoice's gapless IN- number (C2-3). + is_overdue: + type: boolean + description: Computed, open and past its due date; overdue is no longer a status (C2-3). order_id: type: string format: uuid status: type: string + description: The stored status, uppercase (UNPAID, PARTIAL, PAID, VOID, WRITTEN_OFF); the portal keeps its legacy vocabulary. total_amount: type: number description: Float dollars. diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index b1faee9e..95ea5914 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -25,6 +25,7 @@ tags: - name: bankrecon - name: charge-codes - name: configurator + - name: credit-memo - name: customer - name: dashboard - name: delivery @@ -5183,30 +5184,80 @@ paths: /api/v1/invoices: get: operationId: invoiceList - tags: [invoice] + tags: + - invoice summary: List invoices + description: The cursor list envelope, newest first (created_at, then id). status filters on the lowercase lifecycle vocabulary (a comma separated list); customer_id, job_id, ship_to_id and order_id filter on their document; overdue=true lists the open invoices past their due date and overdue=false the rest. total appears only under include=total. A parameter the route does not declare, a status outside the vocabulary (overdue is not one), a malformed cursor or an out of range limit is a 400. parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - $ref: "#/components/parameters/XBranchId" + - name: status + in: query + required: false + description: Comma separated lowercase statuses (unpaid, partial, paid, void, written_off). + schema: + type: string + - name: customer_id + in: query + required: false + description: Filter on the customer. + schema: + type: string + format: uuid + - name: job_id + in: query + required: false + description: Filter on the job (project). + schema: + type: string + format: uuid + - name: ship_to_id + in: query + required: false + description: Filter on the ship-to. + schema: + type: string + format: uuid + - name: order_id + in: query + required: false + description: Filter on the order. + schema: + type: string + format: uuid + - name: overdue + in: query + required: false + description: 'true: open and past due; false: everything else.' + schema: + type: string + enum: + - 'true' + - 'false' + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Include' + - $ref: '#/components/parameters/XBranchId' responses: - "200": + '200': description: The page of invoices. content: application/json: schema: - $ref: "#/components/schemas/InvoicePage" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/InvoicePage' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': &id002 + $ref: '#/components/responses/Unauthorized' + '403': &id003 + $ref: '#/components/responses/ForbiddenEither' + '500': &id004 + $ref: '#/components/responses/InternalError' /api/v1/invoices/{id}: get: operationId: invoiceGet - tags: [invoice] + tags: + - invoice summary: Get one invoice with lines + description: The invoice with every line (id, description, quantity, unit price, line total, cost), its status and open amount, and its ETag. parameters: - name: id in: path @@ -5214,27 +5265,34 @@ paths: schema: type: string format: uuid - - $ref: "#/components/parameters/XBranchId" + - $ref: '#/components/parameters/XBranchId' responses: - "200": + '200': description: The invoice. + headers: &id001 + ETag: &id005 + description: The document's revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/Invoice" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "404": - $ref: "#/components/responses/NotFound" - /api/v1/invoices/{id}/credit-memo: + $ref: '#/components/schemas/Invoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/WireNotFound' + /api/v1/invoices/{id}/transitions: post: - operationId: invoiceCreateCreditMemo - tags: [invoice] - summary: Create and apply a credit memo against an invoice + operationId: invoiceTransition + tags: + - invoice + summary: Void an invoice + description: 'The only client transition is void (roles admin, owner, finance; a reason is required). unpaid, partial, paid and written_off are derived by payments and credit memos, so asking for one is 409 invalid_state_transition. A void is refused with the blocker has_applications while a payment is recorded against the invoice or an applied credit memo names it, and with has_credit_memos while a credit memo that is not void names it. It runs in one transaction: it locks the invoice''s order row first, reverses the invoice''s whole journal entry (dated the void date), returns its billed stock to on hand, reduces the order lines'' fulfilled quantities, re-runs allocation for those quantities and derives the order status; the subledger is credited back. The number is kept; nothing deletes an invoice. Writes invoice.voided (and order.backordered when the order cannot re-allocate) last.' parameters: - name: id in: path @@ -5242,44 +5300,45 @@ paths: schema: type: string format: uuid - - $ref: "#/components/parameters/XBranchId" - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreditMemoCreateRequest" + $ref: '#/components/schemas/InvoiceTransitionRequest' responses: - "201": - description: The created credit memo. + '200': + description: The voided invoice. + headers: *id001 content: application/json: schema: - $ref: "#/components/schemas/CreditMemo" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/Invoice' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': *id002 + '403': *id003 + '404': + $ref: '#/components/responses/WireNotFound' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '428': + $ref: '#/components/responses/WirePreconditionRequired' + '500': *id004 /api/v1/invoices/{id}/email: post: operationId: invoiceEmail - tags: [invoice] + tags: + - invoice summary: Email invoice to customer - description: >- - Queues an email to the customer with the invoice PDF attached. Returns 202 Accepted immediately; the email is dispatched asynchronously. Fails if the customer has no email address on file. + description: Queues an email to the customer with the invoice PDF attached. Returns 202 Accepted immediately; the email is dispatched asynchronously. Fails if the customer has no email address on file. parameters: - name: id in: path @@ -5287,121 +5346,134 @@ paths: schema: type: string format: uuid - - $ref: "#/components/parameters/XBranchId" - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IdempotencyKey' responses: - "202": + '202': description: Email queued. content: application/json: schema: - $ref: "#/components/schemas/InvoiceEmailResponse" - "400": - description: >- - Customer has no email address on file (the standard error envelope), or the Idempotency-Key header is malformed (the ADR 0001 envelope). + $ref: '#/components/schemas/InvoiceEmailResponse' + '400': + description: Customer has no email address on file (the standard error envelope), or the Idempotency-Key header is malformed (the ADR 0001 envelope). content: application/json: schema: oneOf: - - $ref: "#/components/schemas/Error" - - $ref: "#/components/schemas/WireError" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "500": - $ref: "#/components/responses/InternalError" - /api/v1/credit-memos/{customerId}: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/WireError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/IdempotencyConflict' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/credit-memos: get: - operationId: invoiceListCreditMemos - tags: [invoice] - summary: List a customer's credit memos + operationId: creditMemoList + tags: + - credit-memo + summary: List credit memos + description: The cursor list envelope, newest first. status filters on the lowercase vocabulary (draft, open, partial, applied, void; a comma separated list); customer_id, invoice_id and job_id filter on their document. total appears only under include=total. parameters: - - name: customerId - in: path - required: true + - name: status + in: query + required: false + description: Comma separated lowercase statuses. + schema: + type: string + - name: customer_id + in: query + required: false + description: Filter on the customer. schema: type: string format: uuid - - $ref: "#/components/parameters/XBranchId" - responses: - "200": - description: The credit memos, a bare array that is null when the customer has none. - content: - application/json: - schema: - $ref: "#/components/schemas/CreditMemoList" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/ForbiddenEither" - "500": - $ref: "#/components/responses/InternalError" - /api/v1/locations: - get: - operationId: locationList - tags: [location] - summary: List every location node + - name: invoice_id + in: query + required: false + description: Filter on the invoice credited. + schema: + type: string + format: uuid + - name: job_id + in: query + required: false + description: Filter on the job. + schema: + type: string + format: uuid + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Include' + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: All location nodes, a bare array that is null when none exist. + '200': + description: The page of credit memos. content: application/json: schema: - $ref: "#/components/schemas/LocationList" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/CreditMemoPage' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': *id002 + '403': *id003 + '500': *id004 post: - operationId: locationCreate - tags: [location] - summary: Create a location node - description: The active flag defaults to true when the body does not mention it. + operationId: creditMemoCreate + tags: + - credit-memo + summary: Create a draft credit memo + description: 'Creates the credit memo in draft: no number (it is minted at post, so a voided draft consumes none), lines built and totalled, tax at the invoice''s rate and never more than the invoice charged less what earlier credit memos credited. A line that names an invoice line cannot credit more than it billed less what earlier credit memos returned (409 exceeds_billed). The branch is held to the caller''s branch (403 naming branch_id). Writes credit_memo.created.' parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/LocationCreateRequest" + $ref: '#/components/schemas/CreditMemoRequest' responses: - "201": - description: The created location. + '201': + description: The draft credit memo. + headers: + ETag: *id005 + Location: + description: The path of the created document. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/Location" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - /api/v1/locations/{id}: + $ref: '#/components/schemas/CreditMemo' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': *id002 + '403': *id003 + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': *id004 + /api/v1/credit-memos/{id}: get: - operationId: locationGet - tags: [location] - summary: Get one location node + operationId: creditMemoGet + tags: + - credit-memo + summary: Get one credit memo with lines + description: The credit memo with every line and its ETag. parameters: - name: id in: path @@ -5409,9 +5481,179 @@ paths: schema: type: string format: uuid + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The location. + '200': + description: The credit memo. + headers: *id001 + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemo' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/WireNotFound' + put: + operationId: creditMemoUpdate + tags: + - credit-memo + summary: Replace a draft credit memo + description: 'Replaces the header fields and the lines, in draft only: in any other status it is 409 with the blocker credit_memo_not_draft. The customer and the invoice are fixed at create. The revision precondition applies (If-Match or the body revision). Writes credit_memo.updated.' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemoRequest' + responses: + '200': + description: The edited credit memo. + headers: *id001 + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemo' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': *id002 + '403': *id003 + '404': + $ref: '#/components/responses/WireNotFound' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '428': + $ref: '#/components/responses/WirePreconditionRequired' + '500': *id004 + /api/v1/credit-memos/{id}/transitions: + post: + operationId: creditMemoTransition + tags: + - credit-memo + summary: Post or void a credit memo + description: 'draft to open posts the credit memo (roles admin, owner, finance): it locks the invoice it names (409 invoice_void when that invoice is void), recomputes its lines against what was posted, restocks the lines that restock, mints the gapless CM- number, posts the balanced entry (revenue and tax back, receivable down, and for a restocked line the cost back to inventory and out of cost of goods sold at the original cost) and the subledger credit, and writes credit_memo.posted last. draft to void ends a draft and consumes no number; open to void (roles admin, owner, finance; a reason is required) reverses the entry and the restock and puts the credit back on the receivable. The statuses past open (partial, applied) arrive with the payment and AR item and are not reachable here (409 invalid_state_transition). Writes credit_memo.posted or credit_memo.voided.' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/XBranchId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemoTransitionRequest' + responses: + '200': + description: The credit memo after the transition. + headers: *id001 + content: + application/json: + schema: + $ref: '#/components/schemas/CreditMemo' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': *id002 + '403': *id003 + '404': + $ref: '#/components/responses/WireNotFound' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '428': + $ref: '#/components/responses/WirePreconditionRequired' + '500': *id004 + /api/v1/locations: + get: + operationId: locationList + tags: [location] + summary: List every location node + responses: + "200": + description: All location nodes, a bare array that is null when none exist. + content: + application/json: + schema: + $ref: "#/components/schemas/LocationList" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalError" + post: + operationId: locationCreate + tags: [location] + summary: Create a location node + description: The active flag defaults to true when the body does not mention it. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LocationCreateRequest" + responses: + "201": + description: The created location. + content: + application/json: + schema: + $ref: "#/components/schemas/Location" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/IdempotencyConflict" + "413": + $ref: "#/components/responses/PayloadTooLarge" + "422": + $ref: "#/components/responses/UnprocessableEntity" + /api/v1/locations/{id}: + get: + operationId: locationGet + tags: [location] + summary: Get one location node + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: The location. content: application/json: schema: @@ -16109,249 +16351,1327 @@ components: type: string entitled: type: boolean - roles: - type: array - items: - type: string - description: Never null. - modules: - type: array - items: - type: string - description: Never null. - Inventory: - type: object - description: inventory.Inventory. location_id is omitted when unset. - required: - - id - - product_id - - location - - quantity - - allocated - - updated_at - properties: - id: - type: string - format: uuid - product_id: - type: string - format: uuid - location_id: - type: string + roles: + type: array + items: + type: string + description: Never null. + modules: + type: array + items: + type: string + description: Never null. + Inventory: + type: object + description: inventory.Inventory. location_id is omitted when unset. + required: + - id + - product_id + - location + - quantity + - allocated + - updated_at + properties: + id: + type: string + format: uuid + product_id: + type: string + format: uuid + location_id: + type: string + format: uuid + location: + type: string + description: Deprecated text field. + quantity: + type: number + allocated: + type: number + updated_at: + type: string + format: date-time + StockAdjustmentRequest: + type: object + required: + - product_id + - quantity + properties: + product_id: + type: string + format: uuid + location_id: + type: + - string + - 'null' + format: uuid + quantity: + type: number + description: The new quantity, or the delta when is_delta is true. + reason: + type: string + is_delta: + type: boolean + description: If true, add or subtract; if false, replace. + StockMovementRequest: + type: object + required: + - product_id + - to_location_id + - quantity + properties: + product_id: + type: string + format: uuid + from_location_id: + type: + - string + - 'null' + format: uuid + to_location_id: + type: string + format: uuid + quantity: + type: number + reason: + type: string + InventoryStatusAck: + type: object + required: + - status + properties: + status: + type: string + enum: + - ok + InvoiceStatus: + type: string + enum: + - unpaid + - partial + - paid + - void + - written_off + description: 'The lifecycle, lowercase on the wire (ADR 0001 section 6; ADR 0005 section 6.2). overdue is not a status: it is the is_overdue flag and the overdue list filter.' + CreditMemoStatus: + type: string + enum: + - draft + - open + - partial + - applied + - void + description: The credit memo lifecycle, lowercase on the wire (ADR 0005 section 6.3). + InvoiceSummary: + type: object + description: An invoice header, used as a list item and as the head of the full document. + required: + - id + - number + - branch_id + - customer_id + - customer_name + - order_id + - job_id + - ship_to_id + - status + - revision + - currency + - origin + - delivery_type + - picked_up_by + - delivery_id + - invoice_date + - due_date + - payment_terms_id + - discount_due_date + - discount_percent + - subtotal_cents + - tax_cents + - tax_rate_percent + - tax_exempt + - tax_source + - total_cents + - open_cents + - is_overdue + - paid_at + - gl_entry_id + - voided_at + - voided_by + - void_reason + - created_at + - updated_at + properties: + id: + type: string + format: uuid + number: + type: string + description: The gapless IN- number, minted when the invoice is created (ADR 0005 4.1). A void invoice keeps it. + branch_id: + type: string + format: uuid + customer_id: + type: string + format: uuid + customer_name: + type: string + order_id: + type: + - string + - 'null' + format: uuid + description: The order this invoice bills; null on a counter account charge. + job_id: + type: + - string + - 'null' + format: uuid + description: The job (project) the invoice carries, from its order (ADR 0005 7.1). + ship_to_id: + type: + - string + - 'null' + format: uuid + status: + $ref: '#/components/schemas/InvoiceStatus' + revision: + type: integer + format: int64 + currency: + type: string + pattern: ^[A-Z]{3}$ + description: ISO 4217, copied from the order, never sent. + origin: + type: string + enum: + - order + - pos + delivery_type: + type: string + enum: + - pickup + - delivery + picked_up_by: + type: + - string + - 'null' + description: Who collected a will-call order. + delivery_id: + type: + - string + - 'null' + format: uuid + invoice_date: + type: string + format: date + description: The business date in the branch's local calendar. + due_date: + type: + - string + - 'null' + format: date + description: From the customer's payment terms at create. + payment_terms_id: + type: string + format: uuid + discount_due_date: + type: + - string + - 'null' + format: date + description: The last day the early payment discount applies; null when the terms carry none. + discount_percent: + type: + - string + - 'null' + description: The early payment discount percent at scale 4 as a decimal string. + subtotal_cents: + type: integer + format: int64 + tax_cents: + type: integer + format: int64 + tax_rate_percent: + type: + - string + - 'null' + description: A percent with at most 4 fraction digits ("8.875"); null when the tax provider answered. + tax_exempt: + type: boolean + tax_source: + $ref: '#/components/schemas/OrderTaxSource' + total_cents: + type: integer + format: int64 + open_cents: + type: integer + format: int64 + description: 'What is still owed: the total less the payments recorded against the invoice while it is unpaid or partial, 0 when paid, written off or void.' + is_overdue: + type: boolean + description: 'Computed: open and its due_date is before the branch''s today. OVERDUE is not a status.' + paid_at: + type: + - string + - 'null' + format: date-time + gl_entry_id: + type: + - string + - 'null' + format: uuid + description: The invoice entry in the general ledger. + voided_at: + type: + - string + - 'null' + format: date-time + voided_by: + type: + - string + - 'null' + void_reason: + type: + - string + - 'null' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + Invoice: + type: object + description: The full invoice document with its lines (IN-1.8). + required: + - id + - number + - branch_id + - customer_id + - customer_name + - order_id + - job_id + - ship_to_id + - status + - revision + - currency + - origin + - delivery_type + - picked_up_by + - delivery_id + - invoice_date + - due_date + - payment_terms_id + - discount_due_date + - discount_percent + - subtotal_cents + - tax_cents + - tax_rate_percent + - tax_exempt + - tax_source + - total_cents + - open_cents + - is_overdue + - paid_at + - gl_entry_id + - voided_at + - voided_by + - void_reason + - created_at + - updated_at + - ship_to + - lines + properties: + id: + type: string + format: uuid + number: + type: string + description: The gapless IN- number, minted when the invoice is created (ADR 0005 4.1). A void invoice keeps it. + branch_id: + type: string + format: uuid + customer_id: + type: string + format: uuid + customer_name: + type: string + order_id: + type: + - string + - 'null' + format: uuid + description: The order this invoice bills; null on a counter account charge. + job_id: + type: + - string + - 'null' + format: uuid + description: The job (project) the invoice carries, from its order (ADR 0005 7.1). + ship_to_id: + type: + - string + - 'null' + format: uuid + status: + $ref: '#/components/schemas/InvoiceStatus' + revision: + type: integer + format: int64 + currency: + type: string + pattern: ^[A-Z]{3}$ + description: ISO 4217, copied from the order, never sent. + origin: + type: string + enum: + - order + - pos + delivery_type: + type: string + enum: + - pickup + - delivery + picked_up_by: + type: + - string + - 'null' + description: Who collected a will-call order. + delivery_id: + type: + - string + - 'null' + format: uuid + invoice_date: + type: string + format: date + description: The business date in the branch's local calendar. + due_date: + type: + - string + - 'null' + format: date + description: From the customer's payment terms at create. + payment_terms_id: + type: string + format: uuid + discount_due_date: + type: + - string + - 'null' + format: date + description: The last day the early payment discount applies; null when the terms carry none. + discount_percent: + type: + - string + - 'null' + description: The early payment discount percent at scale 4 as a decimal string. + subtotal_cents: + type: integer + format: int64 + tax_cents: + type: integer + format: int64 + tax_rate_percent: + type: + - string + - 'null' + description: A percent with at most 4 fraction digits ("8.875"); null when the tax provider answered. + tax_exempt: + type: boolean + tax_source: + $ref: '#/components/schemas/OrderTaxSource' + total_cents: + type: integer + format: int64 + open_cents: + type: integer + format: int64 + description: 'What is still owed: the total less the payments recorded against the invoice while it is unpaid or partial, 0 when paid, written off or void.' + is_overdue: + type: boolean + description: 'Computed: open and its due_date is before the branch''s today. OVERDUE is not a status.' + paid_at: + type: + - string + - 'null' + format: date-time + gl_entry_id: + type: + - string + - 'null' + format: uuid + description: The invoice entry in the general ledger. + voided_at: + type: + - string + - 'null' + format: date-time + voided_by: + type: + - string + - 'null' + void_reason: + type: + - string + - 'null' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ship_to: + type: + - object + - 'null' + description: The delivery address captured at confirm (ADR 0005 section 5.1); null on an order that never confirmed or a pickup without one. + properties: + id: + type: string + format: uuid + code: + type: string + name: + type: string + line1: + type: string + line2: + type: + - string + - 'null' + city: + type: string + region: + type: string + postal_code: + type: string + country: + type: + - string + - 'null' + phone: + type: + - string + - 'null' + delivery_instructions: + type: + - string + - 'null' + lines: + type: array + items: + $ref: '#/components/schemas/InvoiceLine' + InvoiceLine: + type: object + description: 'A sales line on an invoice: the shared shape of ADR 0005 section 2.2 for the quantity billed, with the order line it bills and the cost that left.' + required: + - id + - position + - line_type + - parent_line_id + - product_id + - charge_code_id + - charge_code + - sku + - description + - quantity + - uom + - price_uom + - uom_qty + - price_uom_qty + - unit_price_ten_thousandths + - priced_unit_price_ten_thousandths + - price_source + - override_reason + - discount_percent + - discount_cents + - discount_reason + - price_adjusted_by + - line_total_cents + - taxable + - revenue_account_code + - is_special_order + - vendor_id + - special_order_unit_cost_ten_thousandths + - created_at + - order_line_id + - unit_cost_ten_thousandths + - cost_cents + properties: + id: + type: string + format: uuid + position: + type: integer + description: The line's place on the document; a kit's components follow it. + line_type: + $ref: '#/components/schemas/SalesLineType' + parent_line_id: + type: + - string + - 'null' + format: uuid + description: Set on a component line only, naming its kit line. + product_id: + type: + - string + - 'null' + format: uuid + description: Required on kit and component lines, optional on a product line (null is a non stock item), null on charge and text lines. + charge_code_id: + type: + - string + - 'null' + format: uuid + description: Set on a charge line only. + charge_code: + type: + - string + - 'null' + description: The charge code's code text, read. + sku: + type: + - string + - 'null' + description: A snapshot of the product's SKU. + description: + type: string + description: A snapshot; required on every line. + quantity: + type: + - string + - 'null' + description: The quantity as a decimal string with its unit; null only on a text line. + uom: + type: + - string + - 'null' + description: The sale unit. On a stocked line it is the product's stocking unit (ADR 0005 section 1). + price_uom: + type: + - string + - 'null' + description: The unit the price is per; equals uom unless the pair says otherwise. + uom_qty: + type: + - string + - 'null' + description: One side of the conversion pair; 1 and 1 when the units agree (any other pair on equal units is a 400). + price_uom_qty: + type: + - string + - 'null' + description: The other side of the conversion pair. + unit_price_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: The price charged per price unit, after any override, before any discount. + priced_unit_price_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: What the pricing engine resolved, read only, so an override is visible as the difference; null on text, charge and non stock lines. + price_source: + $ref: '#/components/schemas/SalesPriceSource' + override_reason: + type: + - string + - 'null' + description: Required when price_source is override. + discount_percent: + type: + - string + - 'null' + description: A percent at scale 4 as a decimal string, greater than 0 and at most 100; never with discount_cents. + discount_cents: + type: + - integer + - 'null' + format: int64 + description: A discount amount in cents, positive; never with discount_percent. + discount_reason: + type: + - string + - 'null' + description: Required with either discount. + price_adjusted_by: + type: + - string + - 'null' + description: The actor of the last override or discount; the audit row carries the rest. + line_total_cents: + type: + - integer + - 'null' + format: int64 + description: The extension, rounded once (ADR 0005 section 2.4); null only on a text line. + taxable: + type: boolean + revenue_account_code: + type: + - string + - 'null' + description: The charge code's account, snapshotted at create; null on every other line type. + is_special_order: + type: boolean + vendor_id: + type: + - string + - 'null' + format: uuid + special_order_unit_cost_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: A unit cost at scale 4. + created_at: + type: string + format: date-time + order_line_id: + type: + - string + - 'null' + format: uuid + description: The order line this invoice line bills; null on a note and on a counter charge. + unit_cost_ten_thousandths: &id006 + type: + - integer + - 'null' + format: int64 + description: 'The cost that left (ADR 0005 8.4) at scale 4, shown to the roles that see margin (admin, owner, finance, sales: the roles that reach these routes). Null when the line carries no cost.' + cost_cents: + type: integer + format: int64 + description: The cost of goods relieved from inventory for this line, in cents (ADR 0005 8.4); 0 when none. + InvoicePage: + type: object + required: + - items + - next_cursor + - limit + properties: + items: + type: array + items: + $ref: '#/components/schemas/InvoiceSummary' + next_cursor: + type: + - string + - 'null' + limit: + type: integer + total: + type: integer + description: Only under include=total. + InvoiceTransitionRequest: + type: object + required: + - to + description: 'The body of POST /invoices/{id}/transitions: to void, the revision and the reason.' + properties: + to: + $ref: '#/components/schemas/InvoiceStatus' + revision: + type: integer + format: int64 + description: The precondition, beside If-Match. + reason: + type: string + maxLength: 500 + description: Required by a void. + InvoiceEmailResponse: + type: object + required: + - status + properties: + status: + type: string + enum: + - queued + CreditMemoSummary: + type: object + description: A credit memo header, used as a list item and as the head of the full document. + required: + - id + - number + - branch_id + - customer_id + - customer_name + - invoice_id + - pos_return_id + - job_id + - ship_to_id + - status + - revision + - currency + - reason_code + - reason + - subtotal_cents + - tax_cents + - tax_rate_percent + - total_cents + - open_cents + - gl_entry_id + - memo_date + - voided_at + - voided_by + - void_reason + - created_at + - updated_at + properties: + id: + type: string + format: uuid + number: + type: + - string + - 'null' + description: 'The gapless CM- number, minted when the memo is posted: null while it is a draft, and a voided draft never had one.' + branch_id: + type: string + format: uuid + customer_id: + type: string + format: uuid + customer_name: + type: string + invoice_id: + type: + - string + - 'null' + format: uuid + description: The invoice it credits, optional. + pos_return_id: + type: + - string + - 'null' + format: uuid + description: Set by a counter return (C2-5). + job_id: + type: + - string + - 'null' + format: uuid + ship_to_id: + type: + - string + - 'null' + format: uuid + status: + $ref: '#/components/schemas/CreditMemoStatus' + revision: + type: integer + format: int64 + currency: + type: string + pattern: ^[A-Z]{3}$ + description: The invoice's, else the customer's effective currency; copied, never sent. + reason_code: + type: string + enum: + - return + - price_adjustment + - damage + - other + reason: + type: string + subtotal_cents: + type: integer + format: int64 + description: 'NEGATIVE: lines, totals and the open amount are negative (ADR 0001 section 7a).' + tax_cents: + type: integer + format: int64 + description: NEGATIVE; never more than the invoice charged less the tax earlier credit memos credited. + tax_rate_percent: + type: + - string + - 'null' + description: The invoice's rate (or the resolved one); null when the invoice's tax came from the provider. + total_cents: + type: integer + format: int64 + description: 'NEGATIVE: subtotal plus tax.' + open_cents: + type: integer + format: int64 + description: 'The credit not yet used: the total while the memo is open, 0 for a draft or a void memo. NEGATIVE like the total.' + gl_entry_id: + type: + - string + - 'null' + format: uuid + memo_date: + type: string + format: date + description: The business date in the branch's local calendar; set again at post. + voided_at: + type: + - string + - 'null' + format: date-time + voided_by: + type: + - string + - 'null' + void_reason: + type: + - string + - 'null' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + CreditMemo: + type: object + description: The full credit memo document with its lines. + required: + - id + - number + - branch_id + - customer_id + - customer_name + - invoice_id + - pos_return_id + - job_id + - ship_to_id + - status + - revision + - currency + - reason_code + - reason + - subtotal_cents + - tax_cents + - tax_rate_percent + - total_cents + - open_cents + - gl_entry_id + - memo_date + - voided_at + - voided_by + - void_reason + - created_at + - updated_at + - lines + properties: + id: + type: string + format: uuid + number: + type: + - string + - 'null' + description: 'The gapless CM- number, minted when the memo is posted: null while it is a draft, and a voided draft never had one.' + branch_id: + type: string + format: uuid + customer_id: + type: string + format: uuid + customer_name: + type: string + invoice_id: + type: + - string + - 'null' + format: uuid + description: The invoice it credits, optional. + pos_return_id: + type: + - string + - 'null' + format: uuid + description: Set by a counter return (C2-5). + job_id: + type: + - string + - 'null' + format: uuid + ship_to_id: + type: + - string + - 'null' + format: uuid + status: + $ref: '#/components/schemas/CreditMemoStatus' + revision: + type: integer + format: int64 + currency: + type: string + pattern: ^[A-Z]{3}$ + description: The invoice's, else the customer's effective currency; copied, never sent. + reason_code: + type: string + enum: + - return + - price_adjustment + - damage + - other + reason: + type: string + subtotal_cents: + type: integer + format: int64 + description: 'NEGATIVE: lines, totals and the open amount are negative (ADR 0001 section 7a).' + tax_cents: + type: integer + format: int64 + description: NEGATIVE; never more than the invoice charged less the tax earlier credit memos credited. + tax_rate_percent: + type: + - string + - 'null' + description: The invoice's rate (or the resolved one); null when the invoice's tax came from the provider. + total_cents: + type: integer + format: int64 + description: 'NEGATIVE: subtotal plus tax.' + open_cents: + type: integer + format: int64 + description: 'The credit not yet used: the total while the memo is open, 0 for a draft or a void memo. NEGATIVE like the total.' + gl_entry_id: + type: + - string + - 'null' + format: uuid + memo_date: + type: string + format: date + description: The business date in the branch's local calendar; set again at post. + voided_at: + type: + - string + - 'null' + format: date-time + voided_by: + type: + - string + - 'null' + void_reason: + type: + - string + - 'null' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + lines: + type: array + items: + $ref: '#/components/schemas/CreditMemoLine' + CreditMemoLine: + type: object + description: 'A line on a credit memo: the shared shape with a NEGATIVE quantity and line total (ADR 0001 section 7a; a plain sum is the AR effect), the invoice line it returns and whether it restocks.' + required: + - id + - position + - line_type + - parent_line_id + - product_id + - charge_code_id + - charge_code + - sku + - description + - quantity + - uom + - price_uom + - uom_qty + - price_uom_qty + - unit_price_ten_thousandths + - priced_unit_price_ten_thousandths + - price_source + - override_reason + - discount_percent + - discount_cents + - discount_reason + - price_adjusted_by + - line_total_cents + - taxable + - revenue_account_code + - is_special_order + - vendor_id + - special_order_unit_cost_ten_thousandths + - created_at + - invoice_line_id + - restock + - unit_cost_ten_thousandths + - cost_cents + properties: + id: + type: string + format: uuid + position: + type: integer + description: The line's place on the document; a kit's components follow it. + line_type: + $ref: '#/components/schemas/SalesLineType' + parent_line_id: + type: + - string + - 'null' + format: uuid + description: Set on a component line only, naming its kit line. + product_id: + type: + - string + - 'null' + format: uuid + description: Required on kit and component lines, optional on a product line (null is a non stock item), null on charge and text lines. + charge_code_id: + type: + - string + - 'null' + format: uuid + description: Set on a charge line only. + charge_code: + type: + - string + - 'null' + description: The charge code's code text, read. + sku: + type: + - string + - 'null' + description: A snapshot of the product's SKU. + description: + type: string + description: A snapshot; required on every line. + quantity: + type: + - string + - 'null' + description: The quantity as a decimal string with its unit; null only on a text line. + uom: + type: + - string + - 'null' + description: The sale unit. On a stocked line it is the product's stocking unit (ADR 0005 section 1). + price_uom: + type: + - string + - 'null' + description: The unit the price is per; equals uom unless the pair says otherwise. + uom_qty: + type: + - string + - 'null' + description: One side of the conversion pair; 1 and 1 when the units agree (any other pair on equal units is a 400). + price_uom_qty: + type: + - string + - 'null' + description: The other side of the conversion pair. + unit_price_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: The price charged per price unit, after any override, before any discount. + priced_unit_price_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: What the pricing engine resolved, read only, so an override is visible as the difference; null on text, charge and non stock lines. + price_source: + $ref: '#/components/schemas/SalesPriceSource' + override_reason: + type: + - string + - 'null' + description: Required when price_source is override. + discount_percent: + type: + - string + - 'null' + description: A percent at scale 4 as a decimal string, greater than 0 and at most 100; never with discount_cents. + discount_cents: + type: + - integer + - 'null' + format: int64 + description: A discount amount in cents, positive; never with discount_percent. + discount_reason: + type: + - string + - 'null' + description: Required with either discount. + price_adjusted_by: + type: + - string + - 'null' + description: The actor of the last override or discount; the audit row carries the rest. + line_total_cents: + type: + - integer + - 'null' + format: int64 + description: The extension, rounded once (ADR 0005 section 2.4); null only on a text line. + taxable: + type: boolean + revenue_account_code: + type: + - string + - 'null' + description: The charge code's account, snapshotted at create; null on every other line type. + is_special_order: + type: boolean + vendor_id: + type: + - string + - 'null' format: uuid - location: - type: string - description: Deprecated text field. - quantity: - type: number - allocated: - type: number - updated_at: + special_order_unit_cost_ten_thousandths: + type: + - integer + - 'null' + format: int64 + description: A unit cost at scale 4. + created_at: type: string format: date-time - StockAdjustmentRequest: - type: object - required: - - product_id - - quantity - properties: - product_id: - type: string - format: uuid - location_id: + invoice_line_id: type: - string - 'null' format: uuid - quantity: - type: number - description: The new quantity, or the delta when is_delta is true. - reason: - type: string - is_delta: + description: The invoice line this credit line returns; null on a free line (a price adjustment, a fee given back, a note). + restock: type: boolean - description: If true, add or subtract; if false, replace. - StockMovementRequest: + description: Whether the goods go back on hand when the credit memo is posted. + unit_cost_ten_thousandths: *id006 + cost_cents: + type: integer + format: int64 + description: 'The cost that comes back to inventory, in cents and NEGATIVE like the line (ADR 0005 8.4): COGS reverses at the original cost; 0 on a line that does not restock.' + CreditMemoLineRequest: type: object - required: - - product_id - - to_location_id - - quantity + description: 'One credit memo line. A line that names an invoice line (invoice_line_id) sends only the negative quantity credited, restock and optionally a description: its price, pair and discount come from the invoice line, and it cannot credit more than that line billed less what earlier credit memos returned (409 exceeds_billed). A free line names a product, a charge code or only a description and sends its own price; quantities are negative.' properties: - product_id: + id: type: string format: uuid - from_location_id: - type: - - string - - 'null' - format: uuid - to_location_id: + description: Keeps a line across edits. + invoice_line_id: type: string format: uuid - quantity: - type: number - reason: - type: string - InventoryStatusAck: - type: object - required: - - status - properties: - status: + line_type: type: string enum: - - ok - InvoiceStatus: - type: string - enum: [UNPAID, PARTIAL, PAID, VOID, OVERDUE] - PaymentTerms: - type: string - enum: [COD, DUE_ON_RECEIPT, NET30, NET60, NET90] - Invoice: - type: object - required: [id, branch_id, order_id, customer_id, status, subtotal, tax_rate, tax_amount, total_amount, payment_terms, due_date, paid_at, created_at, updated_at] - properties: - id: + - product + - charge + - text + product_id: type: string format: uuid - branch_id: + charge_code: type: string - format: uuid - order_id: + description: type: string - format: uuid - customer_id: + quantity: type: string - format: uuid - customer_name: + description: 'A NEGATIVE decimal string: the quantity returned or credited.' + uom: type: string - status: - $ref: "#/components/schemas/InvoiceStatus" - subtotal: - type: integer - format: int64 - description: Cents. - tax_rate: - type: number - description: "A fraction such as 0.0825 for 8.25 percent." - tax_amount: - type: integer - format: int64 - description: Cents. - total_amount: + unit_price_ten_thousandths: type: integer format: int64 - description: Cents, subtotal plus tax. - payment_terms: - $ref: "#/components/schemas/PaymentTerms" - due_date: - type: [string, "null"] - format: date-time - description: Null while the terms set no date. - paid_at: - type: [string, "null"] - format: date-time - description: Null until a payment completes the invoice. - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - lines: - type: array - items: - $ref: "#/components/schemas/InvoiceLine" - InvoiceLine: + description: Free lines only; never negative. + taxable: + type: boolean + restock: + type: boolean + description: Return the goods to stock at post (a stocked product line). + CreditMemoRequest: type: object - required: [id, invoice_id, product_id, quantity, price_each, created_at] + description: The body of POST /credit-memos and PUT /credit-memos/{id}. A create names an invoice (the customer, branch, currency, job and ship-to follow it) or a customer. branch_id, if sent, is held to the caller's branch like every payload branch. currency is never sent. + required: + - reason_code + - reason + - lines properties: - id: + branch_id: + type: string + format: uuid + customer_id: type: string format: uuid invoice_id: type: string format: uuid - product_id: + job_id: type: string format: uuid - product_sku: + ship_to_id: type: string - product_name: + format: uuid + reason_code: type: string - quantity: - type: number - price_each: + enum: + - return + - price_adjustment + - damage + - other + reason: + type: string + minLength: 1 + maxLength: 500 + revision: type: integer format: int64 - description: Cents. - created_at: - type: string - format: date-time - InvoicePage: + description: The precondition on a PUT, beside If-Match. + lines: + type: array + items: + $ref: '#/components/schemas/CreditMemoLineRequest' + CreditMemoPage: type: object - required: [data, total, limit, offset] + required: + - items + - next_cursor + - limit properties: - data: + items: type: array items: - $ref: "#/components/schemas/Invoice" - total: - type: integer + $ref: '#/components/schemas/CreditMemoSummary' + next_cursor: + type: + - string + - 'null' limit: type: integer - offset: - type: integer - CreditMemo: - type: object - required: [id, customer_id, amount, reason, status, created_at] - properties: - id: - type: string - format: uuid - invoice_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - amount: + total: type: integer - format: int64 - description: Cents. - reason: - type: string - status: - type: string - enum: [PENDING, APPLIED, VOID] - created_at: - type: string - format: date-time - applied_at: - type: string - format: date-time - CreditMemoCreateRequest: + description: Only under include=total. + CreditMemoTransitionRequest: type: object - required: [amount_cents, reason] + required: + - to + description: 'The body of POST /credit-memos/{id}/transitions: to open (post) or void, the revision and the reason.' properties: - amount_cents: + to: + $ref: '#/components/schemas/CreditMemoStatus' + revision: type: integer format: int64 - description: Cents; must be positive. + description: The precondition, beside If-Match. reason: type: string - CreditMemoList: - type: [array, "null"] - items: - $ref: "#/components/schemas/CreditMemo" - InvoiceEmailResponse: - type: object - required: [status] - properties: - status: - type: string - enum: [queued] + maxLength: 500 + description: Required by a void. LocationType: type: string enum: [BRANCH, ZONE, AISLE, RACK, SHELF, BIN, YARD] @@ -18032,16 +19352,23 @@ components: description: Float dollars. PortalInvoice: type: object - required: [id, order_id, status, total_amount, subtotal, tax_amount, payment_terms, due_date, paid_at, created_at, lines] + required: [id, number, is_overdue, order_id, status, total_amount, subtotal, tax_amount, payment_terms, due_date, paid_at, created_at, lines] properties: id: type: string format: uuid + number: + type: string + description: The invoice's gapless IN- number (C2-3). + is_overdue: + type: boolean + description: Computed, open and past its due date; overdue is no longer a status (C2-3). order_id: type: string format: uuid status: type: string + description: The stored status, uppercase (UNPAID, PARTIAL, PAID, VOID, WRITTEN_OFF); the portal keeps its legacy vocabulary. total_amount: type: number description: Float dollars. diff --git a/core/internal/apicontract/model_shape_test.go b/core/internal/apicontract/model_shape_test.go index 1f4bb5fd..9191c69a 100644 --- a/core/internal/apicontract/model_shape_test.go +++ b/core/internal/apicontract/model_shape_test.go @@ -88,8 +88,11 @@ var modelBoundSchemas = []struct { {"OrderLine", order.OrderLine{}}, // invoice {"Invoice", invoice.Invoice{}}, + {"InvoiceSummary", invoice.InvoiceSummary{}}, {"InvoiceLine", invoice.InvoiceLine{}}, {"CreditMemo", invoice.CreditMemo{}}, + {"CreditMemoSummary", invoice.CreditMemoSummary{}}, + {"CreditMemoLine", invoice.CreditLine{}}, // payment {"Payment", payment.Payment{}}, {"Refund", payment.Refund{}}, diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index 2cb51f1d..78ae8540 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -778,14 +778,13 @@ func Run() { orderCustMap[orderID] = custID invID := uuid.New() // A share of the invoices are paid in full, a share partly paid and a - // share left unpaid; some of the unpaid ones are past their due date - // (overdue is computed from the due date, never stored). + // share left unpaid; overdue is computed from the due date, never stored. invStatus := "UNPAID" partPaid := false - switch r := rand.Float32(); { - case r < 0.65: + if rand.Float32() < 0.65 { invStatus = "PAID" - case r < 0.75: + } else if rand.Float32() < 0.3 { + // formerly stored as OVERDUE: now a partly paid invoice invStatus, partPaid = "PARTIAL", true } dueDate := orderDate.AddDate(0, 1, 0) @@ -1237,8 +1236,8 @@ func Run() { var memoID string err := db.QueryRow(`INSERT INTO credit_memos (invoice_id, customer_id, branch_id, currency, reason_code, reason, amount, status, number, memo_date, subtotal, tax_amount, total_amount, tax_rate) - SELECT i.id, i.customer_id, i.branch_id, i.currency, $2, $3, $4::numeric, $5, - CASE WHEN $5 = 'DRAFT' THEN NULL ELSE credit_memo_next_number() END, + SELECT i.id, i.customer_id, i.branch_id, i.currency, $2::text, $3::text, $4::numeric, $5::text, + CASE WHEN $5::text = 'DRAFT' THEN NULL ELSE credit_memo_next_number() END, i.invoice_date, -$4::numeric, 0, -$4::numeric, 0 FROM invoices i WHERE i.id = $1 RETURNING id`, invID, m.ReasonCode, m.Reason, m.Amt, m.Status).Scan(&memoID) if err != nil { diff --git a/core/internal/characterization/fixtures_clock_test.go b/core/internal/characterization/fixtures_clock_test.go index 1d9315eb..edb83528 100644 --- a/core/internal/characterization/fixtures_clock_test.go +++ b/core/internal/characterization/fixtures_clock_test.go @@ -106,9 +106,9 @@ func seedClockWindowFixtures(t *testing.T, dbURL string) { at := stamp(inv.days) mustExec(t, db, `INSERT INTO invoices ( id, order_id, customer_id, status, total_amount, subtotal, tax_rate, tax_amount, - payment_terms, due_date, paid_at, created_at, updated_at, branch_id + due_date, paid_at, created_at, updated_at, branch_id ) VALUES ($1, NULL, $2, 'UNPAID', $3, $3, 0, 0, - 'NET30', $4, NULL, $5, $5, + $4, NULL, $5, $5, COALESCE(NULL::uuid, (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')))`, inv.id, fixtureCustomer, inv.total, at, at) // The shared line shape (ADR 0005 2.2): the fixture writes what the diff --git a/core/internal/characterization/scenarios_r1b_b_test.go b/core/internal/characterization/scenarios_r1b_b_test.go index ecebea5f..cfc07847 100644 --- a/core/internal/characterization/scenarios_r1b_b_test.go +++ b/core/internal/characterization/scenarios_r1b_b_test.go @@ -146,7 +146,7 @@ func r1bBBillingGroups() []groupDef { sql: `SELECT count(*) AS invoices FROM invoices WHERE order_id = '{b_invOrder}'::uuid`, setup: func(t *testing.T, h *harness) { goldenExec(t, h, `INSERT INTO invoices (id, order_id, customer_id, branch_id, status, - total_amount, subtotal, tax_rate, tax_amount, due_date, payment_terms, created_at, updated_at) + total_amount, subtotal, tax_rate, tax_amount, due_date, created_at, updated_at) VALUES (gen_random_uuid(), '{b_invOrder}'::uuid, (SELECT customer_id FROM orders WHERE id = '{b_invOrder}'::uuid), (SELECT branch_id FROM orders WHERE id = '{b_invOrder}'::uuid), 'UNPAID', @@ -154,10 +154,10 @@ func r1bBBillingGroups() []groupDef { ROUND((SELECT subtotal FROM orders WHERE id = '{b_invOrder}'::uuid), 2), COALESCE((SELECT tax_rate FROM orders WHERE id = '{b_invOrder}'::uuid), 0), ROUND((SELECT tax_amount FROM orders WHERE id = '{b_invOrder}'::uuid), 2), - CURRENT_DATE + 30, 'NET30', NOW(), NOW())`) + CURRENT_DATE + 30, NOW(), NOW())`) }}, {name: "portal_billing.erp_invoices", method: "GET", path: "/api/v1/invoices?limit=1", - extract: map[string]string{"b_invoice": "/data/0/id"}}, + extract: map[string]string{"b_invoice": "/items/0/id"}}, {name: "portal_billing.invoice.get", method: "GET", path: "/api/portal/v1/invoices/{b_invoice}"}, {name: "portal_billing.invoice.bad_id", method: "GET", path: "/api/portal/v1/invoices/not-a-uuid"}, {name: "portal_billing.invoice.unknown", method: "GET", path: "/api/portal/v1/invoices/" + bNoSuchID}, diff --git a/core/internal/characterization/scenarios_sales_test.go b/core/internal/characterization/scenarios_sales_test.go index f2c161c3..1fc14e9f 100644 --- a/core/internal/characterization/scenarios_sales_test.go +++ b/core/internal/characterization/scenarios_sales_test.go @@ -520,16 +520,65 @@ func invoiceGroups() []groupDef { path: "/api/v1/orders/fulfillment-requests/00000000-0000-0000-0000-0000000000aa/retry"}, // Newest invoice is the one fulfilment just created. {name: "invoice.list_newest", method: "GET", path: "/api/v1/invoices?limit=1", - extract: map[string]string{"myInvoice": "/data/0/id"}}, + extract: map[string]string{"myInvoice": "/items/0/id", "myInvoiceRevision": "/items/0/revision"}}, {name: "invoice.get", method: "GET", path: "/api/v1/invoices/{myInvoice}"}, + {name: "invoice.get.not_found", method: "GET", path: "/api/v1/invoices/00000000-0000-0000-0000-0000000000aa"}, + // The list's filters filter; an unknown parameter, the legacy + // OVERDUE status and a malformed flag are refused. + {name: "invoice.list.open_not_overdue", method: "GET", path: "/api/v1/invoices?status=unpaid&overdue=false&limit=1&include=total", + maskBody: true}, + {name: "invoice.list.overdue_status_gone", method: "GET", path: "/api/v1/invoices?status=overdue"}, + {name: "invoice.list.bad_overdue", method: "GET", path: "/api/v1/invoices?overdue=maybe"}, + {name: "invoice.list.unsupported_parameter", method: "GET", path: "/api/v1/invoices?offset=1"}, + // A status other than void is derived by payments and credit memos. + {name: "invoice.transition.derived_status", method: "POST", path: "/api/v1/invoices/{myInvoice}/transitions", + headers: map[string]string{"If-Match": `"{myInvoiceRevision}"`}, body: map[string]any{"to": "paid"}}, + {name: "invoice.transition.void_needs_reason", method: "POST", path: "/api/v1/invoices/{myInvoice}/transitions", + headers: map[string]string{"If-Match": `"{myInvoiceRevision}"`}, body: map[string]any{"to": "void"}}, + {name: "invoice.transition.void_stale", method: "POST", path: "/api/v1/invoices/{myInvoice}/transitions", + headers: map[string]string{"If-Match": `"99"`}, body: map[string]any{"to": "void", "reason": "stale"}}, + // The credit memo: a draft with no number, edited, posted (it takes + // the next gapless CM- number), blocking the invoice's void while + // it stands, then voided. { - name: "invoice.credit_memo.create", + name: "credit_memo.create", method: "POST", - path: "/api/v1/invoices/{myInvoice}/credit-memo", - body: map[string]any{"amount_cents": 500, "reason": "golden characterisation"}, + path: "/api/v1/credit-memos", + body: map[string]any{"invoice_id": "{myInvoice}", "reason_code": "price_adjustment", "reason": "golden characterisation", + "lines": []map[string]any{{"line_type": "charge", "charge_code": "ADJUST", "quantity": "-1", + "unit_price_ten_thousandths": 5000, "description": "Price adjustment"}}}, + extract: map[string]string{"myCreditMemo": "/id"}, }, - {name: "invoice.credit_memo.list", method: "GET", path: "/api/v1/credit-memos/{myCustomer}"}, - {name: "invoice.get.not_found", method: "GET", path: "/api/v1/invoices/00000000-0000-0000-0000-0000000000aa"}, + {name: "credit_memo.get", method: "GET", path: "/api/v1/credit-memos/{myCreditMemo}"}, + {name: "credit_memo.create.invalid", method: "POST", path: "/api/v1/credit-memos", + body: map[string]any{"invoice_id": "{myInvoice}", "reason_code": "refund", "lines": []map[string]any{{"quantity": "1"}}}}, + {name: "credit_memo.update.stale", method: "PUT", path: "/api/v1/credit-memos/{myCreditMemo}", + headers: map[string]string{"If-Match": `"9"`}, + body: map[string]any{"invoice_id": "{myInvoice}", "reason_code": "price_adjustment", "reason": "edited", + "lines": []map[string]any{{"line_type": "charge", "charge_code": "ADJUST", "quantity": "-1", "unit_price_ten_thousandths": 5000}}}}, + {name: "credit_memo.update", method: "PUT", path: "/api/v1/credit-memos/{myCreditMemo}", + headers: map[string]string{"If-Match": `"1"`}, + body: map[string]any{"invoice_id": "{myInvoice}", "reason_code": "price_adjustment", "reason": "edited", + "lines": []map[string]any{{"line_type": "charge", "charge_code": "ADJUST", "quantity": "-1", "unit_price_ten_thousandths": 7500, + "description": "Price adjustment"}}}}, + {name: "credit_memo.post", method: "POST", path: "/api/v1/credit-memos/{myCreditMemo}/transitions", + headers: map[string]string{"If-Match": `"2"`}, body: map[string]any{"to": "open"}}, + {name: "credit_memo.post.again", method: "POST", path: "/api/v1/credit-memos/{myCreditMemo}/transitions", + headers: map[string]string{"If-Match": `"3"`}, body: map[string]any{"to": "open"}}, + {name: "credit_memo.update.not_draft", method: "PUT", path: "/api/v1/credit-memos/{myCreditMemo}", + headers: map[string]string{"If-Match": `"3"`}, + body: map[string]any{"invoice_id": "{myInvoice}", "reason_code": "price_adjustment", "reason": "late", + "lines": []map[string]any{{"line_type": "charge", "charge_code": "ADJUST", "quantity": "-1", "unit_price_ten_thousandths": 5000}}}}, + {name: "invoice.transition.void_refused_credit_memo", method: "POST", path: "/api/v1/invoices/{myInvoice}/transitions", + headers: map[string]string{"If-Match": `"{myInvoiceRevision}"`}, body: map[string]any{"to": "void", "reason": "has a credit memo"}}, + {name: "credit_memo.list", method: "GET", path: "/api/v1/credit-memos?invoice_id={myInvoice}&status=open"}, + {name: "credit_memo.list.unsupported_parameter", method: "GET", path: "/api/v1/credit-memos?customer=none"}, + {name: "credit_memo.void", method: "POST", path: "/api/v1/credit-memos/{myCreditMemo}/transitions", + headers: map[string]string{"If-Match": `"3"`}, body: map[string]any{"to": "void", "reason": "golden void"}}, + {name: "credit_memo.get.not_found", method: "GET", path: "/api/v1/credit-memos/00000000-0000-0000-0000-0000000000aa"}, + // The module's events, read back from the feed (ADR 0003). + {name: "invoice.events", method: "GET", path: "/api/v1/events?types=invoice.created,invoice.voided&limit=1"}, + {name: "credit_memo.events", method: "GET", path: "/api/v1/events?types=credit_memo.created,credit_memo.updated,credit_memo.posted,credit_memo.voided&limit=10"}, }, }} } diff --git a/core/internal/characterization/testdata/goldens/account.json b/core/internal/characterization/testdata/goldens/account.json index 846158aa..87c82a00 100644 --- a/core/internal/characterization/testdata/goldens/account.json +++ b/core/internal/characterization/testdata/goldens/account.json @@ -9,8 +9,8 @@ "status": 200, "content_type": "application/json", "body": { - "available_credit": 9998358, - "balance_due": 1642, + "available_credit": 9997858, + "balance_due": 2142, "credit_limit": 10000000, "customer_id": "" } @@ -28,7 +28,7 @@ "body": [ { "amount": -1000, - "balance_after": 1642, + "balance_after": 2142, "created_at": "", "customer_id": "", "description": "Payment GOLD-PAY-1", @@ -37,23 +37,33 @@ "type": "PAYMENT" }, { - "amount": -500, - "balance_after": 2642, + "amount": 75, + "balance_after": 3142, "created_at": "", "customer_id": "", - "description": "Credit Memo: golden characterisation", + "description": "Void of credit memo CM-000003", "id": "", "reference_id": "", - "type": "REFUND" + "type": "REVERSAL" + }, + { + "amount": -75, + "balance_after": 3067, + "created_at": "", + "customer_id": "", + "description": "Credit memo CM-000003", + "id": "", + "reference_id": "", + "type": "CREDIT_MEMO" }, { "amount": 3142, "balance_after": 3142, "created_at": "", "customer_id": "", - "description": "Invoice #", + "description": "Invoice IN-000051", "id": "", - "reference_id": "", + "reference_id": "", "type": "INVOICE" } ] diff --git a/core/internal/characterization/testdata/goldens/clockwindow.json b/core/internal/characterization/testdata/goldens/clockwindow.json index a4c5a002..1ec25a5c 100644 --- a/core/internal/characterization/testdata/goldens/clockwindow.json +++ b/core/internal/characterization/testdata/goldens/clockwindow.json @@ -139,7 +139,7 @@ "invoice_count": 11, "outstanding_ar": 23324.76, "start_date": "", - "total_collected": 308657, + "total_collected": 315854.09, "total_invoiced": 76391.14 } } @@ -158,8 +158,8 @@ "outstanding_ar": 9984277, "outstanding_ar_count": 15, "pending_dispatch": 28, - "today_revenue": 30623276, - "today_revenue_change": 303070.7355707356 + "today_revenue": 31342985, + "today_revenue_change": 310195.8617958618 } } }, @@ -187,7 +187,7 @@ }, { "date": "", - "revenue": 30623276 + "revenue": 31342985 } ] } @@ -352,8 +352,8 @@ "account_id": "", "account_name": "Accounts Receivable", "account_type": "ASSET", - "credit": 0, - "debit": 3142 + "credit": 75, + "debit": 3217 }, { "account_code": "1030", @@ -384,8 +384,8 @@ "account_id": "", "account_name": "Sales Revenue", "account_type": "REVENUE", - "credit": 12805, - "debit": 0 + "credit": 12880, + "debit": 75 }, { "account_code": "5010", diff --git a/core/internal/characterization/testdata/goldens/document_invoice.json b/core/internal/characterization/testdata/goldens/document_invoice.json index 3881f0c8..ac36b97a 100644 --- a/core/internal/characterization/testdata/goldens/document_invoice.json +++ b/core/internal/characterization/testdata/goldens/document_invoice.json @@ -10,8 +10,8 @@ "content_type": "application/pdf", "body": { "binary": { - "length": 5039, - "text_sha256": "82ca80eb3e08f315551e96c663e582debe9ee56edfd65cd67cbf889022a15b36" + "length": 4930, + "text_sha256": "2621dfd7eb1067c5b6aca7db9b034b8dd868cfcb8470059efe06d566324e4af7" } } } diff --git a/core/internal/characterization/testdata/goldens/gl_accounts_entries.json b/core/internal/characterization/testdata/goldens/gl_accounts_entries.json index 5f68059c..293f52b3 100644 --- a/core/internal/characterization/testdata/goldens/gl_accounts_entries.json +++ b/core/internal/characterization/testdata/goldens/gl_accounts_entries.json @@ -263,7 +263,7 @@ "body": { "created_at": "", "entry_date": "", - "entry_number": 9, + "entry_number": 11, "id": "", "lines": [ { @@ -337,7 +337,7 @@ "body": { "created_at": "", "entry_date": "", - "entry_number": 10, + "entry_number": 12, "id": "", "lines": [ { @@ -436,7 +436,7 @@ "body": { "created_at": "", "entry_date": "", - "entry_number": 9, + "entry_number": 11, "id": "", "lines": [ { @@ -562,7 +562,7 @@ "body": { "created_at": "", "entry_date": "", - "entry_number": 11, + "entry_number": 13, "id": "", "lines": [ { @@ -586,7 +586,7 @@ "journal_entry_id": "" } ], - "memo": "Reversal of #9: golden reversal", + "memo": "Reversal of #11: golden reversal", "posted_by": "system", "source": "REVERSAL", "source_ref_id": "", @@ -724,9 +724,9 @@ { "created_at": "", "entry_date": "", - "entry_number": 11, + "entry_number": 13, "id": "", - "memo": "Reversal of #9: golden reversal", + "memo": "Reversal of #11: golden reversal", "posted_by": "system", "source": "REVERSAL", "source_ref_id": "", @@ -738,7 +738,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 10, + "entry_number": 12, "id": "", "memo": "golden void source", "posted_by": "", @@ -751,7 +751,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 9, + "entry_number": 11, "id": "", "memo": "golden reverse source", "posted_by": "system", @@ -764,7 +764,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 8, + "entry_number": 10, "id": "", "memo": "Vendor Payment ", "posted_by": "system", @@ -778,7 +778,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 7, + "entry_number": 9, "id": "", "memo": "Vendor Payment ", "posted_by": "system", @@ -792,7 +792,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 6, + "entry_number": 8, "id": "", "memo": "Customer deposit applied to AR", "posted_by": "system", @@ -806,7 +806,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 5, + "entry_number": 7, "id": "", "memo": "Customer deposit applied to AR", "posted_by": "system", @@ -820,7 +820,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 4, + "entry_number": 6, "id": "", "memo": "Customer deposit ", "posted_by": "system", @@ -834,7 +834,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 3, + "entry_number": 5, "id": "", "memo": "Customer deposit ", "posted_by": "system", @@ -848,13 +848,41 @@ { "created_at": "", "entry_date": "", - "entry_number": 2, + "entry_number": 4, "id": "", - "memo": "Invoice ", + "memo": "Reversal of #3: credit memo voided", "posted_by": "system", - "source": "INVOICE", + "source": "REVERSAL", "source_ref_id": "", "status": "POSTED", + "total_credit": 75, + "total_debit": 75, + "updated_at": "" + }, + { + "created_at": "", + "entry_date": "", + "entry_number": 3, + "id": "", + "memo": "Credit memo CM-000003", + "posted_by": "system", + "source": "CREDIT_MEMO", + "source_ref_id": "", + "status": "POSTED", + "total_credit": 75, + "total_debit": 75, + "updated_at": "" + }, + { + "created_at": "", + "entry_date": "", + "entry_number": 2, + "id": "", + "memo": "Invoice IN-000051", + "posted_by": "system", + "source": "INVOICE", + "source_ref_id": "", + "status": "POSTED", "total_credit": 5242, "total_debit": 5242, "updated_at": "" @@ -863,7 +891,7 @@ "created_at": "", "entry_date": "", "entry_number": 1, - "id": "", + "id": "", "memo": "golden characterisation entry", "posted_by": "system", "source": "MANUAL", diff --git a/core/internal/characterization/testdata/goldens/invoice.json b/core/internal/characterization/testdata/goldens/invoice.json index b3527038..49553ab5 100644 --- a/core/internal/characterization/testdata/goldens/invoice.json +++ b/core/internal/characterization/testdata/goldens/invoice.json @@ -436,28 +436,47 @@ "status": 200, "content_type": "application/json", "body": { - "data": [ + "items": [ { "branch_id": "", "created_at": "", + "currency": "USD", "customer_id": "", "customer_name": "Golden Harness Co", - "due_date": "", + "delivery_id": null, + "delivery_type": "delivery", + "discount_due_date": null, + "discount_percent": null, + "due_date": "", + "gl_entry_id": "", "id": "", + "invoice_date": "", + "is_overdue": false, + "job_id": null, + "number": "IN-000051", + "open_cents": 3142, "order_id": "", + "origin": "order", "paid_at": null, - "payment_terms": "NET30", - "status": "UNPAID", - "subtotal": 2805, - "tax_amount": 337, - "tax_rate": 0.12, - "total_amount": 3142, - "updated_at": "" + "payment_terms_id": "", + "picked_up_by": null, + "revision": 1, + "ship_to_id": null, + "status": "unpaid", + "subtotal_cents": 2805, + "tax_cents": 337, + "tax_exempt": false, + "tax_rate_percent": "12", + "tax_source": "branch_rate", + "total_cents": 3142, + "updated_at": "", + "void_reason": null, + "voided_at": null, + "voided_by": null } ], "limit": 1, - "offset": 0, - "total": 51 + "next_cursor": "" } } }, @@ -473,100 +492,1118 @@ "body": { "branch_id": "", "created_at": "", + "currency": "USD", "customer_id": "", "customer_name": "Golden Harness Co", - "due_date": "", + "delivery_id": null, + "delivery_type": "delivery", + "discount_due_date": null, + "discount_percent": null, + "due_date": "", + "gl_entry_id": "", "id": "", + "invoice_date": "", + "is_overdue": false, + "job_id": null, "lines": [ { + "charge_code": null, + "charge_code_id": null, + "cost_cents": 2100, "created_at": "", - "id": "", - "invoice_id": "", - "price_each": 468, + "description": "2x4x8 SPF Premium", + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 2805, + "line_type": "product", + "order_line_id": "", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 46750, "product_id": "", - "product_name": "2x4x8 SPF Premium", - "product_sku": "LUM-248-PREM", - "quantity": 6 + "quantity": "6", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_cost_ten_thousandths": 35000, + "unit_price_ten_thousandths": 46750, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "IN-000051", + "open_cents": 3142, "order_id": "", + "origin": "order", "paid_at": null, - "payment_terms": "NET30", - "status": "UNPAID", - "subtotal": 2805, - "tax_amount": 337, - "tax_rate": 0.12, - "total_amount": 3142, - "updated_at": "" + "payment_terms_id": "", + "picked_up_by": null, + "revision": 1, + "ship_to": null, + "ship_to_id": null, + "status": "unpaid", + "subtotal_cents": 2805, + "tax_cents": 337, + "tax_exempt": false, + "tax_rate_percent": "12", + "tax_source": "branch_rate", + "total_cents": 3142, + "updated_at": "", + "void_reason": null, + "voided_at": null, + "voided_by": null } } }, { - "name": "invoice.credit_memo.create", + "name": "invoice.get.not_found", "request": { - "method": "POST", - "path": "/api/v1/invoices//credit-memo", + "method": "GET", + "path": "/api/v1/invoices/" + }, + "response": { + "status": 404, + "content_type": "application/json", "body": { - "amount_cents": 500, - "reason": "golden characterisation" + "error": { + "code": "not_found", + "message": "invoice not found" + }, + "meta": { + "request_id": "" + } } + } + }, + { + "name": "invoice.list.open_not_overdue", + "mask_body": true, + "request": { + "method": "GET", + "path": "/api/v1/invoices?status=unpaid&overdue=false&limit=1&include=total" }, "response": { - "status": 201, + "status": 200, + "content_type": "application/json", + "body": "" + } + }, + { + "name": "invoice.list.overdue_status_gone", + "request": { + "method": "GET", + "path": "/api/v1/invoices?status=overdue" + }, + "response": { + "status": 400, "content_type": "application/json", "body": { - "amount": 500, - "applied_at": "", - "created_at": "", - "customer_id": "", - "id": "", - "invoice_id": "", - "reason": "golden characterisation", - "status": "APPLIED" + "error": { + "code": "validation_failed", + "details": [ + { + "field": "status", + "message": "must be a comma separated list of: unpaid, partial, paid, void, written_off" + } + ], + "message": "one or more fields failed validation" + }, + "meta": { + "request_id": "" + } } } }, { - "name": "invoice.credit_memo.list", + "name": "invoice.list.bad_overdue", "request": { "method": "GET", - "path": "/api/v1/credit-memos/" + "path": "/api/v1/invoices?overdue=maybe" }, "response": { - "status": 200, + "status": 400, "content_type": "application/json", - "body": [ - { - "amount": 500, - "applied_at": "", - "created_at": "", - "customer_id": "", - "id": "", - "invoice_id": "", - "reason": "golden characterisation", - "status": "APPLIED" + "body": { + "error": { + "code": "validation_failed", + "details": [ + { + "field": "overdue", + "message": "must be true or false" + } + ], + "message": "one or more fields failed validation" + }, + "meta": { + "request_id": "" } - ] + } } }, { - "name": "invoice.get.not_found", + "name": "invoice.list.unsupported_parameter", "request": { "method": "GET", - "path": "/api/v1/invoices/" + "path": "/api/v1/invoices?offset=1" }, "response": { - "status": 404, + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "unsupported_query_parameter", + "details": [ + { + "field": "offset", + "message": "unsupported query parameter" + } + ], + "message": "one or more query parameters are not supported by this route" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "invoice.transition.derived_status", + "request": { + "method": "POST", + "path": "/api/v1/invoices//transitions", + "headers": { + "If-Match": "\"1\"" + }, + "body": { + "to": "paid" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "invalid_state_transition", + "message": "an invoice is moved to paid by its payments and credit memos, not by a transition; the only transition is void" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "invoice.transition.void_needs_reason", + "request": { + "method": "POST", + "path": "/api/v1/invoices//transitions", + "headers": { + "If-Match": "\"1\"" + }, + "body": { + "to": "void" + } + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "bad_request", + "details": [ + { + "field": "reason", + "message": "is required to void an invoice" + } + ], + "message": "one or more fields failed validation" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "invoice.transition.void_stale", + "request": { + "method": "POST", + "path": "/api/v1/invoices//transitions", + "headers": { + "If-Match": "\"99\"" + }, + "body": { + "reason": "stale", + "to": "void" + } + }, + "response": { + "status": 409, "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "stale_revision", + "message": "the document was changed after this revision was read; reload and retry" }, "meta": { - "request_id": "" + "request_id": "" } } } + }, + { + "name": "credit_memo.create", + "request": { + "method": "POST", + "path": "/api/v1/credit-memos", + "body": { + "invoice_id": "", + "lines": [ + { + "charge_code": "ADJUST", + "description": "Price adjustment", + "line_type": "charge", + "quantity": "-1", + "unit_price_ten_thousandths": 5000 + } + ], + "reason": "golden characterisation", + "reason_code": "price_adjustment" + } + }, + "response": { + "status": 201, + "content_type": "application/json", + "body": { + "branch_id": "", + "created_at": "", + "currency": "USD", + "customer_id": "", + "customer_name": "Golden Harness Co", + "gl_entry_id": null, + "id": "", + "invoice_id": "", + "job_id": null, + "lines": [ + { + "charge_code": "ADJUST", + "charge_code_id": "", + "cost_cents": 0, + "created_at": "", + "description": "Price adjustment", + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "invoice_line_id": null, + "is_special_order": false, + "line_total_cents": -50, + "line_type": "charge", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "manual", + "price_uom": "EA", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": null, + "product_id": null, + "quantity": "-1", + "restock": false, + "revenue_account_code": "4010", + "sku": null, + "special_order_unit_cost_ten_thousandths": null, + "taxable": false, + "unit_cost_ten_thousandths": null, + "unit_price_ten_thousandths": 5000, + "uom": "EA", + "uom_qty": "1", + "vendor_id": null + } + ], + "memo_date": "", + "number": null, + "open_cents": 0, + "pos_return_id": null, + "reason": "golden characterisation", + "reason_code": "price_adjustment", + "revision": 1, + "ship_to_id": null, + "status": "draft", + "subtotal_cents": -50, + "tax_cents": 0, + "tax_rate_percent": "12", + "total_cents": -50, + "updated_at": "", + "void_reason": null, + "voided_at": null, + "voided_by": null + } + } + }, + { + "name": "credit_memo.get", + "request": { + "method": "GET", + "path": "/api/v1/credit-memos/" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "branch_id": "", + "created_at": "", + "currency": "USD", + "customer_id": "", + "customer_name": "Golden Harness Co", + "gl_entry_id": null, + "id": "", + "invoice_id": "", + "job_id": null, + "lines": [ + { + "charge_code": "ADJUST", + "charge_code_id": "", + "cost_cents": 0, + "created_at": "", + "description": "Price adjustment", + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "invoice_line_id": null, + "is_special_order": false, + "line_total_cents": -50, + "line_type": "charge", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "manual", + "price_uom": "EA", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": null, + "product_id": null, + "quantity": "-1", + "restock": false, + "revenue_account_code": "4010", + "sku": null, + "special_order_unit_cost_ten_thousandths": null, + "taxable": false, + "unit_cost_ten_thousandths": null, + "unit_price_ten_thousandths": 5000, + "uom": "EA", + "uom_qty": "1", + "vendor_id": null + } + ], + "memo_date": "", + "number": null, + "open_cents": 0, + "pos_return_id": null, + "reason": "golden characterisation", + "reason_code": "price_adjustment", + "revision": 1, + "ship_to_id": null, + "status": "draft", + "subtotal_cents": -50, + "tax_cents": 0, + "tax_rate_percent": "12", + "total_cents": -50, + "updated_at": "", + "void_reason": null, + "voided_at": null, + "voided_by": null + } + } + }, + { + "name": "credit_memo.create.invalid", + "request": { + "method": "POST", + "path": "/api/v1/credit-memos", + "body": { + "invoice_id": "", + "lines": [ + { + "quantity": "1" + } + ], + "reason_code": "refund" + } + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "validation_failed", + "details": [ + { + "field": "reason_code", + "message": "must be one of: return, price_adjustment, damage, other" + }, + { + "field": "reason", + "message": "is required" + }, + { + "field": "lines[0].description", + "message": "is required when the line names no product" + }, + { + "field": "lines[0].quantity", + "message": "must be negative on a credit memo line: it is the quantity credited" + }, + { + "field": "lines[0].uom", + "message": "is required when the line names no product" + }, + { + "field": "lines[0].unit_price_ten_thousandths", + "message": "is required on a free product line: the credit memo prices what it gives back" + } + ], + "message": "one or more fields failed validation" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "credit_memo.update.stale", + "request": { + "method": "PUT", + "path": "/api/v1/credit-memos/", + "headers": { + "If-Match": "\"9\"" + }, + "body": { + "invoice_id": "", + "lines": [ + { + "charge_code": "ADJUST", + "line_type": "charge", + "quantity": "-1", + "unit_price_ten_thousandths": 5000 + } + ], + "reason": "edited", + "reason_code": "price_adjustment" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "stale_revision", + "message": "the document was changed after this revision was read; reload and retry" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "credit_memo.update", + "request": { + "method": "PUT", + "path": "/api/v1/credit-memos/", + "headers": { + "If-Match": "\"1\"" + }, + "body": { + "invoice_id": "", + "lines": [ + { + "charge_code": "ADJUST", + "description": "Price adjustment", + "line_type": "charge", + "quantity": "-1", + "unit_price_ten_thousandths": 7500 + } + ], + "reason": "edited", + "reason_code": "price_adjustment" + } + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "branch_id": "", + "created_at": "", + "currency": "USD", + "customer_id": "", + "customer_name": "Golden Harness Co", + "gl_entry_id": null, + "id": "", + "invoice_id": "", + "job_id": null, + "lines": [ + { + "charge_code": "ADJUST", + "charge_code_id": "", + "cost_cents": 0, + "created_at": "", + "description": "Price adjustment", + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "invoice_line_id": null, + "is_special_order": false, + "line_total_cents": -75, + "line_type": "charge", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "manual", + "price_uom": "EA", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": null, + "product_id": null, + "quantity": "-1", + "restock": false, + "revenue_account_code": "4010", + "sku": null, + "special_order_unit_cost_ten_thousandths": null, + "taxable": false, + "unit_cost_ten_thousandths": null, + "unit_price_ten_thousandths": 7500, + "uom": "EA", + "uom_qty": "1", + "vendor_id": null + } + ], + "memo_date": "", + "number": null, + "open_cents": 0, + "pos_return_id": null, + "reason": "edited", + "reason_code": "price_adjustment", + "revision": 2, + "ship_to_id": null, + "status": "draft", + "subtotal_cents": -75, + "tax_cents": 0, + "tax_rate_percent": "12", + "total_cents": -75, + "updated_at": "", + "void_reason": null, + "voided_at": null, + "voided_by": null + } + } + }, + { + "name": "credit_memo.post", + "request": { + "method": "POST", + "path": "/api/v1/credit-memos//transitions", + "headers": { + "If-Match": "\"2\"" + }, + "body": { + "to": "open" + } + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "branch_id": "", + "created_at": "", + "currency": "USD", + "customer_id": "", + "customer_name": "Golden Harness Co", + "gl_entry_id": "", + "id": "", + "invoice_id": "", + "job_id": null, + "lines": [ + { + "charge_code": "ADJUST", + "charge_code_id": "", + "cost_cents": 0, + "created_at": "", + "description": "Price adjustment", + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "invoice_line_id": null, + "is_special_order": false, + "line_total_cents": -75, + "line_type": "charge", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "manual", + "price_uom": "EA", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": null, + "product_id": null, + "quantity": "-1", + "restock": false, + "revenue_account_code": "4010", + "sku": null, + "special_order_unit_cost_ten_thousandths": null, + "taxable": false, + "unit_cost_ten_thousandths": null, + "unit_price_ten_thousandths": 7500, + "uom": "EA", + "uom_qty": "1", + "vendor_id": null + } + ], + "memo_date": "", + "number": "CM-000003", + "open_cents": -75, + "pos_return_id": null, + "reason": "edited", + "reason_code": "price_adjustment", + "revision": 3, + "ship_to_id": null, + "status": "open", + "subtotal_cents": -75, + "tax_cents": 0, + "tax_rate_percent": "12", + "total_cents": -75, + "updated_at": "", + "void_reason": null, + "voided_at": null, + "voided_by": null + } + } + }, + { + "name": "credit_memo.post.again", + "request": { + "method": "POST", + "path": "/api/v1/credit-memos//transitions", + "headers": { + "If-Match": "\"3\"" + }, + "body": { + "to": "open" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "invalid_state_transition", + "message": "only a draft credit memo can be posted: it is open" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "credit_memo.update.not_draft", + "request": { + "method": "PUT", + "path": "/api/v1/credit-memos/", + "headers": { + "If-Match": "\"3\"" + }, + "body": { + "invoice_id": "", + "lines": [ + { + "charge_code": "ADJUST", + "line_type": "charge", + "quantity": "-1", + "unit_price_ten_thousandths": 5000 + } + ], + "reason": "late", + "reason_code": "price_adjustment" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "conflict", + "details": [ + { + "code": "credit_memo_not_draft", + "message": "only a draft credit memo can be edited: it is open" + } + ], + "message": "only a draft credit memo can be edited: it is open" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "invoice.transition.void_refused_credit_memo", + "request": { + "method": "POST", + "path": "/api/v1/invoices//transitions", + "headers": { + "If-Match": "\"1\"" + }, + "body": { + "reason": "has a credit memo", + "to": "void" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "conflict", + "details": [ + { + "code": "has_credit_memos", + "message": "a credit memo names the invoice: void the credit memos first" + } + ], + "message": "a credit memo names the invoice: void the credit memos first" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "credit_memo.list", + "request": { + "method": "GET", + "path": "/api/v1/credit-memos?invoice_id=&status=open" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "items": [ + { + "branch_id": "", + "created_at": "", + "currency": "USD", + "customer_id": "", + "customer_name": "Golden Harness Co", + "gl_entry_id": "", + "id": "", + "invoice_id": "", + "job_id": null, + "memo_date": "", + "number": "CM-000003", + "open_cents": -75, + "pos_return_id": null, + "reason": "edited", + "reason_code": "price_adjustment", + "revision": 3, + "ship_to_id": null, + "status": "open", + "subtotal_cents": -75, + "tax_cents": 0, + "tax_rate_percent": "12", + "total_cents": -75, + "updated_at": "", + "void_reason": null, + "voided_at": null, + "voided_by": null + } + ], + "limit": 50, + "next_cursor": null + } + } + }, + { + "name": "credit_memo.list.unsupported_parameter", + "request": { + "method": "GET", + "path": "/api/v1/credit-memos?customer=none" + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "unsupported_query_parameter", + "details": [ + { + "field": "customer", + "message": "unsupported query parameter" + } + ], + "message": "one or more query parameters are not supported by this route" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "credit_memo.void", + "request": { + "method": "POST", + "path": "/api/v1/credit-memos//transitions", + "headers": { + "If-Match": "\"3\"" + }, + "body": { + "reason": "golden void", + "to": "void" + } + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "branch_id": "", + "created_at": "", + "currency": "USD", + "customer_id": "", + "customer_name": "Golden Harness Co", + "gl_entry_id": "", + "id": "", + "invoice_id": "", + "job_id": null, + "lines": [ + { + "charge_code": "ADJUST", + "charge_code_id": "", + "cost_cents": 0, + "created_at": "", + "description": "Price adjustment", + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "invoice_line_id": null, + "is_special_order": false, + "line_total_cents": -75, + "line_type": "charge", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "manual", + "price_uom": "EA", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": null, + "product_id": null, + "quantity": "-1", + "restock": false, + "revenue_account_code": "4010", + "sku": null, + "special_order_unit_cost_ten_thousandths": null, + "taxable": false, + "unit_cost_ten_thousandths": null, + "unit_price_ten_thousandths": 7500, + "uom": "EA", + "uom_qty": "1", + "vendor_id": null + } + ], + "memo_date": "", + "number": "CM-000003", + "open_cents": 0, + "pos_return_id": null, + "reason": "edited", + "reason_code": "price_adjustment", + "revision": 4, + "ship_to_id": null, + "status": "void", + "subtotal_cents": -75, + "tax_cents": 0, + "tax_rate_percent": "12", + "total_cents": -75, + "updated_at": "", + "void_reason": "golden void", + "voided_at": "", + "voided_by": null + } + } + }, + { + "name": "credit_memo.get.not_found", + "request": { + "method": "GET", + "path": "/api/v1/credit-memos/" + }, + "response": { + "status": 404, + "content_type": "application/json", + "body": { + "error": { + "code": "not_found", + "message": "credit memo not found" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "invoice.events", + "request": { + "method": "GET", + "path": "/api/v1/events?types=invoice.created,invoice.voided&limit=1" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "items": [ + { + "at": "", + "branch_id": "", + "data": { + "currency": "USD", + "customer_id": "", + "delivery_id": null, + "number": "IN-000051", + "order_id": "", + "status": "unpaid", + "subtotal_cents": 2805, + "tax_cents": 337, + "total_cents": 3142 + }, + "entity": { + "id": "", + "kind": "invoice" + }, + "event_id": "", + "org": "default", + "type": "invoice.created" + } + ], + "limit": 1, + "next_cursor": "" + } + } + }, + { + "name": "credit_memo.events", + "request": { + "method": "GET", + "path": "/api/v1/events?types=credit_memo.created,credit_memo.updated,credit_memo.posted,credit_memo.voided&limit=10" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "items": [ + { + "at": "", + "branch_id": "", + "data": { + "currency": "USD", + "customer_id": "", + "invoice_id": "", + "number": null, + "revision": 1, + "status": "draft", + "total_cents": -50 + }, + "entity": { + "id": "", + "kind": "credit_memo" + }, + "event_id": "", + "org": "default", + "type": "credit_memo.created" + }, + { + "at": "", + "branch_id": "", + "data": { + "currency": "USD", + "customer_id": "", + "invoice_id": "", + "number": null, + "revision": 2, + "status": "draft", + "total_cents": -75 + }, + "entity": { + "id": "", + "kind": "credit_memo" + }, + "event_id": "", + "org": "default", + "type": "credit_memo.updated" + }, + { + "at": "", + "branch_id": "", + "data": { + "currency": "USD", + "customer_id": "", + "from_status": "draft", + "invoice_id": "", + "number": "CM-000003", + "revision": 3, + "status": "open", + "total_cents": -75 + }, + "entity": { + "id": "", + "kind": "credit_memo" + }, + "event_id": "", + "org": "default", + "type": "credit_memo.posted" + }, + { + "at": "", + "branch_id": "", + "data": { + "currency": "USD", + "customer_id": "", + "from_status": "open", + "invoice_id": "", + "number": "CM-000003", + "revision": 4, + "status": "void", + "total_cents": -75 + }, + "entity": { + "id": "", + "kind": "credit_memo" + }, + "event_id": "", + "org": "default", + "type": "credit_memo.voided" + } + ], + "limit": 10, + "next_cursor": "" + } + } } ] diff --git a/core/internal/characterization/testdata/goldens/portal_billing.json b/core/internal/characterization/testdata/goldens/portal_billing.json index 9195a509..87ac1a91 100644 --- a/core/internal/characterization/testdata/goldens/portal_billing.json +++ b/core/internal/characterization/testdata/goldens/portal_billing.json @@ -255,28 +255,47 @@ "status": 200, "content_type": "application/json", "body": { - "data": [ + "items": [ { "branch_id": "", "created_at": "", + "currency": "USD", "customer_id": "", "customer_name": "Kelbrook Construction", - "due_date": "", + "delivery_id": null, + "delivery_type": "pickup", + "discount_due_date": null, + "discount_percent": null, + "due_date": "", + "gl_entry_id": null, "id": "", + "invoice_date": "", + "is_overdue": false, + "job_id": null, + "number": "IN-000052", + "open_cents": 1064, "order_id": "", + "origin": "order", "paid_at": null, - "payment_terms": "NET30", - "status": "UNPAID", - "subtotal": 950, - "tax_amount": 114, - "tax_rate": 0.12, - "total_amount": 1064, - "updated_at": "" + "payment_terms_id": "", + "picked_up_by": null, + "revision": 1, + "ship_to_id": null, + "status": "unpaid", + "subtotal_cents": 950, + "tax_cents": 114, + "tax_exempt": false, + "tax_rate_percent": "12", + "tax_source": "legacy", + "total_cents": 1064, + "updated_at": "", + "void_reason": null, + "voided_at": null, + "voided_by": null } ], "limit": 1, - "offset": 0, - "total": 52 + "next_cursor": "" } } }, @@ -293,7 +312,9 @@ "created_at": "", "due_date": "", "id": "", + "is_overdue": false, "lines": [], + "number": "IN-000052", "order_id": "", "paid_at": null, "payment_terms": "NET30", @@ -319,7 +340,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -328,7 +349,7 @@ "name": "portal_billing.invoice.unknown", "request": { "method": "GET", - "path": "/api/portal/v1/invoices/" + "path": "/api/portal/v1/invoices/" }, "response": { "status": 404, @@ -339,7 +360,7 @@ "message": "Not Found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -353,7 +374,7 @@ "X-Integration-Key": "fb-brain-demo-key-2026" }, "body": { - "driver_id": "", + "driver_id": "", "scheduled_date": "", "stops": [ { @@ -361,7 +382,7 @@ "sequence": 1 } ], - "vehicle_id": "" + "vehicle_id": "" } }, "response": { @@ -370,7 +391,7 @@ "body": { "created": true, "replaced": false, - "route_id": "", + "route_id": "", "stop_count": 1 } } @@ -379,7 +400,7 @@ "name": "portal_billing.erp_route_deliveries", "request": { "method": "GET", - "path": "/api/v1/delivery/routes//deliveries" + "path": "/api/v1/delivery/routes//deliveries" }, "response": { "status": 200, @@ -390,7 +411,7 @@ "created_at": "", "customer_name": "Kelbrook Construction", "delivery_instructions": null, - "id": "", + "id": "", "latitude": null, "longitude": null, "order_id": "", @@ -398,7 +419,7 @@ "pod_proof_url": null, "pod_signed_by": null, "pod_timestamp": null, - "route_id": "", + "route_id": "", "status": "PENDING", "stop_sequence": 1, "updated_at": "" @@ -410,7 +431,7 @@ "name": "portal_billing.delivery.get", "request": { "method": "GET", - "path": "/api/portal/v1/deliveries/" + "path": "/api/portal/v1/deliveries/" }, "response": { "status": 200, @@ -423,7 +444,7 @@ "driver_phone": "250-555-4005", "driver_photo_url": null, "estimated_arrival": null, - "id": "", + "id": "", "order_id": "", "order_number": "", "pod_proof_url": null, @@ -453,7 +474,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -462,7 +483,7 @@ "name": "portal_billing.delivery.unknown", "request": { "method": "GET", - "path": "/api/portal/v1/deliveries/" + "path": "/api/portal/v1/deliveries/" }, "response": { "status": 404, @@ -473,7 +494,7 @@ "message": "Not Found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -482,7 +503,7 @@ "name": "portal_billing.reschedule.none", "request": { "method": "GET", - "path": "/api/portal/v1/deliveries//reschedule" + "path": "/api/portal/v1/deliveries//reschedule" }, "response": { "status": 204, @@ -507,7 +528,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -516,7 +537,7 @@ "name": "portal_billing.reschedule.unknown", "request": { "method": "GET", - "path": "/api/portal/v1/deliveries//reschedule" + "path": "/api/portal/v1/deliveries//reschedule" }, "response": { "status": 404, @@ -527,7 +548,7 @@ "message": "Not Found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -536,7 +557,7 @@ "name": "portal_billing.reschedule.request", "request": { "method": "POST", - "path": "/api/portal/v1/deliveries//reschedule", + "path": "/api/portal/v1/deliveries//reschedule", "body": { "reason": "golden reschedule", "requested_date": "" @@ -549,8 +570,8 @@ "applied": false, "created_at": "", "current_scheduled_date": "", - "delivery_id": "", - "id": "", + "delivery_id": "", + "id": "", "order_id": "", "reason": "golden reschedule", "requested_date": "", @@ -564,7 +585,7 @@ "name": "portal_billing.reschedule.past", "request": { "method": "POST", - "path": "/api/portal/v1/deliveries//reschedule", + "path": "/api/portal/v1/deliveries//reschedule", "body": { "reason": "too late", "requested_date": "" @@ -579,7 +600,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -588,7 +609,7 @@ "name": "portal_billing.reschedule.bad_date", "request": { "method": "POST", - "path": "/api/portal/v1/deliveries//reschedule", + "path": "/api/portal/v1/deliveries//reschedule", "body": { "requested_date": "soon" } @@ -602,7 +623,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -611,7 +632,7 @@ "name": "portal_billing.reschedule.bad_body", "request": { "method": "POST", - "path": "/api/portal/v1/deliveries//reschedule", + "path": "/api/portal/v1/deliveries//reschedule", "body": "not an object" }, "response": { @@ -623,7 +644,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -632,7 +653,7 @@ "name": "portal_billing.reschedule.unknown_post", "request": { "method": "POST", - "path": "/api/portal/v1/deliveries//reschedule", + "path": "/api/portal/v1/deliveries//reschedule", "body": { "requested_date": "" } @@ -646,7 +667,7 @@ "message": "Not Found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -655,7 +676,7 @@ "name": "portal_billing.reschedule.get", "request": { "method": "GET", - "path": "/api/portal/v1/deliveries//reschedule" + "path": "/api/portal/v1/deliveries//reschedule" }, "response": { "status": 200, @@ -664,8 +685,8 @@ "applied": false, "created_at": "", "current_scheduled_date": "", - "delivery_id": "", - "id": "", + "delivery_id": "", + "id": "", "order_id": "", "reason": "golden reschedule", "requested_date": "", diff --git a/core/internal/characterization/testdata/goldens/reporting_reports.json b/core/internal/characterization/testdata/goldens/reporting_reports.json index e8414226..5b7ecb76 100644 --- a/core/internal/characterization/testdata/goldens/reporting_reports.json +++ b/core/internal/characterization/testdata/goldens/reporting_reports.json @@ -11,11 +11,11 @@ "body": { "by_method": { "CASH": 10, - "CHECK": 306222.76 + "CHECK": 313419.85 }, "date": "", - "total_collected": 306232.76, - "transaction_count": 37 + "total_collected": 313429.85, + "transaction_count": 40 } } }, @@ -29,7 +29,7 @@ "status": 200, "content_type": "application/json", "body": { - "close_balance": -13.58, + "close_balance": -8.58, "customer_id": "", "customer_name": "Golden Harness Co", "end_date": "", @@ -39,19 +39,27 @@ "credit": 0, "date": "", "debit": 31.42, - "description": "Invoice #", + "description": "Invoice IN-000051", "type": "INVOICE" }, { - "balance": 26.42, - "credit": 5, + "balance": 30.67, + "credit": 0.75, "date": "", "debit": 0, - "description": "Credit Memo: golden characterisation", - "type": "REFUND" + "description": "Credit memo CM-000003", + "type": "CREDIT_MEMO" + }, + { + "balance": 31.42, + "credit": 0, + "date": "", + "debit": 0.75, + "description": "Void of credit memo CM-000003", + "type": "REVERSAL" }, { - "balance": 16.42, + "balance": 21.42, "credit": 10, "date": "", "debit": 0, @@ -59,11 +67,11 @@ "type": "PAYMENT" }, { - "balance": -13.58, + "balance": -8.58, "credit": 30, "date": "", "debit": 0, - "description": "POS return credit #", + "description": "POS return credit #", "type": "REFUND" } ], @@ -82,12 +90,12 @@ "status": 200, "content_type": "application/json", "body": { - "close_balance": -13.58, + "close_balance": -8.58, "customer_id": "", "customer_name": "Golden Harness Co", "end_date": "", "lines": null, - "open_balance": -13.58, + "open_balance": -8.58, "start_date": "" } } @@ -107,7 +115,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } diff --git a/core/internal/document/handler.go b/core/internal/document/handler.go index 34b15fd0..10170cc4 100644 --- a/core/internal/document/handler.go +++ b/core/internal/document/handler.go @@ -91,7 +91,7 @@ func (h *Handler) HandlePrintInvoice(w http.ResponseWriter, r *http.Request) { return } - inv, err := h.invoiceSvc.GetInvoice(r.Context(), id) + inv, err := h.invoiceSvc.GetInvoiceRecord(r.Context(), id) if err != nil { httputil.RespondError(w, r, "invoice not found", http.StatusNotFound, err) return @@ -159,7 +159,7 @@ func (h *Handler) HandleEmailInvoice(w http.ResponseWriter, r *http.Request) { return } - inv, err := h.invoiceSvc.GetInvoice(r.Context(), id) + inv, err := h.invoiceSvc.GetInvoiceRecord(r.Context(), id) if err != nil { httputil.RespondError(w, r, "invoice not found", http.StatusNotFound, err) return diff --git a/core/internal/invoice/repository.go b/core/internal/invoice/repository.go index 3a6e3362..f9e5abb3 100644 --- a/core/internal/invoice/repository.go +++ b/core/internal/invoice/repository.go @@ -178,7 +178,7 @@ func statusStrings(states []InvoiceStatus) []string { // listWhere is the shared predicate of the list and its count: the branch // wall, then the filters that filter. The count ignores the keyset position. -var listWhere = "WHERE " + wall("i", 1, 2) + ` +var listWhere = "\n\tWHERE " + wall("i", 1, 2) + ` AND (cardinality($3::text[]) = 0 OR i.status = ANY($3)) AND ($4::uuid IS NULL OR i.customer_id = $4) AND ($5::uuid IS NULL OR i.project_id = $5) @@ -257,14 +257,35 @@ func (r *PostgresRepository) LockInvoice(ctx context.Context, id uuid.UUID) (*In return r.GetInvoice(ctx, id) } -// invoiceHead reads the invoice header; the wall applies. -func (r *PostgresRepository) invoiceHead(ctx context.Context, id uuid.UUID, _ bool) (*Invoice, error) { +// GetInvoiceRecord reads an invoice for a route that holds the loaded record's +// own branch to the payload branch rule (CheckPayloadBranch: a 403 naming the +// record), as the document print and email routes do (PR 39). It applies the +// request's context branch only: the record check, not this read, is what +// scopes a bound caller with no context branch to its grants. +func (r *PostgresRepository) GetInvoiceRecord(ctx context.Context, id uuid.UUID) (*Invoice, error) { + inv, err := r.invoiceHead(ctx, id, true) + if err != nil { + return nil, err + } + if inv.Lines, err = r.invoiceLines(ctx, id); err != nil { + return nil, err + } + return inv, nil +} + +// invoiceHead reads the invoice header; the wall applies. contextOnly drops +// the grants half of the wall (GetInvoiceRecord). +func (r *PostgresRepository) invoiceHead(ctx context.Context, id uuid.UUID, contextOnly bool) (*Invoice, error) { var inv Invoice var snapshot []byte + grants := middleware.GrantsSubForQuery(ctx) + if contextOnly { + grants = nil + } row := r.db.GetExecutor(ctx).QueryRow(ctx, ` SELECT `+summaryColumns+`, i.ship_to_snapshot`+summaryFrom+` WHERE i.id = $1 AND `+wall("i", 2, 3), - id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + id, middleware.BranchIDForQuery(ctx), grants) if err := scanSummary(row, &inv.InvoiceSummary, &snapshot); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errInvoiceNotFound diff --git a/core/internal/invoice/repository_cm.go b/core/internal/invoice/repository_cm.go index 11ff05c1..2d48cd9b 100644 --- a/core/internal/invoice/repository_cm.go +++ b/core/internal/invoice/repository_cm.go @@ -68,7 +68,7 @@ type CreditFilter struct { Limit int } -var creditWhere = "WHERE " + wall("cm", 1, 2) + ` +var creditWhere = "\n\tWHERE " + wall("cm", 1, 2) + ` AND (cardinality($3::text[]) = 0 OR cm.status = ANY($3)) AND ($4::uuid IS NULL OR cm.customer_id = $4) AND ($5::uuid IS NULL OR cm.invoice_id = $5) diff --git a/core/internal/invoice/service.go b/core/internal/invoice/service.go index 1ccd4267..ac2d4206 100644 --- a/core/internal/invoice/service.go +++ b/core/internal/invoice/service.go @@ -230,6 +230,18 @@ func (s *Service) GetInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error return s.repo.GetInvoice(ctx, id) } +// GetInvoiceRecord reads an invoice for a caller that holds the record's own +// branch to the payload branch rule itself (the document print and email +// routes, PR 39): only the request's context branch narrows the read. +func (s *Service) GetInvoiceRecord(ctx context.Context, id uuid.UUID) (*Invoice, error) { + if r, ok := s.repo.(interface { + GetInvoiceRecord(ctx context.Context, id uuid.UUID) (*Invoice, error) + }); ok { + return r.GetInvoiceRecord(ctx, id) + } + return s.repo.GetInvoice(ctx, id) +} + // ListInvoices answers one page and whether another follows (the repository // reads limit+1), with the filtered total on request. func (s *Service) ListInvoices(ctx context.Context, f ListFilter, wantTotal bool) (items []InvoiceSummary, hasMore bool, total *int64, err error) { diff --git a/core/internal/order/tax_provider_test.go b/core/internal/order/tax_provider_test.go index 0289abe1..4728e484 100644 --- a/core/internal/order/tax_provider_test.go +++ b/core/internal/order/tax_provider_test.go @@ -168,8 +168,9 @@ func TestOrderConfirmTaxQuoteStale(t *testing.T) { } } -// RULE (ADR 0005 5.3): an OVERDUE invoice counts in the credit exposure, -// beside UNPAID and PARTIAL; a PAID one does not. +// RULE (ADR 0005 5.3): an overdue invoice (unpaid, past its due date; OVERDUE is +// no longer a stored status) counts in the credit exposure like any open one; +// a PAID one does not, and neither does a VOID one. func TestOrderCreditExposureCountsOverdueInvoices(t *testing.T) { testutil.LockOutboxTables(t) db := testutil.RequireDB(t) @@ -181,9 +182,10 @@ func TestOrderCreditExposureCountsOverdueInvoices(t *testing.T) { } seedInvoice := func(status string, total float64) { t.Helper() - _, err := db.Pool.Exec(ctx, `INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_amount, due_date) + _, err := db.Pool.Exec(ctx, `INSERT INTO invoices (id, order_id, customer_id, branch_id, status, total_amount, subtotal, tax_amount, due_date, voided_at) VALUES (gen_random_uuid(), (SELECT id FROM orders WHERE customer_id = $1 LIMIT 1), $1, - (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'), $2, $3, $3, 0, CURRENT_DATE - 40)`, + (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'), $2, $3, $3, 0, CURRENT_DATE - 40, + CASE WHEN $2 = 'VOID' THEN NOW() END)`, f.customerID, status, total) if err != nil { t.Fatal(err) @@ -198,10 +200,11 @@ func TestOrderCreditExposureCountsOverdueInvoices(t *testing.T) { } seedInvoice("PAID", 500) + seedInvoice("VOID", 500) r := f.create() r = f.do("POST", "/api/v1/orders/"+str(t, r.body, "id")+"/transitions", map[string]any{"to": "confirmed", "revision": 1}) if r.status != 200 || str(t, r.body, "status") != "confirmed" { - t.Fatalf("confirm beside a PAID invoice = %d %q, want confirmed: %s", r.status, r.body["status"], r.raw) + t.Fatalf("confirm beside a PAID and a VOID invoice = %d %q, want confirmed: %s", r.status, r.body["status"], r.raw) } // Cancel it: a live order's unbilled remainder counts in the exposure too, // and this test isolates the invoice. @@ -210,11 +213,11 @@ func TestOrderCreditExposureCountsOverdueInvoices(t *testing.T) { t.Fatalf("cancel = %d: %s", r.status, r.raw) } - seedInvoice("OVERDUE", 60) + seedInvoice("UNPAID", 60) // due 40 days ago: overdue r = f.create() r = f.do("POST", "/api/v1/orders/"+str(t, r.body, "id")+"/transitions", map[string]any{"to": "confirmed", "revision": 1}) if r.status != 200 || str(t, r.body, "status") != "on_hold" { - t.Fatalf("confirm beside an OVERDUE invoice = %d %q, want on_hold (60 open plus 59.88 is over 100): %s", r.status, r.body["status"], r.raw) + t.Fatalf("confirm beside an overdue invoice = %d %q, want on_hold (60 open plus 59.88 is over 100): %s", r.status, r.body["status"], r.raw) } if reason := str(t, r.body, "hold_reason"); reason != "credit_limit" { t.Errorf("hold_reason = %q, want credit_limit", reason) From 077fbdadb5fb81e285f08e9954b3e5789cdb4f13 Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 13:23:17 -0700 Subject: [PATCH 07/25] api client: usage for invoices and credit memos; accounts and gl enums gain the credit memo and reversal values --- core/api/openapi.yaml | 4 +- web/packages/api-client/src/schema.d.ts | 965 +++++++++++++++++++++--- web/packages/api-client/test/usage.ts | 38 +- 3 files changed, 913 insertions(+), 94 deletions(-) diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 95ea5914..45615bc5 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -12952,6 +12952,8 @@ components: - PAYMENT - ADJUSTMENT - REFUND + - CREDIT_MEMO + - REVERSAL amount: type: integer format: int64 @@ -15669,7 +15671,7 @@ components: type: string source: type: string - description: MANUAL, INVOICE, PAYMENT, ADJUSTMENT, CLOSING, VENDOR_INVOICE, VENDOR_PAYMENT, REVERSAL, RETURN or DEPOSIT. + description: MANUAL, INVOICE, PAYMENT, ADJUSTMENT, CLOSING, VENDOR_INVOICE, VENDOR_PAYMENT, REVERSAL, RETURN, DEPOSIT, CREDIT_MEMO or WRITE_OFF. source_ref_id: type: string format: uuid diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index c5a632df..64dd2fdf 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -2383,7 +2383,10 @@ export interface paths { path?: never; cookie?: never; }; - /** List invoices */ + /** + * List invoices + * @description The cursor list envelope, newest first (created_at, then id). status filters on the lowercase lifecycle vocabulary (a comma separated list); customer_id, job_id, ship_to_id and order_id filter on their document; overdue=true lists the open invoices past their due date and overdue=false the rest. total appears only under include=total. A parameter the route does not declare, a status outside the vocabulary (overdue is not one), a malformed cursor or an out of range limit is a 400. + */ get: operations["invoiceList"]; put?: never; post?: never; @@ -2400,7 +2403,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get one invoice with lines */ + /** + * Get one invoice with lines + * @description The invoice with every line (id, description, quantity, unit price, line total, cost), its status and open amount, and its ETag. + */ get: operations["invoiceGet"]; put?: never; post?: never; @@ -2410,7 +2416,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/invoices/{id}/credit-memo": { + "/api/v1/invoices/{id}/transitions": { parameters: { query?: never; header?: never; @@ -2419,8 +2425,11 @@ export interface paths { }; get?: never; put?: never; - /** Create and apply a credit memo against an invoice */ - post: operations["invoiceCreateCreditMemo"]; + /** + * Void an invoice + * @description The only client transition is void (roles admin, owner, finance; a reason is required). unpaid, partial, paid and written_off are derived by payments and credit memos, so asking for one is 409 invalid_state_transition. A void is refused with the blocker has_applications while a payment is recorded against the invoice or an applied credit memo names it, and with has_credit_memos while a credit memo that is not void names it. It runs in one transaction: it locks the invoice's order row first, reverses the invoice's whole journal entry (dated the void date), returns its billed stock to on hand, reduces the order lines' fulfilled quantities, re-runs allocation for those quantities and derives the order status; the subledger is credited back. The number is kept; nothing deletes an invoice. Writes invoice.voided (and order.backordered when the order cannot re-allocate) last. + */ + post: operations["invoiceTransition"]; delete?: never; options?: never; head?: never; @@ -2447,16 +2456,47 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/credit-memos/{customerId}": { + "/api/v1/credit-memos": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List a customer's credit memos */ - get: operations["invoiceListCreditMemos"]; + /** + * List credit memos + * @description The cursor list envelope, newest first. status filters on the lowercase vocabulary (draft, open, partial, applied, void; a comma separated list); customer_id, invoice_id and job_id filter on their document. total appears only under include=total. + */ + get: operations["creditMemoList"]; put?: never; + /** + * Create a draft credit memo + * @description Creates the credit memo in draft: no number (it is minted at post, so a voided draft consumes none), lines built and totalled, tax at the invoice's rate and never more than the invoice charged less what earlier credit memos credited. A line that names an invoice line cannot credit more than it billed less what earlier credit memos returned (409 exceeds_billed). The branch is held to the caller's branch (403 naming branch_id). Writes credit_memo.created. + */ + post: operations["creditMemoCreate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/credit-memos/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get one credit memo with lines + * @description The credit memo with every line and its ETag. + */ + get: operations["creditMemoGet"]; + /** + * Replace a draft credit memo + * @description Replaces the header fields and the lines, in draft only: in any other status it is 409 with the blocker credit_memo_not_draft. The customer and the invoice are fixed at create. The revision precondition applies (If-Match or the body revision). Writes credit_memo.updated. + */ + put: operations["creditMemoUpdate"]; post?: never; delete?: never; options?: never; @@ -2464,6 +2504,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/credit-memos/{id}/transitions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post or void a credit memo + * @description draft to open posts the credit memo (roles admin, owner, finance): it locks the invoice it names (409 invoice_void when that invoice is void), recomputes its lines against what was posted, restocks the lines that restock, mints the gapless CM- number, posts the balanced entry (revenue and tax back, receivable down, and for a restocked line the cost back to inventory and out of cost of goods sold at the original cost) and the subledger credit, and writes credit_memo.posted last. draft to void ends a draft and consumes no number; open to void (roles admin, owner, finance; a reason is required) reverses the entry and the restock and puts the credit back on the receivable. The statuses past open (partial, applied) arrive with the payment and AR item and are not reachable here (409 invalid_state_transition). Writes credit_memo.posted or credit_memo.voided. + */ + post: operations["creditMemoTransition"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/locations": { parameters: { query?: never; @@ -5974,7 +6034,7 @@ export interface components { /** Format: uuid */ customer_id: string; /** @enum {string} */ - type: "INVOICE" | "PAYMENT" | "ADJUSTMENT" | "REFUND"; + type: "INVOICE" | "PAYMENT" | "ADJUSTMENT" | "REFUND" | "CREDIT_MEMO" | "REVERSAL"; /** * Format: int64 * @description Cents. @@ -7556,7 +7616,7 @@ export interface components { /** Format: date-time */ entry_date: string; memo: string; - /** @description MANUAL, INVOICE, PAYMENT, ADJUSTMENT, CLOSING, VENDOR_INVOICE, VENDOR_PAYMENT, REVERSAL, RETURN or DEPOSIT. */ + /** @description MANUAL, INVOICE, PAYMENT, ADJUSTMENT, CLOSING, VENDOR_INVOICE, VENDOR_PAYMENT, REVERSAL, RETURN, DEPOSIT, CREDIT_MEMO or WRITE_OFF. */ source: string; /** Format: uuid */ source_ref_id?: string; @@ -8016,111 +8076,633 @@ export interface components { /** @enum {string} */ status: "ok"; }; - /** @enum {string} */ - InvoiceStatus: "UNPAID" | "PARTIAL" | "PAID" | "VOID" | "OVERDUE"; - /** @enum {string} */ - PaymentTerms: "COD" | "DUE_ON_RECEIPT" | "NET30" | "NET60" | "NET90"; - Invoice: { + /** + * @description The lifecycle, lowercase on the wire (ADR 0001 section 6; ADR 0005 section 6.2). overdue is not a status: it is the is_overdue flag and the overdue list filter. + * @enum {string} + */ + InvoiceStatus: "unpaid" | "partial" | "paid" | "void" | "written_off"; + /** + * @description The credit memo lifecycle, lowercase on the wire (ADR 0005 section 6.3). + * @enum {string} + */ + CreditMemoStatus: "draft" | "open" | "partial" | "applied" | "void"; + /** @description An invoice header, used as a list item and as the head of the full document. */ + InvoiceSummary: { /** Format: uuid */ id: string; + /** @description The gapless IN- number, minted when the invoice is created (ADR 0005 4.1). A void invoice keeps it. */ + number: string; /** Format: uuid */ branch_id: string; /** Format: uuid */ - order_id: string; + customer_id: string; + customer_name: string; + /** + * Format: uuid + * @description The order this invoice bills; null on a counter account charge. + */ + order_id: string | null; + /** + * Format: uuid + * @description The job (project) the invoice carries, from its order (ADR 0005 7.1). + */ + job_id: string | null; + /** Format: uuid */ + ship_to_id: string | null; + status: components["schemas"]["InvoiceStatus"]; + /** Format: int64 */ + revision: number; + /** @description ISO 4217, copied from the order, never sent. */ + currency: string; + /** @enum {string} */ + origin: "order" | "pos"; + /** @enum {string} */ + delivery_type: "pickup" | "delivery"; + /** @description Who collected a will-call order. */ + picked_up_by: string | null; + /** Format: uuid */ + delivery_id: string | null; + /** + * Format: date + * @description The business date in the branch's local calendar. + */ + invoice_date: string; + /** + * Format: date + * @description From the customer's payment terms at create. + */ + due_date: string | null; + /** Format: uuid */ + payment_terms_id: string; + /** + * Format: date + * @description The last day the early payment discount applies; null when the terms carry none. + */ + discount_due_date: string | null; + /** @description The early payment discount percent at scale 4 as a decimal string. */ + discount_percent: string | null; + /** Format: int64 */ + subtotal_cents: number; + /** Format: int64 */ + tax_cents: number; + /** @description A percent with at most 4 fraction digits ("8.875"); null when the tax provider answered. */ + tax_rate_percent: string | null; + tax_exempt: boolean; + tax_source: components["schemas"]["OrderTaxSource"]; + /** Format: int64 */ + total_cents: number; + /** + * Format: int64 + * @description What is still owed: the total less the payments recorded against the invoice while it is unpaid or partial, 0 when paid, written off or void. + */ + open_cents: number; + /** @description Computed: open and its due_date is before the branch's today. OVERDUE is not a status. */ + is_overdue: boolean; + /** Format: date-time */ + paid_at: string | null; + /** + * Format: uuid + * @description The invoice entry in the general ledger. + */ + gl_entry_id: string | null; + /** Format: date-time */ + voided_at: string | null; + voided_by: string | null; + void_reason: string | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description The full invoice document with its lines (IN-1.8). */ + Invoice: { + /** Format: uuid */ + id: string; + /** @description The gapless IN- number, minted when the invoice is created (ADR 0005 4.1). A void invoice keeps it. */ + number: string; + /** Format: uuid */ + branch_id: string; /** Format: uuid */ customer_id: string; - customer_name?: string; + customer_name: string; + /** + * Format: uuid + * @description The order this invoice bills; null on a counter account charge. + */ + order_id: string | null; + /** + * Format: uuid + * @description The job (project) the invoice carries, from its order (ADR 0005 7.1). + */ + job_id: string | null; + /** Format: uuid */ + ship_to_id: string | null; status: components["schemas"]["InvoiceStatus"]; + /** Format: int64 */ + revision: number; + /** @description ISO 4217, copied from the order, never sent. */ + currency: string; + /** @enum {string} */ + origin: "order" | "pos"; + /** @enum {string} */ + delivery_type: "pickup" | "delivery"; + /** @description Who collected a will-call order. */ + picked_up_by: string | null; + /** Format: uuid */ + delivery_id: string | null; + /** + * Format: date + * @description The business date in the branch's local calendar. + */ + invoice_date: string; + /** + * Format: date + * @description From the customer's payment terms at create. + */ + due_date: string | null; + /** Format: uuid */ + payment_terms_id: string; + /** + * Format: date + * @description The last day the early payment discount applies; null when the terms carry none. + */ + discount_due_date: string | null; + /** @description The early payment discount percent at scale 4 as a decimal string. */ + discount_percent: string | null; + /** Format: int64 */ + subtotal_cents: number; + /** Format: int64 */ + tax_cents: number; + /** @description A percent with at most 4 fraction digits ("8.875"); null when the tax provider answered. */ + tax_rate_percent: string | null; + tax_exempt: boolean; + tax_source: components["schemas"]["OrderTaxSource"]; + /** Format: int64 */ + total_cents: number; /** * Format: int64 - * @description Cents. + * @description What is still owed: the total less the payments recorded against the invoice while it is unpaid or partial, 0 when paid, written off or void. */ - subtotal: number; - /** @description A fraction such as 0.0825 for 8.25 percent. */ - tax_rate: number; + open_cents: number; + /** @description Computed: open and its due_date is before the branch's today. OVERDUE is not a status. */ + is_overdue: boolean; + /** Format: date-time */ + paid_at: string | null; + /** + * Format: uuid + * @description The invoice entry in the general ledger. + */ + gl_entry_id: string | null; + /** Format: date-time */ + voided_at: string | null; + voided_by: string | null; + void_reason: string | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + /** @description The delivery address captured at confirm (ADR 0005 section 5.1); null on an order that never confirmed or a pickup without one. */ + ship_to: { + /** Format: uuid */ + id?: string; + code?: string; + name?: string; + line1?: string; + line2?: string | null; + city?: string; + region?: string; + postal_code?: string; + country?: string | null; + phone?: string | null; + delivery_instructions?: string | null; + } | null; + lines: components["schemas"]["InvoiceLine"][]; + }; + /** @description A sales line on an invoice: the shared shape of ADR 0005 section 2.2 for the quantity billed, with the order line it bills and the cost that left. */ + InvoiceLine: { + /** Format: uuid */ + id: string; + /** @description The line's place on the document; a kit's components follow it. */ + position: number; + line_type: components["schemas"]["SalesLineType"]; + /** + * Format: uuid + * @description Set on a component line only, naming its kit line. + */ + parent_line_id: string | null; + /** + * Format: uuid + * @description Required on kit and component lines, optional on a product line (null is a non stock item), null on charge and text lines. + */ + product_id: string | null; + /** + * Format: uuid + * @description Set on a charge line only. + */ + charge_code_id: string | null; + /** @description The charge code's code text, read. */ + charge_code: string | null; + /** @description A snapshot of the product's SKU. */ + sku: string | null; + /** @description A snapshot; required on every line. */ + description: string; + /** @description The quantity as a decimal string with its unit; null only on a text line. */ + quantity: string | null; + /** @description The sale unit. On a stocked line it is the product's stocking unit (ADR 0005 section 1). */ + uom: string | null; + /** @description The unit the price is per; equals uom unless the pair says otherwise. */ + price_uom: string | null; + /** @description One side of the conversion pair; 1 and 1 when the units agree (any other pair on equal units is a 400). */ + uom_qty: string | null; + /** @description The other side of the conversion pair. */ + price_uom_qty: string | null; /** * Format: int64 - * @description Cents. + * @description The price charged per price unit, after any override, before any discount. */ - tax_amount: number; + unit_price_ten_thousandths: number | null; /** * Format: int64 - * @description Cents, subtotal plus tax. + * @description What the pricing engine resolved, read only, so an override is visible as the difference; null on text, charge and non stock lines. */ - total_amount: number; - payment_terms: components["schemas"]["PaymentTerms"]; + priced_unit_price_ten_thousandths: number | null; + price_source: components["schemas"]["SalesPriceSource"]; + /** @description Required when price_source is override. */ + override_reason: string | null; + /** @description A percent at scale 4 as a decimal string, greater than 0 and at most 100; never with discount_cents. */ + discount_percent: string | null; /** - * Format: date-time - * @description Null while the terms set no date. + * Format: int64 + * @description A discount amount in cents, positive; never with discount_percent. + */ + discount_cents: number | null; + /** @description Required with either discount. */ + discount_reason: string | null; + /** @description The actor of the last override or discount; the audit row carries the rest. */ + price_adjusted_by: string | null; + /** + * Format: int64 + * @description The extension, rounded once (ADR 0005 section 2.4); null only on a text line. + */ + line_total_cents: number | null; + taxable: boolean; + /** @description The charge code's account, snapshotted at create; null on every other line type. */ + revenue_account_code: string | null; + is_special_order: boolean; + /** Format: uuid */ + vendor_id: string | null; + /** + * Format: int64 + * @description A unit cost at scale 4. + */ + special_order_unit_cost_ten_thousandths: number | null; + /** Format: date-time */ + created_at: string; + /** + * Format: uuid + * @description The order line this invoice line bills; null on a note and on a counter charge. + */ + order_line_id: string | null; + /** + * Format: int64 + * @description The cost that left (ADR 0005 8.4) at scale 4, shown to the roles that see margin (admin, owner, finance, sales: the roles that reach these routes). Null when the line carries no cost. + */ + unit_cost_ten_thousandths: number | null; + /** + * Format: int64 + * @description The cost of goods relieved from inventory for this line, in cents (ADR 0005 8.4); 0 when none. + */ + cost_cents: number; + }; + InvoicePage: { + items: components["schemas"]["InvoiceSummary"][]; + next_cursor: string | null; + limit: number; + /** @description Only under include=total. */ + total?: number; + }; + /** @description The body of POST /invoices/{id}/transitions: to void, the revision and the reason. */ + InvoiceTransitionRequest: { + to: components["schemas"]["InvoiceStatus"]; + /** + * Format: int64 + * @description The precondition, beside If-Match. + */ + revision?: number; + /** @description Required by a void. */ + reason?: string; + }; + InvoiceEmailResponse: { + /** @enum {string} */ + status: "queued"; + }; + /** @description A credit memo header, used as a list item and as the head of the full document. */ + CreditMemoSummary: { + /** Format: uuid */ + id: string; + /** @description The gapless CM- number, minted when the memo is posted: null while it is a draft, and a voided draft never had one. */ + number: string | null; + /** Format: uuid */ + branch_id: string; + /** Format: uuid */ + customer_id: string; + customer_name: string; + /** + * Format: uuid + * @description The invoice it credits, optional. + */ + invoice_id: string | null; + /** + * Format: uuid + * @description Set by a counter return (C2-5). + */ + pos_return_id: string | null; + /** Format: uuid */ + job_id: string | null; + /** Format: uuid */ + ship_to_id: string | null; + status: components["schemas"]["CreditMemoStatus"]; + /** Format: int64 */ + revision: number; + /** @description The invoice's, else the customer's effective currency; copied, never sent. */ + currency: string; + /** @enum {string} */ + reason_code: "return" | "price_adjustment" | "damage" | "other"; + reason: string; + /** + * Format: int64 + * @description NEGATIVE: lines, totals and the open amount are negative (ADR 0001 section 7a). + */ + subtotal_cents: number; + /** + * Format: int64 + * @description NEGATIVE; never more than the invoice charged less the tax earlier credit memos credited. + */ + tax_cents: number; + /** @description The invoice's rate (or the resolved one); null when the invoice's tax came from the provider. */ + tax_rate_percent: string | null; + /** + * Format: int64 + * @description NEGATIVE: subtotal plus tax. + */ + total_cents: number; + /** + * Format: int64 + * @description The credit not yet used: the total while the memo is open, 0 for a draft or a void memo. NEGATIVE like the total. + */ + open_cents: number; + /** Format: uuid */ + gl_entry_id: string | null; + /** + * Format: date + * @description The business date in the branch's local calendar; set again at post. + */ + memo_date: string; + /** Format: date-time */ + voided_at: string | null; + voided_by: string | null; + void_reason: string | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + /** @description The full credit memo document with its lines. */ + CreditMemo: { + /** Format: uuid */ + id: string; + /** @description The gapless CM- number, minted when the memo is posted: null while it is a draft, and a voided draft never had one. */ + number: string | null; + /** Format: uuid */ + branch_id: string; + /** Format: uuid */ + customer_id: string; + customer_name: string; + /** + * Format: uuid + * @description The invoice it credits, optional. + */ + invoice_id: string | null; + /** + * Format: uuid + * @description Set by a counter return (C2-5). + */ + pos_return_id: string | null; + /** Format: uuid */ + job_id: string | null; + /** Format: uuid */ + ship_to_id: string | null; + status: components["schemas"]["CreditMemoStatus"]; + /** Format: int64 */ + revision: number; + /** @description The invoice's, else the customer's effective currency; copied, never sent. */ + currency: string; + /** @enum {string} */ + reason_code: "return" | "price_adjustment" | "damage" | "other"; + reason: string; + /** + * Format: int64 + * @description NEGATIVE: lines, totals and the open amount are negative (ADR 0001 section 7a). + */ + subtotal_cents: number; + /** + * Format: int64 + * @description NEGATIVE; never more than the invoice charged less the tax earlier credit memos credited. + */ + tax_cents: number; + /** @description The invoice's rate (or the resolved one); null when the invoice's tax came from the provider. */ + tax_rate_percent: string | null; + /** + * Format: int64 + * @description NEGATIVE: subtotal plus tax. + */ + total_cents: number; + /** + * Format: int64 + * @description The credit not yet used: the total while the memo is open, 0 for a draft or a void memo. NEGATIVE like the total. + */ + open_cents: number; + /** Format: uuid */ + gl_entry_id: string | null; + /** + * Format: date + * @description The business date in the branch's local calendar; set again at post. + */ + memo_date: string; + /** Format: date-time */ + voided_at: string | null; + voided_by: string | null; + void_reason: string | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + lines: components["schemas"]["CreditMemoLine"][]; + }; + /** @description A line on a credit memo: the shared shape with a NEGATIVE quantity and line total (ADR 0001 section 7a; a plain sum is the AR effect), the invoice line it returns and whether it restocks. */ + CreditMemoLine: { + /** Format: uuid */ + id: string; + /** @description The line's place on the document; a kit's components follow it. */ + position: number; + line_type: components["schemas"]["SalesLineType"]; + /** + * Format: uuid + * @description Set on a component line only, naming its kit line. + */ + parent_line_id: string | null; + /** + * Format: uuid + * @description Required on kit and component lines, optional on a product line (null is a non stock item), null on charge and text lines. + */ + product_id: string | null; + /** + * Format: uuid + * @description Set on a charge line only. + */ + charge_code_id: string | null; + /** @description The charge code's code text, read. */ + charge_code: string | null; + /** @description A snapshot of the product's SKU. */ + sku: string | null; + /** @description A snapshot; required on every line. */ + description: string; + /** @description The quantity as a decimal string with its unit; null only on a text line. */ + quantity: string | null; + /** @description The sale unit. On a stocked line it is the product's stocking unit (ADR 0005 section 1). */ + uom: string | null; + /** @description The unit the price is per; equals uom unless the pair says otherwise. */ + price_uom: string | null; + /** @description One side of the conversion pair; 1 and 1 when the units agree (any other pair on equal units is a 400). */ + uom_qty: string | null; + /** @description The other side of the conversion pair. */ + price_uom_qty: string | null; + /** + * Format: int64 + * @description The price charged per price unit, after any override, before any discount. + */ + unit_price_ten_thousandths: number | null; + /** + * Format: int64 + * @description What the pricing engine resolved, read only, so an override is visible as the difference; null on text, charge and non stock lines. + */ + priced_unit_price_ten_thousandths: number | null; + price_source: components["schemas"]["SalesPriceSource"]; + /** @description Required when price_source is override. */ + override_reason: string | null; + /** @description A percent at scale 4 as a decimal string, greater than 0 and at most 100; never with discount_cents. */ + discount_percent: string | null; + /** + * Format: int64 + * @description A discount amount in cents, positive; never with discount_percent. + */ + discount_cents: number | null; + /** @description Required with either discount. */ + discount_reason: string | null; + /** @description The actor of the last override or discount; the audit row carries the rest. */ + price_adjusted_by: string | null; + /** + * Format: int64 + * @description The extension, rounded once (ADR 0005 section 2.4); null only on a text line. + */ + line_total_cents: number | null; + taxable: boolean; + /** @description The charge code's account, snapshotted at create; null on every other line type. */ + revenue_account_code: string | null; + is_special_order: boolean; + /** Format: uuid */ + vendor_id: string | null; + /** + * Format: int64 + * @description A unit cost at scale 4. + */ + special_order_unit_cost_ten_thousandths: number | null; + /** Format: date-time */ + created_at: string; + /** + * Format: uuid + * @description The invoice line this credit line returns; null on a free line (a price adjustment, a fee given back, a note). + */ + invoice_line_id: string | null; + /** @description Whether the goods go back on hand when the credit memo is posted. */ + restock: boolean; + /** + * Format: int64 + * @description The cost that left (ADR 0005 8.4) at scale 4, shown to the roles that see margin (admin, owner, finance, sales: the roles that reach these routes). Null when the line carries no cost. */ - due_date: string | null; + unit_cost_ten_thousandths: number | null; /** - * Format: date-time - * @description Null until a payment completes the invoice. + * Format: int64 + * @description The cost that comes back to inventory, in cents and NEGATIVE like the line (ADR 0005 8.4): COGS reverses at the original cost; 0 on a line that does not restock. */ - paid_at: string | null; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - updated_at: string; - lines?: components["schemas"]["InvoiceLine"][]; + cost_cents: number; }; - InvoiceLine: { - /** Format: uuid */ - id: string; + /** @description One credit memo line. A line that names an invoice line (invoice_line_id) sends only the negative quantity credited, restock and optionally a description: its price, pair and discount come from the invoice line, and it cannot credit more than that line billed less what earlier credit memos returned (409 exceeds_billed). A free line names a product, a charge code or only a description and sends its own price; quantities are negative. */ + CreditMemoLineRequest: { + /** + * Format: uuid + * @description Keeps a line across edits. + */ + id?: string; /** Format: uuid */ - invoice_id: string; + invoice_line_id?: string; + /** @enum {string} */ + line_type?: "product" | "charge" | "text"; /** Format: uuid */ - product_id: string; - product_sku?: string; - product_name?: string; - quantity: number; + product_id?: string; + charge_code?: string; + description?: string; + /** @description A NEGATIVE decimal string: the quantity returned or credited. */ + quantity?: string; + uom?: string; /** * Format: int64 - * @description Cents. + * @description Free lines only; never negative. */ - price_each: number; - /** Format: date-time */ - created_at: string; - }; - InvoicePage: { - data: components["schemas"]["Invoice"][]; - total: number; - limit: number; - offset: number; + unit_price_ten_thousandths?: number; + taxable?: boolean; + /** @description Return the goods to stock at post (a stocked product line). */ + restock?: boolean; }; - CreditMemo: { + /** @description The body of POST /credit-memos and PUT /credit-memos/{id}. A create names an invoice (the customer, branch, currency, job and ship-to follow it) or a customer. branch_id, if sent, is held to the caller's branch like every payload branch. currency is never sent. */ + CreditMemoRequest: { /** Format: uuid */ - id: string; + branch_id?: string; + /** Format: uuid */ + customer_id?: string; /** Format: uuid */ invoice_id?: string; /** Format: uuid */ - customer_id: string; + job_id?: string; + /** Format: uuid */ + ship_to_id?: string; + /** @enum {string} */ + reason_code: "return" | "price_adjustment" | "damage" | "other"; + reason: string; /** * Format: int64 - * @description Cents. + * @description The precondition on a PUT, beside If-Match. */ - amount: number; - reason: string; - /** @enum {string} */ - status: "PENDING" | "APPLIED" | "VOID"; - /** Format: date-time */ - created_at: string; - /** Format: date-time */ - applied_at?: string; + revision?: number; + lines: components["schemas"]["CreditMemoLineRequest"][]; + }; + CreditMemoPage: { + items: components["schemas"]["CreditMemoSummary"][]; + next_cursor: string | null; + limit: number; + /** @description Only under include=total. */ + total?: number; }; - CreditMemoCreateRequest: { + /** @description The body of POST /credit-memos/{id}/transitions: to open (post) or void, the revision and the reason. */ + CreditMemoTransitionRequest: { + to: components["schemas"]["CreditMemoStatus"]; /** * Format: int64 - * @description Cents; must be positive. + * @description The precondition, beside If-Match. */ - amount_cents: number; - reason: string; - }; - CreditMemoList: components["schemas"]["CreditMemo"][] | null; - InvoiceEmailResponse: { - /** @enum {string} */ - status: "queued"; + revision?: number; + /** @description Required by a void. */ + reason?: string; }; /** @enum {string} */ LocationType: "BRANCH" | "ZONE" | "AISLE" | "RACK" | "SHELF" | "BIN" | "YARD"; @@ -9072,8 +9654,13 @@ export interface components { PortalInvoice: { /** Format: uuid */ id: string; + /** @description The invoice's gapless IN- number (C2-3). */ + number: string; + /** @description Computed, open and past its due date; overdue is no longer a status (C2-3). */ + is_overdue: boolean; /** Format: uuid */ order_id: string; + /** @description The stored status, uppercase (UNPAID, PARTIAL, PAID, VOID, WRITTEN_OFF); the portal keeps its legacy vocabulary. */ status: string; /** @description Float dollars. */ total_amount: number; @@ -16702,10 +17289,24 @@ export interface operations { invoiceList: { parameters: { query?: { - /** @description Page size. Unparseable, non positive or over maximum values are silently ignored and the default applies; the value is never refused today. */ - limit?: components["parameters"]["Limit"]; - /** @description Page offset. Unparseable or negative values are silently ignored and the default applies. */ - offset?: components["parameters"]["Offset"]; + /** @description Comma separated lowercase statuses (unpaid, partial, paid, void, written_off). */ + status?: string; + /** @description Filter on the customer. */ + customer_id?: string; + /** @description Filter on the job (project). */ + job_id?: string; + /** @description Filter on the ship-to. */ + ship_to_id?: string; + /** @description Filter on the order. */ + order_id?: string; + /** @description true: open and past due; false: everything else. */ + overdue?: "true" | "false"; + /** @description Page size of a converted list, 1 to 200, default 50. Anything else is a 400 naming limit (ADR 0001 section 2); it is never clamped. */ + limit?: components["parameters"]["PageLimit"]; + /** @description The opaque cursor of the previous page's next_cursor, passed back verbatim. A malformed cursor, or one minted for another ordering, is a 400 naming cursor. */ + cursor?: components["parameters"]["Cursor"]; + /** @description A comma separated list of expansions. total adds the count of rows matching the filters, which costs a second query and is therefore opt in. */ + include?: components["parameters"]["Include"]; }; header?: { /** @description Branch scope for modules registered behind the branch middleware (quote, customer, order, invoice in this fragment set). A missing header passes for admins and in dev mode; with default_branch_required on, a non admin without it is refused. A non UUID value is a 400 and a branch the caller has no grant for is a 403. */ @@ -16725,6 +17326,7 @@ export interface operations { "application/json": components["schemas"]["InvoicePage"]; }; }; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; 500: components["responses"]["InternalError"]; @@ -16747,6 +17349,8 @@ export interface operations { /** @description The invoice. */ 200: { headers: { + /** @description The document's revision. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -16756,15 +17360,17 @@ export interface operations { 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; }; }; - invoiceCreateCreditMemo: { + invoiceTransition: { parameters: { query?: never; header?: { /** @description Branch scope for modules registered behind the branch middleware (quote, customer, order, invoice in this fragment set). A missing header passes for admins and in dev mode; with default_branch_required on, a non admin without it is refused. A non UUID value is a 400 and a branch the caller has no grant for is a 403. */ "X-Branch-Id"?: components["parameters"]["XBranchId"]; + /** @description The document revision the client read, in quotes ("3"; the weak form W/"3" is accepted too). The body's revision field is the alternative; a write carrying neither is 428, a stale one 409 stale_revision, and * or a list of tags is a 400. */ + "If-Match"?: components["parameters"]["IfMatch"]; /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; }; @@ -16775,26 +17381,29 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreditMemoCreateRequest"]; + "application/json": components["schemas"]["InvoiceTransitionRequest"]; }; }; responses: { - /** @description The created credit memo. */ - 201: { + /** @description The voided invoice. */ + 200: { headers: { + /** @description The document's revision. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreditMemo"]; + "application/json": components["schemas"]["Invoice"]; }; }; 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["IdempotencyConflict"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; 413: components["responses"]["PayloadTooLarge"]; 422: components["responses"]["UnprocessableEntity"]; + 428: components["responses"]["WirePreconditionRequired"]; 500: components["responses"]["InternalError"]; }; }; @@ -16841,7 +17450,89 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; - invoiceListCreditMemos: { + creditMemoList: { + parameters: { + query?: { + /** @description Comma separated lowercase statuses. */ + status?: string; + /** @description Filter on the customer. */ + customer_id?: string; + /** @description Filter on the invoice credited. */ + invoice_id?: string; + /** @description Filter on the job. */ + job_id?: string; + /** @description Page size of a converted list, 1 to 200, default 50. Anything else is a 400 naming limit (ADR 0001 section 2); it is never clamped. */ + limit?: components["parameters"]["PageLimit"]; + /** @description The opaque cursor of the previous page's next_cursor, passed back verbatim. A malformed cursor, or one minted for another ordering, is a 400 naming cursor. */ + cursor?: components["parameters"]["Cursor"]; + /** @description A comma separated list of expansions. total adds the count of rows matching the filters, which costs a second query and is therefore opt in. */ + include?: components["parameters"]["Include"]; + }; + header?: { + /** @description Branch scope for modules registered behind the branch middleware (quote, customer, order, invoice in this fragment set). A missing header passes for admins and in dev mode; with default_branch_required on, a non admin without it is refused. A non UUID value is a 400 and a branch the caller has no grant for is a 403. */ + "X-Branch-Id"?: components["parameters"]["XBranchId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The page of credit memos. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreditMemoPage"]; + }; + }; + 400: components["responses"]["BadRequestEither"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ForbiddenEither"]; + 500: components["responses"]["InternalError"]; + }; + }; + creditMemoCreate: { + parameters: { + query?: never; + header?: { + /** @description Branch scope for modules registered behind the branch middleware (quote, customer, order, invoice in this fragment set). A missing header passes for admins and in dev mode; with default_branch_required on, a non admin without it is refused. A non UUID value is a 400 and a branch the caller has no grant for is a 403. */ + "X-Branch-Id"?: components["parameters"]["XBranchId"]; + /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ + "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreditMemoRequest"]; + }; + }; + responses: { + /** @description The draft credit memo. */ + 201: { + headers: { + /** @description The document's revision. */ + ETag?: string; + /** @description The path of the created document. */ + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreditMemo"]; + }; + }; + 400: components["responses"]["BadRequestEither"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ForbiddenEither"]; + 409: components["responses"]["ConflictEither"]; + 413: components["responses"]["PayloadTooLarge"]; + 422: components["responses"]["UnprocessableEntity"]; + 500: components["responses"]["InternalError"]; + }; + }; + creditMemoGet: { parameters: { query?: never; header?: { @@ -16849,24 +17540,114 @@ export interface operations { "X-Branch-Id"?: components["parameters"]["XBranchId"]; }; path: { - customerId: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The credit memos, a bare array that is null when the customer has none. */ + /** @description The credit memo. */ 200: { headers: { + /** @description The document's revision. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreditMemoList"]; + "application/json": components["schemas"]["CreditMemo"]; }; }; 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; + }; + }; + creditMemoUpdate: { + parameters: { + query?: never; + header?: { + /** @description Branch scope for modules registered behind the branch middleware (quote, customer, order, invoice in this fragment set). A missing header passes for admins and in dev mode; with default_branch_required on, a non admin without it is refused. A non UUID value is a 400 and a branch the caller has no grant for is a 403. */ + "X-Branch-Id"?: components["parameters"]["XBranchId"]; + /** @description The document revision the client read, in quotes ("3"; the weak form W/"3" is accepted too). The body's revision field is the alternative; a write carrying neither is 428, a stale one 409 stale_revision, and * or a list of tags is a 400. */ + "If-Match"?: components["parameters"]["IfMatch"]; + /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ + "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreditMemoRequest"]; + }; + }; + responses: { + /** @description The edited credit memo. */ + 200: { + headers: { + /** @description The document's revision. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreditMemo"]; + }; + }; + 400: components["responses"]["BadRequestEither"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; + 413: components["responses"]["PayloadTooLarge"]; + 422: components["responses"]["UnprocessableEntity"]; + 428: components["responses"]["WirePreconditionRequired"]; + 500: components["responses"]["InternalError"]; + }; + }; + creditMemoTransition: { + parameters: { + query?: never; + header?: { + /** @description Branch scope for modules registered behind the branch middleware (quote, customer, order, invoice in this fragment set). A missing header passes for admins and in dev mode; with default_branch_required on, a non admin without it is refused. A non UUID value is a 400 and a branch the caller has no grant for is a 403. */ + "X-Branch-Id"?: components["parameters"]["XBranchId"]; + /** @description The document revision the client read, in quotes ("3"; the weak form W/"3" is accepted too). The body's revision field is the alternative; a write carrying neither is 428, a stale one 409 stale_revision, and * or a list of tags is a 400. */ + "If-Match"?: components["parameters"]["IfMatch"]; + /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ + "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreditMemoTransitionRequest"]; + }; + }; + responses: { + /** @description The credit memo after the transition. */ + 200: { + headers: { + /** @description The document's revision. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreditMemo"]; + }; + }; + 400: components["responses"]["BadRequestEither"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; + 413: components["responses"]["PayloadTooLarge"]; + 422: components["responses"]["UnprocessableEntity"]; + 428: components["responses"]["WirePreconditionRequired"]; 500: components["responses"]["InternalError"]; }; }; diff --git a/web/packages/api-client/test/usage.ts b/web/packages/api-client/test/usage.ts index 8f502f12..cf19ca1b 100644 --- a/web/packages/api-client/test/usage.ts +++ b/web/packages/api-client/test/usage.ts @@ -70,7 +70,34 @@ async function _happy() { ); const customerRevision: number = edited.body.revision; - return [quoteTotal, nextCursor, firstStatus, revision, lineTotal, orderStatus, vehicleCount, cancelledStatus, limit, terms, shipToRevision, customerRevision]; + // Invoices and credit memos (ADR 0005 6.2 and 6.3): the cursor list, the + // detail with its lines, the void, and the credit memo from draft to posted. + const invoices = await client.get("/api/v1/invoices", { query: { status: "unpaid", overdue: "true", limit: 10 } }); + const invoiceNumber: string | undefined = invoices.body.items[0]?.number; + const invoice = await client.get("/api/v1/invoices/{id}", { path: { id: "8f14e45f-ceea-467f-a830-aacd11a4" } }); + const billed: number = invoice.body.lines[0]?.line_total_cents ?? 0; + const open: number = invoice.body.open_cents; + const voided = await client.post( + "/api/v1/invoices/{id}/transitions", + { to: "void", revision: 2, reason: "billed in error" }, + { path: { id: "8f14e45f-ceea-467f-a830-aacd11a4" } }, + ); + const voidedAt: string | null | undefined = voided.body?.voided_at; + const memo = await client.post("/api/v1/credit-memos", { + invoice_id: "8f14e45f-ceea-467f-a830-aacd11a4", + reason_code: "return", + reason: "two pieces came back", + lines: [{ invoice_line_id: "8f14e45f-ceea-467f-a830-aacd11a4", quantity: "-2", restock: true }], + }); + const memoNumber: string | null | undefined = memo.body?.number; + const posted = await client.post( + "/api/v1/credit-memos/{id}/transitions", + { to: "open", revision: 1 }, + { path: { id: "8f14e45f-ceea-467f-a830-aacd11a4" } }, + ); + const memoTotal: number = posted.body?.total_cents ?? 0; + + return [invoiceNumber, billed, open, voidedAt, memoNumber, memoTotal, quoteTotal, nextCursor, firstStatus, revision, lineTotal, orderStatus, vehicleCount, cancelledStatus, limit, terms, shipToRevision, customerRevision]; } // Wrong paths: each line must be a compile error -------------------------- @@ -106,6 +133,15 @@ async function _wrong() { // @ts-expect-error a ship-to needs its code, name and line1 await client.post("/api/v1/customers/{id}/ship-tos", { name: "only a name" }, { path: { id: "x" } }); + // @ts-expect-error overdue is a computed flag and a list filter: it is a true/false string + await client.get("/api/v1/invoices", { query: { overdue: 7 } }); + + // @ts-expect-error the only invoice transition a client sends is void; the body needs its target + await client.post("/api/v1/invoices/{id}/transitions", { revision: 1 }, { path: { id: "x" } }); + + // @ts-expect-error a credit memo names its reason by code and carries its lines + await client.post("/api/v1/credit-memos", { customer_id: "x" }); + // @ts-expect-error path parameter must be a string await client.get("/api/v1/quotes/{id}", { path: { id: 123 } }); From 4cec7c81803fc3fc3b63d73b8f85c566362bbea1 Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 13:29:43 -0700 Subject: [PATCH 08/25] invoices and credit memos: wire tests (shape, list, overdue, terms, void, numbering, credit memo lifecycle, tax cap, restock COGS) Co-Authored-By: Claude Sonnet 5.5 --- core/internal/invoice/credit_test.go | 556 +++++++++++++++++ core/internal/invoice/fixture_test.go | 475 ++++++++++++++ core/internal/invoice/wire_test.go | 584 ++++++++++++++++++ .../src/components/invoices/SalesDialog.ts | 92 +++ .../desk/src/components/invoices/chips.ts | 25 + .../src/components/invoices/lines-table.ts | 119 ++++ web/apps/desk/src/lib/credit-display.ts | 91 +++ web/apps/desk/src/lib/utils.ts | 10 + .../desk/src/pages/invoices/InvoiceDetail.ts | 510 ++++++++------- .../desk/src/pages/invoices/InvoiceList.ts | 237 +++++-- .../desk/src/services/CreditMemoService.ts | 131 ++++ web/apps/desk/src/services/InvoiceService.ts | 103 ++- .../src/services/ReportingService.test.ts | 70 +-- .../desk/src/services/ReportingService.ts | 13 +- web/apps/desk/src/services/apiError.ts | 5 + web/apps/desk/src/types/creditMemo.ts | 51 ++ web/apps/desk/src/types/invoice.ts | 70 +-- 17 files changed, 2714 insertions(+), 428 deletions(-) create mode 100644 core/internal/invoice/credit_test.go create mode 100644 core/internal/invoice/fixture_test.go create mode 100644 core/internal/invoice/wire_test.go create mode 100644 web/apps/desk/src/components/invoices/SalesDialog.ts create mode 100644 web/apps/desk/src/components/invoices/chips.ts create mode 100644 web/apps/desk/src/components/invoices/lines-table.ts create mode 100644 web/apps/desk/src/lib/credit-display.ts create mode 100644 web/apps/desk/src/services/CreditMemoService.ts create mode 100644 web/apps/desk/src/types/creditMemo.ts diff --git a/core/internal/invoice/credit_test.go b/core/internal/invoice/credit_test.go new file mode 100644 index 00000000..292c4b00 --- /dev/null +++ b/core/internal/invoice/credit_test.go @@ -0,0 +1,556 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice_test + +// The credit memo on the wire contract (ADR 0001) and ADR 0005 section 6.3: a +// draft with no number, lines that return invoice lines or give a price back, +// the number minted at post, the tax never above what the invoice charged, the +// restock with its entry at the original cost, and the void. + +import ( + "fmt" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +var creditNumberPattern = regexp.MustCompile(`^CM-\d{6,}$`) + +// RULE (IN-1.1, IN-1.2, IN-1.3, IN-1.4): the create answers 201 with Location, a +// draft with number null and lowercase status, negative totals, the lines in the +// shared shape with the invoice line they return, the revision and ETag. +func TestCreditMemoCreateShape(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("10") + line := f.firstLineID(invID) + r := f.do("POST", "/api/v1/credit-memos", f.creditBody(invID, returnLine(line, "-4", true))) + if r.status != http.StatusCreated { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + b := r.body + id := str(t, b, "id") + if r.header.Get("Location") != "/api/v1/credit-memos/"+id { + t.Errorf("Location = %q", r.header.Get("Location")) + } + if r.header.Get("ETag") != `"1"` || rev(t, r) != 1 { + t.Errorf("ETag %q revision %d, want \"1\"", r.header.Get("ETag"), rev(t, r)) + } + for key, want := range map[string]string{"status": "draft", "reason_code": "return", "currency": "USD", "invoice_id": invID} { + if got := str(t, b, key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + // 4 of 10 on a 55.00 line: 22.00, tax 8.875% of 2200 = 195.25 -> 195 (not the last piece) + for key, want := range map[string]int64{"subtotal_cents": -2200, "tax_cents": -195, "total_cents": -2395, "open_cents": 0} { + if got := num(t, b, key); got != want { + t.Errorf("%s = %d, want %d", key, got, want) + } + } + for _, key := range []string{"number", "pos_return_id", "job_id", "ship_to_id", "gl_entry_id", "voided_at", "voided_by", "void_reason"} { + if v, ok := b[key]; !ok || v != nil { + t.Errorf("%s = %v (present %v), want present as null", key, v, ok) + } + } + if str(t, b, "tax_rate_percent") != "8.875" { + t.Errorf("tax_rate_percent = %v, want the invoice's 8.875", b["tax_rate_percent"]) + } + l := b["lines"].([]any)[0].(map[string]any) + if str(t, l, "quantity") != "-4" || num(t, l, "line_total_cents") != -2200 || l["restock"] != true || str(t, l, "invoice_line_id") != line { + t.Errorf("line = %v", l) + } + if num(t, l, "unit_price_ten_thousandths") != 55000 || str(t, l, "uom") != "PCS" || num(t, l, "unit_cost_ten_thousandths") != 32500 || num(t, l, "cost_cents") != -1300 { + t.Errorf("line price/cost = %v", l) + } + if ev := eventTypes(t, db, "credit_memo", id); fmt.Sprint(ev) != "[credit_memo.created]" { + t.Errorf("events = %v", ev) + } + // the get reads the same + g := f.do("GET", "/api/v1/credit-memos/"+id, nil) + if g.status != 200 || g.header.Get("ETag") != `"1"` || num(t, g.body, "total_cents") != -2395 { + t.Errorf("get = %d %s", g.status, g.raw) + } + // nothing moved: a draft is not a posting + if f.stock() != "90.0000/0.0000" || f.balance() != 5988 { + t.Errorf("a draft moved stock %s or the balance %d", f.stock(), f.balance()) + } + if e, _ := f.entryLegs(id); e != 0 { + t.Errorf("%d journal entries for a draft", e) + } +} + +// RULE (ADR 0001 section 4): one 400 with every offending field and its full +// path; an unknown body field refused; a currency sent is refused. +func TestCreditMemoValidation(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("2") + line := f.firstLineID(invID) + body := map[string]any{ + "invoice_id": invID, "reason_code": "refund", "reason": " ", + "lines": []map[string]any{ + {"invoice_line_id": line, "quantity": "2"}, // positive + {"invoice_line_id": line, "quantity": "-1", "product_id": f.productID.String()}, // price fields with an invoice line + {"line_type": "product", "quantity": "-1"}, // no description, no price + {"line_type": "kit", "quantity": "-1", "description": "k"}, // a server's type + {"line_type": "charge", "quantity": "-1"}, // no code + }, + } + r := f.do("POST", "/api/v1/credit-memos", body) + if r.status != 400 { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + code, _, fields := errorOf(t, r) + if code != "validation_failed" { + t.Errorf("code = %q, want validation_failed", code) + } + want := []string{"reason_code", "reason", "lines[0].quantity", "lines[1].product_id", "lines[2].description", "lines[2].unit_price_ten_thousandths", "lines[3].line_type", "lines[4].charge_code"} + for _, w := range want { + found := false + for _, got := range fields { + if got == w { + found = true + } + } + if !found { + t.Errorf("no detail for %s in %v", w, fields) + } + } + if r := f.do("POST", "/api/v1/credit-memos", map[string]any{"invoice_id": invID, "reason_code": "return", "reason": "x", "currency": "CAD", "lines": []map[string]any{returnLine(line, "-1", false)}}); r.status != 400 { + t.Errorf("a currency in the body = %d, want 400 (never sent)", r.status) + } + if r := f.do("POST", "/api/v1/credit-memos", map[string]any{"invoice_id": invID, "reason_code": "return", "reason": "x", "revision": 1, "lines": []map[string]any{returnLine(line, "-1", false)}}); r.status != 400 { + t.Errorf("a revision on create = %d, want 400", r.status) + } + if r := f.do("POST", "/api/v1/credit-memos", map[string]any{"reason_code": "return", "reason": "x", "lines": []map[string]any{{"line_type": "text", "description": "n"}}}); r.status != 400 { + t.Errorf("no customer or invoice = %d, want 400", r.status) + } + // lines that are only notes credit nothing + if r := f.do("POST", "/api/v1/credit-memos", map[string]any{"invoice_id": invID, "reason_code": "return", "reason": "x", "lines": []map[string]any{{"line_type": "text", "description": "n"}}}); r.status != 400 { + t.Errorf("a credit memo of notes = %d, want 400", r.status) + } + // a line that is not on this invoice, a bad reference + other := uuid.NewString() + if r := f.do("POST", "/api/v1/credit-memos", f.creditBody(invID, returnLine(other, "-1", false))); r.status != 400 { + t.Errorf("a line of another invoice = %d, want 400", r.status) + } + if r := f.do("POST", "/api/v1/credit-memos", map[string]any{"invoice_id": uuid.NewString(), "reason_code": "return", "reason": "x", "lines": []map[string]any{returnLine(line, "-1", false)}}); r.status != 400 { + t.Errorf("an unknown invoice = %d, want 400", r.status) + } + if r := f.do("POST", "/api/v1/credit-memos", map[string]any{"customer_id": uuid.NewString(), "reason_code": "price_adjustment", "reason": "x", + "lines": []map[string]any{{"line_type": "charge", "charge_code": "ADJUST", "quantity": "-1", "unit_price_ten_thousandths": 100}}}); r.status != 400 { + t.Errorf("an unknown customer = %d, want 400", r.status) + } + if n := countOf(t, db, `SELECT count(*) FROM credit_memos WHERE customer_id = $1`, f.customerID); n != 0 { + t.Errorf("%d credit memos from refused creates", n) + } +} + +// RULE (ADR 0005 6.3): a line cannot credit more than the invoice line billed +// less what earlier credit memos returned (409 exceeds_billed), the drafts +// counted at draft time. +func TestCreditMemoCannotReturnMoreThanBilled(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("10") + line := f.firstLineID(invID) + + if r := f.do("POST", "/api/v1/credit-memos", f.creditBody(invID, returnLine(line, "-10.0001", false))); r.status != 409 { + t.Fatalf("returning 10.0001 of 10 = %d, want 409: %s", r.status, r.raw) + } else if code, blockers, _ := errorOf(t, r); code != "conflict" || fmt.Sprint(blockers) != "[exceeds_billed]" { + t.Errorf("code %q blockers %v, want conflict exceeds_billed", code, blockers) + } + first := f.createCredit(f.creditBody(invID, returnLine(line, "-6", false))) + if r := f.do("POST", "/api/v1/credit-memos", f.creditBody(invID, returnLine(line, "-5", false))); r.status != 409 { + t.Errorf("6 drafted then 5 more of 10 = %d, want 409 exceeds_billed", r.status) + } + second := f.createCredit(f.creditBody(invID, returnLine(line, "-4", false))) // exactly the rest + // post the first; the second still fits what is left + if r := f.postCredit(str(t, first.body, "id"), 1); r.status != 200 { + t.Fatalf("post = %d: %s", r.status, r.raw) + } + if r := f.postCredit(str(t, second.body, "id"), 1); r.status != 200 { + t.Fatalf("post the second = %d: %s", r.status, r.raw) + } + // everything billed is credited: nothing more + if r := f.do("POST", "/api/v1/credit-memos", f.creditBody(invID, returnLine(line, "-0.0001", false))); r.status != 409 { + t.Errorf("returning more after the whole line is credited = %d, want 409", r.status) + } +} + +// RULE (ADR 0005 3 and 6.3): partial credit memos never credit more tax than the +// invoice charged: seven single piece returns each round their tax up (48.8 to +// 49 cents, seven times 343) against an invoice that charged 342, so the last +// takes exactly the remainder (48), and the credited tax sums to the charge. +func TestPartialCreditMemosNeverCreditMoreTaxThanCharged(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("7") // 38.50, tax 8.875% = 341.69 -> 342 + if inv := f.getInvoice(invID); num(t, inv.body, "tax_cents") != 342 || num(t, inv.body, "subtotal_cents") != 3850 { + t.Fatalf("invoice tax %v subtotal %v, want 342 and 3850", inv.body["tax_cents"], inv.body["subtotal_cents"]) + } + line := f.firstLineID(invID) + var taxes []int64 + var subtotals int64 + for i := 0; i < 7; i++ { + cm := f.createCredit(f.creditBody(invID, returnLine(line, "-1", false))) + r := f.postCredit(str(t, cm.body, "id"), 1) + if r.status != 200 { + t.Fatalf("post %d = %d: %s", i, r.status, r.raw) + } + taxes = append(taxes, -num(t, r.body, "tax_cents")) + subtotals += -num(t, r.body, "subtotal_cents") + } + var sum int64 + for _, tx := range taxes { + sum += tx + } + if sum != 342 { + t.Errorf("tax credited %v sums to %d, want exactly the 342 charged", taxes, sum) + } + if taxes[6] != 48 { + t.Errorf("the last memo credited %d, want the remainder 48", taxes[6]) + } + if subtotals != 3850 { + t.Errorf("subtotals credited %d, want the whole 3850 to the cent", subtotals) + } + for i := 0; i < 6; i++ { + if taxes[i] != 49 { + t.Errorf("memo %d credited %d tax, want 49", i, taxes[i]) + } + } + // the receivable is whole again: invoice 4192 - 7 credits + if f.balance() != 0 { + t.Errorf("balance after crediting the whole invoice = %d, want 0", f.balance()) + } +} + +// RULE (ADR 0005 6.3, 4.1): a draft carries no number and takes the next gapless +// CM- number at post; a voided draft consumes none; the entry, the subledger and +// the restock all land in the post. +func TestCreditMemoNumberIsMintedAtPost(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("10") + line := f.firstLineID(invID) + + var failure error + for attempt := 0; attempt < 3; attempt++ { + failure = nil + start := counterNext(t, db, "credit_memo") + a := f.createCredit(f.creditBody(invID, returnLine(line, "-1", false))) + b := f.createCredit(f.creditBody(invID, returnLine(line, "-1", false))) + c := f.createCredit(f.creditBody(invID, returnLine(line, "-1", false))) + for _, d := range []resp{a, b, c} { + if d.body["number"] != nil { + t.Fatalf("a draft carries a number: %v", d.body["number"]) + } + } + if got := counterNext(t, db, "credit_memo"); got != start { + failure = fmt.Errorf("drafts moved the counter from %d to %d", start, got) + continue + } + // void the middle draft: it never held a number + v := f.do("POST", "/api/v1/credit-memos/"+str(t, b.body, "id")+"/transitions", map[string]any{"to": "void", "revision": 1, "reason": "oops"}) + if v.status != 200 || v.body["number"] != nil || str(t, v.body, "status") != "void" { + t.Fatalf("void draft = %d: %s", v.status, v.raw) + } + pa := f.postCredit(str(t, a.body, "id"), 1) + pc := f.postCredit(str(t, c.body, "id"), 1) + if pa.status != 200 || pc.status != 200 { + t.Fatalf("post = %d / %d: %s %s", pa.status, pc.status, pa.raw, pc.raw) + } + na, nc := str(t, pa.body, "number"), str(t, pc.body, "number") + if !creditNumberPattern.MatchString(na) || na != fmt.Sprintf("CM-%06d", start) || nc != fmt.Sprintf("CM-%06d", start+1) { + failure = fmt.Errorf("posted numbers %s and %s, want CM-%06d and CM-%06d (the voided draft consumed none)", na, nc, start, start+1) + continue + } + if str(t, pa.body, "status") != "open" || pa.body["gl_entry_id"] == nil || rev(t, pa) != 2 || num(t, pa.body, "open_cents") != num(t, pa.body, "total_cents") { + t.Errorf("posted memo = %s", pa.raw) + } + if ev := eventTypes(t, db, "credit_memo", str(t, a.body, "id")); fmt.Sprint(ev) != "[credit_memo.created credit_memo.posted]" { + t.Errorf("events = %v", ev) + } + break + } + if failure != nil { + t.Fatal(failure) + } +} + +// RULE (ADR 0005 8.2 and 8.4): a restocking credit memo's post returns the +// quantity to on hand and writes one balanced entry: revenue and tax back, +// receivable down, inventory up and cost of goods sold down at the cost that +// LEFT (the invoice line's), not today's average. A return without restock +// writes no inventory or cost leg. The void reverses both. +func TestRestockingCreditMemoReversesCOGSAtTheOriginalCost(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("10") // cost 3.25 each + line := f.firstLineID(invID) + mustExec(t, db, `UPDATE products SET average_unit_cost = 9.99 WHERE id = $1`, f.productID) // the cost moved since + + cm := f.createCredit(f.creditBody(invID, returnLine(line, "-4", true))) + id := str(t, cm.body, "id") + r := f.postCredit(id, 1) + if r.status != 200 { + t.Fatalf("post = %d: %s", r.status, r.raw) + } + if f.stock() != "94.0000/0.0000" { + t.Errorf("stock after the post = %s, want 94 on hand", f.stock()) + } + entries, legs := f.entryLegs(id) + if entries != 1 { + t.Fatalf("%d entries for the credit memo, want 1", entries) + } + want := map[string]leg{ + "1020": {0, 2395}, "4010": {2200, 0}, "2020": {195, 0}, "1030": {1300, 0}, "5010": {0, 1300}, + } + if len(legs) != len(want) { + t.Errorf("legs = %v, want exactly %v", legs, want) + } + for code, w := range want { + if legs[code] != w { + t.Errorf("leg %s = %+v, want %+v (1300 is 4 x the ORIGINAL 3.25, not 9.99)", code, legs[code], w) + } + } + if f.balance() != 5988-2395 { + t.Errorf("balance = %d, want %d", f.balance(), 5988-2395) + } + + // a return that does not restock moves no stock and writes no cost legs + cm2 := f.createCredit(f.creditBody(invID, returnLine(line, "-1", false))) + id2 := str(t, cm2.body, "id") + if r := f.postCredit(id2, 1); r.status != 200 { + t.Fatalf("post 2 = %d: %s", r.status, r.raw) + } + _, legs2 := f.entryLegs(id2) + if _, ok := legs2["1030"]; ok { + t.Errorf("a return without restock wrote an inventory leg: %v", legs2) + } + if _, ok := legs2["5010"]; ok { + t.Errorf("a return without restock wrote a cost leg: %v", legs2) + } + if f.stock() != "94.0000/0.0000" { + t.Errorf("stock after a no restock return = %s, want 94", f.stock()) + } + + // the void reverses the entry and the restock + v := f.do("POST", "/api/v1/credit-memos/"+id+"/transitions", map[string]any{"to": "void", "revision": 2, "reason": "returned in error"}) + if v.status != 200 || str(t, v.body, "status") != "void" || str(t, v.body, "number") != str(t, r.body, "number") { + t.Fatalf("void = %d: %s", v.status, v.raw) + } + if f.stock() != "90.0000/0.0000" { + t.Errorf("stock after the void = %s, want 90 (the restock reversed)", f.stock()) + } + if n, rl := f.reversalLegs(id); n != 1 || rl["1030"] != (leg{0, 1300}) || rl["5010"] != (leg{1300, 0}) || rl["1020"] != (leg{2395, 0}) { + t.Errorf("reversal %d %v", n, rl) + } + if f.balance() != 5988-599 { + t.Errorf("balance after voiding the first memo = %d, want 5389 (the invoice less the second memo's 599)", f.balance()) + } + if ev := eventTypes(t, db, "credit_memo", id); fmt.Sprint(ev) != "[credit_memo.created credit_memo.posted credit_memo.voided]" { + t.Errorf("events = %v", ev) + } + // a void when the restocked goods are gone is refused + cm3 := f.createCredit(f.creditBody(invID, returnLine(line, "-2", true))) + if r := f.postCredit(str(t, cm3.body, "id"), 1); r.status != 200 { + t.Fatalf("post 3 = %d: %s", r.status, r.raw) + } + mustExec(t, db, `UPDATE inventory SET quantity = 0 WHERE product_id = $1`, f.productID) // sold elsewhere + r3 := f.do("POST", "/api/v1/credit-memos/"+str(t, cm3.body, "id")+"/transitions", map[string]any{"to": "void", "revision": 2, "reason": "too late"}) + if _, blockers, _ := errorOf(t, r3); r3.status != 409 || fmt.Sprint(blockers) != "[restock_consumed]" { + t.Errorf("void of a restock whose goods are gone = %d %v, want 409 restock_consumed", r3.status, blockers) + } +} + +// RULE (ADR 0001 section 11): the draft edit takes the revision (428, 409 stale, +// If-Match strong and weak, the body revision), recomputes the lines, and works +// on drafts only (409 credit_memo_not_draft). The transitions refuse everything +// outside the table. +func TestCreditMemoEditAndTransitions(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("10") + line := f.firstLineID(invID) + cm := f.createCredit(f.creditBody(invID, returnLine(line, "-2", false))) + id := str(t, cm.body, "id") + edit := f.creditBody(invID, returnLine(line, "-3", false)) + edit["reason"] = "edited" + + if r := f.do("PUT", "/api/v1/credit-memos/"+id, edit); r.status != 428 { + t.Errorf("PUT without a precondition = %d, want 428", r.status) + } + if r := f.do("PUT", "/api/v1/credit-memos/"+id, edit, "If-Match", `"7"`); r.status != 409 { + t.Errorf("PUT on a stale revision = %d, want 409", r.status) + } + r := f.do("PUT", "/api/v1/credit-memos/"+id, edit, "If-Match", `W/"1"`) + if r.status != 200 || rev(t, r) != 2 || str(t, r.body, "reason") != "edited" || num(t, r.body, "subtotal_cents") != -1650 { + t.Fatalf("PUT = %d: %s", r.status, r.raw) + } + // the customer and the invoice are fixed once created + other := f.creditBody(invID, returnLine(line, "-1", false)) + other["customer_id"] = uuid.NewString() + if r := f.do("PUT", "/api/v1/credit-memos/"+id, other, "If-Match", `"2"`); r.status != 400 { + t.Errorf("PUT naming another customer = %d, want 400", r.status) + } + // a line keeps its id when the edit sends it + lineID := r.body["lines"].([]any)[0].(map[string]any)["id"].(string) + keep := f.creditBody(invID, returnLine(line, "-3", false)) + keep["lines"].([]map[string]any)[0]["id"] = lineID + if r2 := f.do("PUT", "/api/v1/credit-memos/"+id, keep, "If-Match", `"2"`); r2.status != 200 || r2.body["lines"].([]any)[0].(map[string]any)["id"] != lineID { + t.Errorf("PUT keeping the line id = %d: %s", r2.status, r2.raw) + } + + for _, to := range []string{"partial", "applied", "draft"} { + x := f.do("POST", "/api/v1/credit-memos/"+id+"/transitions", map[string]any{"to": to, "revision": 3}) + if code, _, _ := errorOf(t, x); x.status != 409 || code != "invalid_state_transition" { + t.Errorf("transition to %s = %d %q, want 409 invalid_state_transition", to, x.status, code) + } + } + if x := f.do("POST", "/api/v1/credit-memos/"+id+"/transitions", map[string]any{"to": "void", "revision": 3}); x.status != 400 { + t.Errorf("void without a reason = %d, want 400", x.status) + } + if x := f.do("POST", "/api/v1/credit-memos/"+id+"/transitions", map[string]any{"to": "open", "revision": 3}, "X-Test-Role", "sales"); x.status != 403 { + t.Errorf("a sales post = %d, want 403", x.status) + } + post := f.postCredit(id, 3) + if post.status != 200 { + t.Fatalf("post = %d: %s", post.status, post.raw) + } + if x := f.postCredit(id, 4); x.status != 409 { + t.Errorf("posting twice = %d, want 409", x.status) + } + put := f.do("PUT", "/api/v1/credit-memos/"+id, edit, "If-Match", `"4"`) + if _, blockers, _ := errorOf(t, put); put.status != 409 || fmt.Sprint(blockers) != "[credit_memo_not_draft]" { + t.Errorf("PUT on a posted memo = %d %v, want 409 credit_memo_not_draft", put.status, blockers) + } + // a void invoice cannot be credited + voidInv, _ := f.invoice("2") + vl := f.firstLineID(voidInv) + if v := f.voidInvoice(voidInv, rev(t, f.getInvoice(voidInv)), "billed twice"); v.status != 200 { + t.Fatalf("void the invoice = %d: %s", v.status, v.raw) + } + x := f.do("POST", "/api/v1/credit-memos", f.creditBody(voidInv, returnLine(vl, "-1", false))) + if _, blockers, _ := errorOf(t, x); x.status != 409 || fmt.Sprint(blockers) != "[invoice_void]" { + t.Errorf("a credit memo against a void invoice = %d %v, want 409 invoice_void", x.status, blockers) + } +} + +// RULE (ADR 0005 6.3): the credit memo list is the cursor envelope with working +// filters, and an empty page is [] in the bytes. +func TestCreditMemoList(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("10") + line := f.firstLineID(invID) + var ids []string + for i := 0; i < 3; i++ { + ids = append(ids, str(t, f.createCredit(f.creditBody(invID, returnLine(line, "-1", false))).body, "id")) + } + f.postCredit(ids[0], 1) + var seen []string + cursor := "" + for page := 0; page < 5; page++ { + path := "/api/v1/credit-memos?limit=2&invoice_id=" + invID + if cursor != "" { + path += "&cursor=" + cursor + } + r := f.do("GET", path, nil) + if r.status != 200 { + t.Fatalf("list = %d: %s", r.status, r.raw) + } + for _, it := range r.body["items"].([]any) { + seen = append(seen, it.(map[string]any)["id"].(string)) + } + if r.body["next_cursor"] == nil { + break + } + cursor = r.body["next_cursor"].(string) + } + if fmt.Sprint(seen) != fmt.Sprint([]string{ids[2], ids[1], ids[0]}) { + t.Errorf("walk = %v, want newest first each once", seen) + } + if r := f.do("GET", "/api/v1/credit-memos?customer_id="+f.customerID.String()+"&status=open", nil); len(r.body["items"].([]any)) != 1 { + t.Errorf("status=open = %s", r.raw) + } + if r := f.do("GET", "/api/v1/credit-memos?customer_id="+f.customerID.String()+"&status=draft,open&include=total", nil); num(t, r.body, "total") != 3 { + t.Errorf("include=total = %s", r.raw) + } + empty := f.do("GET", "/api/v1/credit-memos?customer_id="+uuid.NewString(), nil) + if !strings.Contains(string(empty.raw), `"items":[]`) { + t.Errorf("empty page = %s", empty.raw) + } + for _, bad := range []string{"status=PENDING", "offset=1", "customer=x", "limit=0", "job_id=zzz"} { + if r := f.do("GET", "/api/v1/credit-memos?"+bad, nil); r.status != 400 { + t.Errorf("?%s = %d, want 400", bad, r.status) + } + } + if r := f.do("GET", "/api/v1/credit-memos/"+uuid.NewString(), nil); r.status != 404 { + t.Errorf("unknown credit memo = %d, want 404", r.status) + } + // the old routes are gone + if r := f.do("GET", "/api/v1/credit-memos/"+f.customerID.String()+"/x", nil); r.status != 404 && r.status != 405 { + t.Errorf("a stray credit memo path = %d", r.status) + } + if r := f.do("POST", "/api/v1/invoices/"+invID+"/credit-memo", map[string]any{"amount_cents": 500, "reason": "old route"}); r.status != 404 && r.status != 405 { + t.Errorf("the removed invoice credit memo route = %d, want 404 or 405", r.status) + } +} + +// RULE (ADR 0005 3, 6.3): a credit memo with no invoice (a price given back) takes +// the customer's currency and the branch rate, and a free charge line posts to +// its code's account; the idempotent replay makes one memo and one event. +func TestCreditMemoWithNoInvoice(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + body := map[string]any{"customer_id": f.customerID.String(), "reason_code": "price_adjustment", "reason": "goodwill", + "lines": []map[string]any{ + {"line_type": "product", "description": "Freight allowance", "quantity": "-1", "uom": "EA", "unit_price_ten_thousandths": 100000}, + {"line_type": "text", "description": "per the account manager"}, + }} + r := f.do("POST", "/api/v1/credit-memos", body, "Idempotency-Key", "cm-once") + if r.status != 201 { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + if again := f.do("POST", "/api/v1/credit-memos", body, "Idempotency-Key", "cm-once"); again.header.Get("Idempotency-Replayed") != "true" || str(t, again.body, "id") != str(t, r.body, "id") { + t.Errorf("the replay = %d replayed %q", again.status, again.header.Get("Idempotency-Replayed")) + } + if n := countOf(t, db, `SELECT count(*) FROM credit_memos WHERE customer_id = $1`, f.customerID); n != 1 { + t.Errorf("%d credit memos after a replay, want 1", n) + } + // 10.00 credited, taxable by default, at the branch rate 8.875% = 88.75 -> 89 + for key, want := range map[string]int64{"subtotal_cents": -1000, "tax_cents": -89, "total_cents": -1089} { + if got := num(t, r.body, key); got != want { + t.Errorf("%s = %d, want %d", key, got, want) + } + } + if r.body["invoice_id"] != nil || str(t, r.body, "currency") != "USD" { + t.Errorf("invoice_id %v currency %v", r.body["invoice_id"], r.body["currency"]) + } + id := str(t, r.body, "id") + if p := f.postCredit(id, 1); p.status != 200 { + t.Fatalf("post = %d: %s", p.status, p.raw) + } + _, legs := f.entryLegs(id) + if legs["4010"] != (leg{1000, 0}) || legs["2020"] != (leg{89, 0}) || legs["1020"] != (leg{0, 1089}) { + t.Errorf("legs = %v", legs) + } + if f.balance() != -1089 { + t.Errorf("balance = %d, want -1089 (the customer is owed)", f.balance()) + } +} diff --git a/core/internal/invoice/fixture_test.go b/core/internal/invoice/fixture_test.go new file mode 100644 index 00000000..94056ad7 --- /dev/null +++ b/core/internal/invoice/fixture_test.go @@ -0,0 +1,475 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice_test + +// The invoice and credit memo modules end to end: a real Postgres, the real +// order, invoice, inventory, general ledger and account services wired as serve +// wires them, the real handlers on one mux behind the real idempotency +// middleware and the branch middleware. Requests are JSON and responses are read +// back as JSON, so each test states a wire fact. The orders that produce the +// invoices are made through the order routes (create, confirm, fulfil). + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/invoice" + "github.com/gablelbm/gable/internal/order" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +type fixture struct { + t *testing.T + db *database.DB + srv *httptest.Server + orders *order.Service + invoices *invoice.Service + customerID uuid.UUID + productID uuid.UUID + sku string + branchID uuid.UUID + yardID uuid.UUID +} + +const defaultBranch = `(SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')` + +// newFixture builds a customer, a product costing 3.25 and priced 5.50 (a +// pickup order's tax is the branch rate, 8.875 percent) with 100 on hand, and +// serves the order and invoice routes. opts tune the services before they are +// served (an event recorder that fails, a branch guard). +func newFixture(t *testing.T, db *database.DB, opts ...func(*fixture)) *fixture { + t.Helper() + t.Setenv("AUTH_MODE", "dev") + ctx := context.Background() + f := &fixture{t: t, db: db, customerID: uuid.New(), productID: uuid.New(), sku: "INV-" + uuid.NewString()[:8]} + if err := db.Pool.QueryRow(ctx, `SELECT `+defaultBranch).Scan(&f.branchID); err != nil { + t.Fatalf("default branch: %v", err) + } + mustExec(t, db, `INSERT INTO customers (id, name, account_number, primary_branch_id) VALUES ($1, 'Wire Invoice Co', $2, `+defaultBranch+`)`, + f.customerID, "WINV-"+uuid.NewString()[:8]) + mustExec(t, db, `INSERT INTO products (id, sku, description, uom_primary, base_price, average_unit_cost) VALUES ($1, $2, '2x4x8 SPF', 'PCS', 5.5, 3.25)`, + f.productID, f.sku) + mustExec(t, db, `UPDATE locations SET default_tax_rate = 0.088750 WHERE id = `+defaultBranch) + f.yardID = uuid.New() + mustExec(t, db, `INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'YARD', $2, `+defaultBranch+`, `+defaultBranch+`)`, + f.yardID, "IV-"+f.yardID.String()[:8]) + mustExec(t, db, `INSERT INTO inventory (product_id, location_id, location, quantity, allocated) VALUES ($1, $2, 'Y', 100, 0)`, f.productID, f.yardID) + + logger := slog.Default() + glSvc := gl.NewService(gl.NewRepository(db), nil, logger) + acct := account.NewService(account.NewRepository(db), db, logger) + stock := inventory.NewService(inventory.NewRepository(db)) + f.invoices = invoice.NewService(invoice.NewRepository(db), glSvc, acct, db). + WithAuditLog(audit.NewLogger(db)).WithOutbox(outbox.NewWriter(db, "")).WithStock(stock) + f.orders = order.NewService(order.NewRepository(db)).WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db). + WithAuditLog(audit.NewLogger(db)).WithInventory(stock).WithInvoices(f.invoices) + f.invoices.WithOrders(f.orders) + for _, o := range opts { + o(f) + } + + mux := http.NewServeMux() + order.NewHandler(f.orders).RegisterRoutes(mux) + invoice.NewHandler(f.invoices).RegisterRoutes(mux) + f.srv = httptest.NewServer(middleware.Idempotency(db)(roleClaims(middleware.NewBranchMiddleware(db).Handler(mux)))) + + t.Cleanup(func() { + f.srv.Close() + f.cleanup() + }) + return f +} + +func mustExec(t *testing.T, db *database.DB, sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(context.Background(), sql, args...); err != nil { + t.Fatalf("%s: %v", strings.Join(strings.Fields(sql), " "), err) + } +} + +// roleClaims stands in for authentication: X-Test-Role becomes the claims a JWT +// would carry; X-Test-Sub its subject. +func roleClaims(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if role := r.Header.Get("X-Test-Role"); role != "" { + claims := &middleware.UserClaims{Role: role, Roles: []string{role}} + claims.Subject = r.Header.Get("X-Test-Sub") + r = r.WithContext(context.WithValue(r.Context(), middleware.UserContextKey, claims)) + } + next.ServeHTTP(w, r) + }) +} + +// cleanup removes everything the fixture's customer produced, children first. +func (f *fixture) cleanup() { + ctx := context.Background() + exec := func(sql string, args ...any) { _, _ = f.db.Pool.Exec(ctx, sql, args...) } + invs := `(SELECT id FROM invoices WHERE customer_id = $1)` + cms := `(SELECT id FROM credit_memos WHERE customer_id = $1)` + entries := `(SELECT id FROM gl_journal_entries WHERE source_ref_id IN ` + invs + ` OR source_ref_id IN ` + cms + `)` + exec(`DELETE FROM gl_journal_lines WHERE journal_entry_id IN (SELECT id FROM gl_journal_entries WHERE reverses_entry_id IN `+entries+`)`, f.customerID) + exec(`DELETE FROM gl_journal_entries WHERE reverses_entry_id IN `+entries, f.customerID) + exec(`DELETE FROM gl_journal_lines WHERE journal_entry_id IN `+entries, f.customerID) + exec(`DELETE FROM gl_journal_entries WHERE id IN `+entries, f.customerID) + exec(`DELETE FROM events_outbox WHERE entity_id IN `+invs+` OR entity_id IN `+cms+` OR entity_id IN (SELECT id FROM orders WHERE customer_id = $1)`, f.customerID) + exec(`DELETE FROM audit_log WHERE entity_id IN `+invs+` OR entity_id IN `+cms+` OR entity_id IN (SELECT id FROM orders WHERE customer_id = $1)`, f.customerID) + exec(`DELETE FROM customer_transactions WHERE customer_id = $1`, f.customerID) + exec(`DELETE FROM credit_memo_lines WHERE credit_memo_id IN `+cms, f.customerID) + exec(`DELETE FROM credit_memos WHERE customer_id = $1`, f.customerID) + exec(`DELETE FROM payments WHERE invoice_id IN `+invs, f.customerID) + exec(`DELETE FROM invoice_lines WHERE invoice_id IN `+invs, f.customerID) + exec(`DELETE FROM invoices WHERE customer_id = $1`, f.customerID) + exec(`DELETE FROM order_lines WHERE order_id IN (SELECT id FROM orders WHERE customer_id = $1)`, f.customerID) + exec(`DELETE FROM orders WHERE customer_id = $1`, f.customerID) + exec(`DELETE FROM inventory WHERE location_id = $1`, f.yardID) + exec(`DELETE FROM locations WHERE id = $1`, f.yardID) + exec(`DELETE FROM products WHERE id = $1`, f.productID) + exec(`DELETE FROM customers WHERE id = $1`, f.customerID) +} + +type resp struct { + status int + header http.Header + body map[string]any + raw []byte +} + +func (f *fixture) do(method, path string, body any, headers ...string) resp { + f.t.Helper() + return doOn(f.t, f.srv.URL, method, path, body, headers...) +} + +func doOn(t *testing.T, base, method, path string, body any, headers ...string) resp { + t.Helper() + var rdr io.Reader + switch b := body.(type) { + case nil: + case string: + rdr = strings.NewReader(b) + default: + raw, err := json.Marshal(b) + if err != nil { + t.Fatal(err) + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, base+path, rdr) + if err != nil { + t.Fatal(err) + } + if rdr != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Request-ID", "req-inv-test") + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + out := resp{status: res.StatusCode, header: res.Header, raw: raw} + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + _ = dec.Decode(&out.body) + return out +} + +func str(t *testing.T, m map[string]any, key string) string { + t.Helper() + s, ok := m[key].(string) + if !ok { + t.Fatalf("%s is %T (%v), want a string", key, m[key], m[key]) + } + return s +} + +func num(t *testing.T, m map[string]any, key string) int64 { + t.Helper() + n, ok := m[key].(json.Number) + if !ok { + t.Fatalf("%s is %T (%v), want an integer", key, m[key], m[key]) + } + v, err := n.Int64() + if err != nil { + t.Fatalf("%s = %s, want an integer: %v", key, n, err) + } + return v +} + +func rev(t *testing.T, r resp) int64 { return num(t, r.body, "revision") } + +func errorOf(t *testing.T, r resp) (code string, blockers []string, fields []string) { + t.Helper() + e, ok := r.body["error"].(map[string]any) + if !ok { + t.Fatalf("no error envelope in %s", r.raw) + } + code, _ = e["code"].(string) + if ds, ok := e["details"].([]any); ok { + for _, d := range ds { + dm := d.(map[string]any) + if c, _ := dm["code"].(string); c != "" { + blockers = append(blockers, c) + } + if fld, _ := dm["field"].(string); fld != "" { + fields = append(fields, fld) + } + } + } + if meta, _ := r.body["meta"].(map[string]any); meta["request_id"] != "req-inv-test" { + t.Errorf("meta.request_id = %v, want the request's id", meta["request_id"]) + } + return code, blockers, fields +} + +// --------------------------------------------------------------------------- +// Producing invoices through the order routes. +// --------------------------------------------------------------------------- + +// pickupLine is a stocked product line of qty. +func (f *fixture) pickupLine(qty string) map[string]any { + return map[string]any{"product_id": f.productID.String(), "quantity": qty} +} + +// order makes a pickup order of the lines (a single line of 10 by default), +// confirmed. It answers the order id and its revision. +func (f *fixture) confirmedOrder(lines ...map[string]any) (string, int64) { + f.t.Helper() + if len(lines) == 0 { + lines = []map[string]any{f.pickupLine("10")} + } + r := f.do("POST", "/api/v1/orders", map[string]any{"customer_id": f.customerID.String(), "delivery_type": "pickup", "lines": lines}) + if r.status != http.StatusCreated { + f.t.Fatalf("create order = %d: %s", r.status, r.raw) + } + id := str(f.t, r.body, "id") + r = f.do("POST", "/api/v1/orders/"+id+"/transitions", map[string]any{"to": "confirmed", "revision": 1}) + if r.status != http.StatusOK || str(f.t, r.body, "status") != "confirmed" { + f.t.Fatalf("confirm order = %d %v: %s", r.status, r.body["status"], r.raw) + } + return id, rev(f.t, r) +} + +// fulfil bills the order (all of it when lines is nil) and answers the invoice +// id and the order's new revision. +func (f *fixture) fulfil(orderID string, revision int64, lines []map[string]any) (invoiceID string, newRev int64) { + f.t.Helper() + body := map[string]any{"revision": revision, "picked_up_by": "Counter customer"} + if lines != nil { + body["lines"] = lines + } + r := f.do("POST", "/api/v1/orders/"+orderID+"/fulfillments", body) + if r.status != http.StatusCreated { + f.t.Fatalf("fulfil = %d: %s", r.status, r.raw) + } + loc := r.header.Get("Location") + if !strings.HasPrefix(loc, "/api/v1/invoices/") { + f.t.Fatalf("Location = %q", loc) + } + return strings.TrimPrefix(loc, "/api/v1/invoices/"), rev(f.t, r) +} + +// invoice makes an order of qty, confirms it and bills it all: one invoice. +func (f *fixture) invoice(qty string) (invoiceID, orderID string) { + f.t.Helper() + orderID, r := f.confirmedOrder(f.pickupLine(qty)) + invoiceID, _ = f.fulfil(orderID, r, nil) + return invoiceID, orderID +} + +func (f *fixture) getInvoice(id string) resp { + f.t.Helper() + r := f.do("GET", "/api/v1/invoices/"+id, nil) + if r.status != http.StatusOK { + f.t.Fatalf("get invoice = %d: %s", r.status, r.raw) + } + return r +} + +// firstLineID is the id of the invoice's first line. +func (f *fixture) firstLineID(invoiceID string) string { + f.t.Helper() + r := f.getInvoice(invoiceID) + lines := r.body["lines"].([]any) + return lines[0].(map[string]any)["id"].(string) +} + +func (f *fixture) voidInvoice(id string, revision int64, reason string, headers ...string) resp { + f.t.Helper() + return f.do("POST", "/api/v1/invoices/"+id+"/transitions", map[string]any{"to": "void", "revision": revision, "reason": reason}, headers...) +} + +// creditBody is a credit memo request of the lines against the invoice. +func (f *fixture) creditBody(invoiceID string, lines ...map[string]any) map[string]any { + return map[string]any{"invoice_id": invoiceID, "reason_code": "return", "reason": "customer returned goods", "lines": lines} +} + +func returnLine(invoiceLineID, qty string, restock bool) map[string]any { + return map[string]any{"invoice_line_id": invoiceLineID, "quantity": qty, "restock": restock} +} + +func (f *fixture) createCredit(body map[string]any) resp { + f.t.Helper() + r := f.do("POST", "/api/v1/credit-memos", body) + if r.status != http.StatusCreated { + f.t.Fatalf("create credit memo = %d: %s", r.status, r.raw) + } + return r +} + +func (f *fixture) postCredit(id string, revision int64) resp { + f.t.Helper() + return f.do("POST", "/api/v1/credit-memos/"+id+"/transitions", map[string]any{"to": "open", "revision": revision}) +} + +// --------------------------------------------------------------------------- +// Reading the ledger and the stock. +// --------------------------------------------------------------------------- + +type leg struct{ debit, credit int64 } + +// entryLegs sums, per account code, the legs of the entries whose source +// reference is ref (the invoice or credit memo id). +func (f *fixture) entryLegs(ref string) (entries int, legs map[string]leg) { + f.t.Helper() + ctx := context.Background() + if err := f.db.Pool.QueryRow(ctx, `SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, ref).Scan(&entries); err != nil { + f.t.Fatal(err) + } + rows, err := f.db.Pool.Query(ctx, ` + SELECT a.code, ROUND(SUM(l.debit) * 100)::bigint, ROUND(SUM(l.credit) * 100)::bigint + FROM gl_journal_lines l JOIN gl_journal_entries e ON e.id = l.journal_entry_id JOIN gl_accounts a ON a.id = l.account_id + WHERE e.source_ref_id = $1 GROUP BY a.code`, ref) + if err != nil { + f.t.Fatal(err) + } + defer rows.Close() + legs = map[string]leg{} + for rows.Next() { + var code string + var l leg + if err := rows.Scan(&code, &l.debit, &l.credit); err != nil { + f.t.Fatal(err) + } + legs[code] = l + } + return entries, legs +} + +// reversalLegs sums the legs of the reversal of an invoice's or credit memo's +// entry. +func (f *fixture) reversalLegs(ref string) (entries int, legs map[string]leg) { + f.t.Helper() + ctx := context.Background() + if err := f.db.Pool.QueryRow(ctx, ` + SELECT count(*) FROM gl_journal_entries r JOIN gl_journal_entries o ON o.id = r.reverses_entry_id WHERE o.source_ref_id = $1`, ref).Scan(&entries); err != nil { + f.t.Fatal(err) + } + rows, err := f.db.Pool.Query(ctx, ` + SELECT a.code, ROUND(SUM(l.debit) * 100)::bigint, ROUND(SUM(l.credit) * 100)::bigint + FROM gl_journal_lines l JOIN gl_journal_entries r ON r.id = l.journal_entry_id + JOIN gl_journal_entries o ON o.id = r.reverses_entry_id JOIN gl_accounts a ON a.id = l.account_id + WHERE o.source_ref_id = $1 GROUP BY a.code`, ref) + if err != nil { + f.t.Fatal(err) + } + defer rows.Close() + legs = map[string]leg{} + for rows.Next() { + var code string + var l leg + if err := rows.Scan(&code, &l.debit, &l.credit); err != nil { + f.t.Fatal(err) + } + legs[code] = l + } + return entries, legs +} + +// stock reads on hand/allocated of the fixture's product. +func (f *fixture) stock() string { + f.t.Helper() + var s string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT sum(quantity)::text || '/' || sum(allocated)::text FROM inventory WHERE product_id = $1`, f.productID).Scan(&s); err != nil { + f.t.Fatal(err) + } + return s +} + +// balance reads the customer's subledger balance in cents. +func (f *fixture) balance() int64 { + f.t.Helper() + var n int64 + if err := f.db.Pool.QueryRow(context.Background(), `SELECT ROUND(balance_due * 100)::bigint FROM customers WHERE id = $1`, f.customerID).Scan(&n); err != nil { + f.t.Fatal(err) + } + return n +} + +// eventTypes lists an entity's event types in order. +func eventTypes(t *testing.T, db *database.DB, entityType, entityID string) []string { + t.Helper() + rows, err := db.Pool.Query(context.Background(), + `SELECT type FROM events_outbox WHERE entity_type = $1 AND entity_id = $2 ORDER BY position`, entityType, entityID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var ty string + if err := rows.Scan(&ty); err != nil { + t.Fatal(err) + } + out = append(out, ty) + } + return out +} + +func countOf(t *testing.T, db *database.DB, sql string, args ...any) int64 { + t.Helper() + var n int64 + if err := db.Pool.QueryRow(context.Background(), sql, args...).Scan(&n); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return n +} + +// failOn is an event recorder that fails for one event type. +type failOn struct { + inner invoice.EventRecorder + typ string +} + +func (f failOn) Write(ctx context.Context, ev outbox.Event) error { + if ev.Type == f.typ { + return fmt.Errorf("outbox insert failed") + } + return f.inner.Write(ctx, ev) +} + +// counterNext reads the gapless counter's next value. +func counterNext(t *testing.T, db *database.DB, series string) int64 { + t.Helper() + return countOf(t, db, `SELECT next_value FROM document_counters WHERE series = $1`, series) +} diff --git a/core/internal/invoice/wire_test.go b/core/internal/invoice/wire_test.go new file mode 100644 index 00000000..aaa783fd --- /dev/null +++ b/core/internal/invoice/wire_test.go @@ -0,0 +1,584 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice_test + +// The invoice module on the wire contract (ADR 0001) and ADR 0005 sections 4 +// and 6: the document and its lines, the list, the filters, the overdue flag +// that replaced the OVERDUE status, the void with its ledger, stock and order +// effects, and the gapless number. + +import ( + "context" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +var numberPattern = regexp.MustCompile(`^IN-\d{6,}$`) + +// RULE (IN-1.1, IN-1.2, IN-1.3, IN-1.8, IN-3.8, IN-5): an invoice read carries +// its number, a lowercase status, money in _cents, the scaled unit price, the +// tax with its rate and source, what is still owed, the terms dates, every +// optional field present as null, the revision with its ETag, and every line in +// the shared shape with the cost that left. +func TestInvoiceWireShape(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, orderID := f.invoice("10") // 10 PCS at 5.50, tax 8.875% + + r := f.do("GET", "/api/v1/invoices/"+invID, nil) + if r.status != http.StatusOK { + t.Fatalf("get = %d: %s", r.status, r.raw) + } + b := r.body + if !numberPattern.MatchString(str(t, b, "number")) { + t.Errorf("number = %q, want IN-nnnnnn", b["number"]) + } + for key, want := range map[string]string{"status": "unpaid", "currency": "USD", "origin": "order", "delivery_type": "pickup", + "picked_up_by": "Counter customer", "tax_source": "branch_rate", "tax_rate_percent": "8.875", "order_id": orderID} { + if got := str(t, b, key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + for key, want := range map[string]int64{"subtotal_cents": 5500, "tax_cents": 488, "total_cents": 5988, "open_cents": 5988} { + if got := num(t, b, key); got != want { + t.Errorf("%s = %d, want %d", key, got, want) + } + } + if b["is_overdue"] != false || b["tax_exempt"] != false { + t.Errorf("is_overdue %v tax_exempt %v, want false false", b["is_overdue"], b["tax_exempt"]) + } + if etag := r.header.Get("ETag"); etag != fmt.Sprintf(`"%d"`, rev(t, r)) { + t.Errorf("ETag = %q, want the revision %d in quotes", etag, rev(t, r)) + } + // Every optional field is present, null while unset (ADR 0001 section 12). + for _, key := range []string{"job_id", "ship_to_id", "ship_to", "delivery_id", "discount_due_date", "discount_percent", "paid_at", + "voided_at", "voided_by", "void_reason"} { + if v, ok := b[key]; !ok || v != nil { + t.Errorf("%s = %v (present %v), want present as null", key, v, ok) + } + } + for _, key := range []string{"id", "branch_id", "customer_id", "customer_name", "payment_terms_id", "gl_entry_id", "created_at", "updated_at"} { + if _, ok := b[key]; !ok || b[key] == nil { + t.Errorf("%s is missing or null", key) + } + } + if !regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`).MatchString(str(t, b, "invoice_date")) || !regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`).MatchString(str(t, b, "due_date")) { + t.Errorf("invoice_date %v due_date %v, want YYYY-MM-DD", b["invoice_date"], b["due_date"]) + } + for _, key := range []string{"subtotal", "tax_rate", "tax_amount", "total_amount", "payment_terms", "data"} { + if _, ok := b[key]; ok { + t.Errorf("legacy field %q is still on the wire", key) + } + } + + lines := b["lines"].([]any) + if len(lines) != 1 { + t.Fatalf("%d lines, want 1: %s", len(lines), r.raw) + } + l := lines[0].(map[string]any) + for key, want := range map[string]string{"line_type": "product", "quantity": "10", "uom": "PCS", "price_uom": "PCS", "uom_qty": "1", "price_uom_qty": "1", + "price_source": "price_list", "sku": f.sku, "description": "2x4x8 SPF"} { + if got := str(t, l, key); got != want { + t.Errorf("line %s = %q, want %q", key, got, want) + } + } + for key, want := range map[string]int64{"unit_price_ten_thousandths": 55000, "line_total_cents": 5500, "unit_cost_ten_thousandths": 32500, "cost_cents": 3250, "position": 0} { + if got := num(t, l, key); got != want { + t.Errorf("line %s = %d, want %d", key, got, want) + } + } + if l["taxable"] != true || l["order_line_id"] == nil { + t.Errorf("line taxable %v order_line_id %v", l["taxable"], l["order_line_id"]) + } + for _, key := range []string{"parent_line_id", "charge_code", "discount_percent", "discount_cents", "discount_reason", "override_reason"} { + if v, ok := l[key]; !ok || v != nil { + t.Errorf("line %s = %v (present %v), want present as null", key, v, ok) + } + } + + // The order lists the invoice (IN-5: the record route is stable) and the + // events feed carries invoice.created. + g := f.do("GET", "/api/v1/orders/"+orderID, nil) + if ids := g.body["invoice_ids"].([]any); len(ids) != 1 || ids[0] != invID { + t.Errorf("order invoice_ids = %v, want [%s]", ids, invID) + } + if ev := eventTypes(t, db, "invoice", invID); fmt.Sprint(ev) != "[invoice.created]" { + t.Errorf("invoice events = %v, want [invoice.created]", ev) + } + if bad := f.do("GET", "/api/v1/invoices/"+invID+"?x=1", nil); bad.status != 400 { + t.Errorf("an undeclared query parameter on the get = %d, want 400", bad.status) + } +} + +// RULE (IN-3.7): not found and a malformed id are the wire's envelope. +func TestInvoiceGetRefusals(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + r := f.do("GET", "/api/v1/invoices/"+uuid.NewString(), nil) + if r.status != 404 { + t.Fatalf("unknown invoice = %d, want 404", r.status) + } + if code, _, _ := errorOf(t, r); code != "not_found" { + t.Errorf("code = %q, want not_found", code) + } + r = f.do("GET", "/api/v1/invoices/not-a-uuid", nil) + if code, _, fields := errorOf(t, r); r.status != 400 || code != "bad_request" || len(fields) != 1 || fields[0] != "id" { + t.Errorf("malformed id = %d %q %v, want 400 bad_request naming id", r.status, code, fields) + } +} + +// RULE (IN-1.4, IN-3.2): the list is the cursor envelope, newest first, every +// row once, with filters that filter and unknown ones refused; an empty page is +// [] in the bytes. +func TestInvoiceList(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + var ids []string + for i := 0; i < 3; i++ { + id, _ := f.invoice("1") + ids = append(ids, id) + } + // newest first, by (created_at, id) + var seen []string + cursor := "" + for page := 0; page < 5; page++ { + path := "/api/v1/invoices?limit=2&customer_id=" + f.customerID.String() + if cursor != "" { + path += "&cursor=" + cursor + } + r := f.do("GET", path, nil) + if r.status != 200 { + t.Fatalf("list = %d: %s", r.status, r.raw) + } + for _, it := range r.body["items"].([]any) { + seen = append(seen, it.(map[string]any)["id"].(string)) + } + if r.body["next_cursor"] == nil { + break + } + cursor = r.body["next_cursor"].(string) + } + want := []string{ids[2], ids[1], ids[0]} + if fmt.Sprint(seen) != fmt.Sprint(want) { + t.Errorf("the cursor walk saw %v, want %v (newest first, each once)", seen, want) + } + + if r := f.do("GET", "/api/v1/invoices?customer_id="+f.customerID.String()+"&include=total", nil); r.status != 200 || num(t, r.body, "total") != 3 { + t.Errorf("include=total = %d %v, want 3", r.status, r.body["total"]) + } + if r := f.do("GET", "/api/v1/invoices?customer_id="+f.customerID.String()+"&order_id="+uuid.NewString(), nil); r.status != 200 || len(r.body["items"].([]any)) != 0 { + t.Errorf("an order_id filter that matches nothing: %d %s", r.status, r.raw) + } + empty := f.do("GET", "/api/v1/invoices?customer_id="+uuid.NewString(), nil) + if !strings.Contains(string(empty.raw), `"items":[]`) { + t.Errorf("an empty page = %s, want items [] in the bytes", empty.raw) + } + for _, bad := range []string{"offset=1", "status=UNPAID", "status=overdue", "overdue=maybe", "customer_id=zzz", "limit=0", "cursor=junk", "nope=1"} { + if r := f.do("GET", "/api/v1/invoices?"+bad, nil); r.status != 400 { + t.Errorf("?%s = %d, want 400", bad, r.status) + } + } + if r := f.do("GET", "/api/v1/invoices?status=unpaid,partial&customer_id="+f.customerID.String(), nil); r.status != 200 || len(r.body["items"].([]any)) != 3 { + t.Errorf("status=unpaid,partial = %d %s", r.status, r.raw) + } + if r := f.do("GET", "/api/v1/invoices?status=void&customer_id="+f.customerID.String(), nil); r.status != 200 || len(r.body["items"].([]any)) != 0 { + t.Errorf("status=void = %d %s", r.status, r.raw) + } +} + +// RULE (ADR 0005 6.1): OVERDUE is gone from the wire. An open invoice past its +// due date is is_overdue, and the overdue filter lists it; a paid one is not +// overdue however old; an invoice not yet due is not. +func TestOverdueIsComputedNotStored(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + late, _ := f.invoice("1") + current, _ := f.invoice("1") + paid, _ := f.invoice("1") + mustExec(t, db, `UPDATE invoices SET due_date = CURRENT_DATE - 40 WHERE id = ANY($1)`, []uuid.UUID{uuid.MustParse(late), uuid.MustParse(paid)}) + mustExec(t, db, `UPDATE invoices SET status = 'PAID', paid_at = NOW() WHERE id = $1`, paid) + + list := func(q string) []string { + r := f.do("GET", "/api/v1/invoices?customer_id="+f.customerID.String()+q, nil) + if r.status != 200 { + t.Fatalf("list ?%s = %d: %s", q, r.status, r.raw) + } + var out []string + for _, it := range r.body["items"].([]any) { + out = append(out, it.(map[string]any)["id"].(string)) + } + sort.Strings(out) + return out + } + sorted := func(ids ...string) []string { sort.Strings(ids); return ids } + if got := list("&overdue=true"); fmt.Sprint(got) != fmt.Sprint(sorted(late)) { + t.Errorf("overdue=true = %v, want only the open late invoice %s", got, late) + } + if got := list("&overdue=false"); fmt.Sprint(got) != fmt.Sprint(sorted(current, paid)) { + t.Errorf("overdue=false = %v, want the current and the paid one", got) + } + if r := f.getInvoice(late); r.body["is_overdue"] != true || str(t, r.body, "status") != "unpaid" { + t.Errorf("late invoice is_overdue %v status %v, want true unpaid", r.body["is_overdue"], r.body["status"]) + } + if r := f.getInvoice(paid); r.body["is_overdue"] != false || num(t, r.body, "open_cents") != 0 { + t.Errorf("paid invoice is_overdue %v open %v, want false 0", r.body["is_overdue"], r.body["open_cents"]) + } +} + +// RULE (ADR 0005 7.2): the invoice snapshots its customer's terms: the due date +// (a day of the month after the invoice month, clamped), and the early pay +// discount date and percent. +func TestInvoiceSnapshotsTheCustomersTerms(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + termsID := uuid.New() + mustExec(t, db, `INSERT INTO payment_terms (id, code, name, kind, day_of_month, discount_percent, discount_days) VALUES ($1, $2, 'The 31st', 'DAY_OF_MONTH', 31, 2, 10)`, + termsID, "T31-"+termsID.String()[:6]) + t.Cleanup(func() { + mustExec(t, db, `UPDATE customers SET payment_terms_id = payment_terms_default_id() WHERE id = $1`, f.customerID) + mustExec(t, db, `DELETE FROM invoices WHERE customer_id = $1`, f.customerID) + mustExec(t, db, `DELETE FROM payment_terms WHERE id = $1`, termsID) + }) + mustExec(t, db, `UPDATE customers SET payment_terms_id = $2 WHERE id = $1`, f.customerID, termsID) + invID, _ := f.invoice("1") + r := f.getInvoice(invID) + date := str(t, r.body, "invoice_date") + d, err := time.Parse("2006-01-02", date) + if err != nil { + t.Fatal(err) + } + next := time.Date(d.Year(), d.Month()+1, 1, 0, 0, 0, 0, time.UTC) + last := time.Date(next.Year(), next.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() + day := 31 + if last < day { + day = last + } + if want := time.Date(next.Year(), next.Month(), day, 0, 0, 0, 0, time.UTC).Format("2006-01-02"); str(t, r.body, "due_date") != want { + t.Errorf("due_date = %v, want %s (the 31st clamped to the month)", r.body["due_date"], want) + } + if want := d.AddDate(0, 0, 10).Format("2006-01-02"); str(t, r.body, "discount_due_date") != want || str(t, r.body, "discount_percent") != "2" { + t.Errorf("discount %v until %v, want 2 until %s", r.body["discount_percent"], r.body["discount_due_date"], want) + } + if str(t, r.body, "payment_terms_id") != termsID.String() { + t.Errorf("payment_terms_id = %v, want %s", r.body["payment_terms_id"], termsID) + } +} + +// RULE (ADR 0005 6.2, IN-3.8): the void reverses the invoice's whole entry, +// returns the billed stock to on hand, brings the order's lines back, re-derives +// its status and credits the subledger back; the invoice keeps its number and +// reads void. +func TestInvoiceVoidReversesLedgerStockAndOrder(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, orderID := f.invoice("10") + number := str(t, f.getInvoice(invID).body, "number") + if got := f.stock(); got != "90.0000/0.0000" { + t.Fatalf("stock after billing = %s, want 90/0", got) + } + if f.balance() != 5988 { + t.Fatalf("balance after billing = %d, want 5988", f.balance()) + } + + before := f.getInvoice(invID) + // the refusals first + if r := f.do("POST", "/api/v1/invoices/"+invID+"/transitions", map[string]any{"to": "void", "reason": "x"}); r.status != 428 { + t.Errorf("void without a precondition = %d, want 428", r.status) + } + if r := f.voidInvoice(invID, 99, "stale"); r.status != 409 { + t.Errorf("void on a stale revision = %d, want 409", r.status) + } else if code, _, _ := errorOf(t, r); code != "stale_revision" { + t.Errorf("code = %q, want stale_revision", code) + } + if r := f.do("POST", "/api/v1/invoices/"+invID+"/transitions", map[string]any{"to": "void", "revision": rev(t, before)}); r.status != 400 { + t.Errorf("void without a reason = %d, want 400", r.status) + } else if _, _, fields := errorOf(t, r); len(fields) != 1 || fields[0] != "reason" { + t.Errorf("fields = %v, want reason", fields) + } + for _, to := range []string{"paid", "partial", "unpaid", "written_off"} { + r := f.do("POST", "/api/v1/invoices/"+invID+"/transitions", map[string]any{"to": to, "revision": rev(t, before), "reason": "x"}) + if code, _, _ := errorOf(t, r); r.status != 409 || code != "invalid_state_transition" { + t.Errorf("transition to %s = %d %q, want 409 invalid_state_transition", to, r.status, code) + } + } + if r := f.do("POST", "/api/v1/invoices/"+invID+"/transitions", map[string]any{"to": "overdue", "revision": rev(t, before), "reason": "x"}); r.status != 400 { + t.Errorf("transition to overdue = %d, want 400 (not a status)", r.status) + } + if r := f.voidInvoice(invID, rev(t, before), "wrong role", "X-Test-Role", "sales"); r.status != 403 { + t.Errorf("a sales void = %d, want 403", r.status) + } + if got := f.getInvoice(invID); str(t, got.body, "status") != "unpaid" { + t.Fatalf("a refused void changed the invoice: %s", got.raw) + } + + r := f.voidInvoice(invID, rev(t, before), "billed in error", "X-Test-Role", "finance", "X-Test-Sub", "fin-user") + if r.status != 200 { + t.Fatalf("void = %d: %s", r.status, r.raw) + } + for key, want := range map[string]string{"status": "void", "number": number, "void_reason": "billed in error", "voided_by": "fin-user"} { + if got := str(t, r.body, key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + if r.body["voided_at"] == nil || num(t, r.body, "open_cents") != 0 || rev(t, r) != rev(t, before)+1 { + t.Errorf("voided_at %v open %v revision %d", r.body["voided_at"], r.body["open_cents"], rev(t, r)) + } + if r.header.Get("ETag") != fmt.Sprintf(`"%d"`, rev(t, r)) { + t.Errorf("ETag = %q", r.header.Get("ETag")) + } + if again := f.voidInvoice(invID, rev(t, r), "twice"); again.status != 409 { + t.Errorf("a second void = %d, want 409", again.status) + } + + // the ledger: the original entry stands, a dated reversal swaps its legs + entries, orig := f.entryLegs(invID) + revEntries, rev1 := f.reversalLegs(invID) + if entries != 1 || revEntries != 1 { + t.Fatalf("%d invoice entries and %d reversals, want 1 and 1", entries, revEntries) + } + for code, l := range orig { + if got := rev1[code]; got.debit != l.credit || got.credit != l.debit { + t.Errorf("reversal leg %s = %+v, want the swap of %+v", code, got, l) + } + } + // the stock: on hand back, and allocated again to the reopened order + if got := f.stock(); got != "100.0000/10.0000" { + t.Errorf("stock after the void = %s, want 100 on hand and 10 allocated to the reopened order", got) + } + // the order: confirmed again, nothing fulfilled + o := f.do("GET", "/api/v1/orders/"+orderID, nil) + if str(t, o.body, "status") != "confirmed" || len(o.body["invoice_ids"].([]any)) != 0 { + t.Errorf("order after the void: status %v invoice_ids %v, want confirmed and none", o.body["status"], o.body["invoice_ids"]) + } + ol := o.body["lines"].([]any)[0].(map[string]any) + if str(t, ol, "quantity_fulfilled") != "0" || str(t, ol, "quantity_allocated") != "10" || str(t, ol, "quantity_backordered") != "0" { + t.Errorf("order line quantities %v/%v/%v, want fulfilled 0 allocated 10 backordered 0", ol["quantity_fulfilled"], ol["quantity_allocated"], ol["quantity_backordered"]) + } + // the subledger: the invoice's debit is credited back + if f.balance() != 0 { + t.Errorf("balance after the void = %d, want 0", f.balance()) + } + if ev := eventTypes(t, db, "invoice", invID); fmt.Sprint(ev) != "[invoice.created invoice.voided]" { + t.Errorf("invoice events = %v", ev) + } + // a void invoice leaves the open lists and counts nowhere + if l := f.do("GET", "/api/v1/invoices?status=void&customer_id="+f.customerID.String(), nil); len(l.body["items"].([]any)) != 1 { + t.Errorf("status=void list = %s", l.raw) + } + + // the order bills again, and the new invoice takes the next number + r2, newRev := f.fulfil(orderID, rev(t, o), nil) + _ = newRev + if n2 := str(t, f.getInvoice(r2).body, "number"); n2 == number { + t.Errorf("the re-billed invoice reused the number %s", n2) + } +} + +// RULE (ADR 0005 6.2): a void is refused while a payment is recorded against +// the invoice or an applied credit memo names it (has_applications), and while a +// credit memo that is not void names it (has_credit_memos). +func TestInvoiceVoidRefusals(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + + // a payment recorded against the invoice + paid, _ := f.invoice("1") + mustExec(t, db, `INSERT INTO payments (invoice_id, amount, method, reference) VALUES ($1, 1.00, 'CASH', 'REF')`, paid) + r := f.voidInvoice(paid, rev(t, f.getInvoice(paid)), "has a payment") + if code, blockers, _ := errorOf(t, r); r.status != 409 || code != "conflict" || fmt.Sprint(blockers) != "[has_applications]" { + t.Errorf("void with a payment = %d %q %v, want 409 conflict has_applications", r.status, code, blockers) + } + + // an applied credit memo naming it + applied, _ := f.invoice("1") + mustExec(t, db, `INSERT INTO credit_memos (invoice_id, customer_id, branch_id, currency, reason_code, reason, amount, status, number, memo_date, subtotal, tax_amount, total_amount, tax_rate) + VALUES ($1, $2, `+defaultBranch+`, 'USD', 'OTHER', 'applied', 5, 'APPLIED', credit_memo_next_number(), CURRENT_DATE, -5, 0, -5, 0)`, applied, f.customerID) + r = f.voidInvoice(applied, rev(t, f.getInvoice(applied)), "has an applied memo") + if _, blockers, _ := errorOf(t, r); r.status != 409 || fmt.Sprint(blockers) != "[has_applications]" { + t.Errorf("void with an applied credit memo = %d %v, want has_applications", r.status, blockers) + } + + // a credit memo that is not void names it + named, _ := f.invoice("2") + line := f.firstLineID(named) + cm := f.createCredit(f.creditBody(named, returnLine(line, "-1", false))) + r = f.voidInvoice(named, rev(t, f.getInvoice(named)), "has a draft memo") + if _, blockers, _ := errorOf(t, r); r.status != 409 || fmt.Sprint(blockers) != "[has_credit_memos]" { + t.Errorf("void with a credit memo = %d %v, want has_credit_memos", r.status, blockers) + } + // ... until the credit memo is void + v := f.do("POST", "/api/v1/credit-memos/"+str(t, cm.body, "id")+"/transitions", map[string]any{"to": "void", "revision": rev(t, cm), "reason": "changed my mind"}) + if v.status != 200 { + t.Fatalf("void the credit memo = %d: %s", v.status, v.raw) + } + if r = f.voidInvoice(named, rev(t, f.getInvoice(named)), "now free"); r.status != 200 { + t.Errorf("void after the credit memo is void = %d: %s", r.status, r.raw) + } + // a payment against a void invoice is refused by the payment module + if n := countOf(t, db, `SELECT count(*) FROM payments WHERE invoice_id = $1`, named); n != 0 { + t.Errorf("%d payments on the void invoice", n) + } +} + +// RULE (ADR 0005 4.1): invoice numbers are gapless. A fulfilment that rolls back +// (here: its last event write fails) gives its number back, and the next +// invoice takes it; the counter is the numbering of every writer, the seed's raw +// insert included. +func TestInvoiceNumbersHaveNoGapAfterARollback(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + failing := true + f := newFixture(t, db, func(f *fixture) { + f.orders.WithOutbox(switchable{inner: outbox.NewWriter(db, ""), typ: "order.fulfilled", fail: &failing}) + }) + orderID, r := f.confirmedOrder(f.pickupLine("2")) + + // A passing run of three attempts: another test of another package may mint + // between the two reads, a real gap would repeat. + var last error + for attempt := 0; attempt < 3; attempt++ { + failing = true + before := counterNext(t, db, "invoice") + bad := f.do("POST", "/api/v1/orders/"+orderID+"/fulfillments", map[string]any{"revision": r, "picked_up_by": "X"}) + if bad.status < 500 { + t.Fatalf("the fulfilment with a failing event write = %d, want a 5xx", bad.status) + } + if after := counterNext(t, db, "invoice"); after != before { + last = fmt.Errorf("the counter moved from %d to %d across a rolled back create", before, after) + continue + } + failing = false + inv, _ := f.fulfil(orderID, r, nil) + if got := str(t, f.getInvoice(inv).body, "number"); got != fmt.Sprintf("IN-%06d", before) { + last = fmt.Errorf("the next invoice is %s, want IN-%06d: the rolled back number was lost", got, before) + // reset for the next attempt: void it so the order can bill again is overkill; the order is billed now + return + } + last = nil + break + } + if last != nil { + t.Fatal(last) + } + if n := countOf(t, db, `SELECT count(*) FROM invoices WHERE order_id = $1`, orderID); n != 1 { + t.Errorf("%d invoices for the order, want 1 (the rolled back one left no row)", n) + } +} + +// switchable is an event recorder that fails for one event type while *fail is set. +type switchable struct { + inner outboxWriter + typ string + fail *bool +} + +type outboxWriter interface { + Write(ctx context.Context, ev outbox.Event) error +} + +func (s switchable) Write(ctx context.Context, ev outbox.Event) error { + if *s.fail && ev.Type == s.typ { + return fmt.Errorf("outbox insert failed") + } + return s.inner.Write(ctx, ev) +} + +// RULE (ADR 0005 4.1, 14.2): three fulfilments racing at pool size 4 get three +// consecutive numbers and no deadlock. +func TestConcurrentFulfilmentsGetConsecutiveNumbers(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDBMaxConns(t, 4) + f := newFixture(t, db) + const contenders = 3 + type pending struct { + order string + rev int64 + } + var orders []pending + for i := 0; i < contenders; i++ { + id, r := f.confirmedOrder(f.pickupLine("1")) + orders = append(orders, pending{id, r}) + } + numbers := make(chan string, contenders) + var wg sync.WaitGroup + for _, p := range orders { + wg.Add(1) + go func() { + defer wg.Done() + r := f.do("POST", "/api/v1/orders/"+p.order+"/fulfillments", map[string]any{"revision": p.rev, "picked_up_by": "X"}) + if r.status != http.StatusCreated { + t.Errorf("fulfil = %d: %s", r.status, r.raw) + return + } + numbers <- str(t, f.getInvoice(strings.TrimPrefix(r.header.Get("Location"), "/api/v1/invoices/")).body, "number") + }() + } + wg.Wait() + close(numbers) + var got []int + for n := range numbers { + var v int + if _, err := fmt.Sscanf(n, "IN-%d", &v); err != nil { + t.Fatalf("number %q: %v", n, err) + } + got = append(got, v) + } + sort.Ints(got) + if len(got) != contenders { + t.Fatalf("%d invoices, want %d", len(got), contenders) + } + // consecutive among themselves (a mint by another package's test between + // two of ours would break this; a real gap fails every time) + for i := 1; i < len(got); i++ { + if got[i] != got[i-1]+1 { + t.Errorf("numbers %v are not consecutive", got) + break + } + } +} + +// RULE (ADR 0005 4.1): a raw SQL writer (the seed) that names no number gets the +// next one from the same counter, and the Go path continues after it. +func TestRawInsertNumbersThroughTheCounter(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + before := counterNext(t, db, "invoice") + var raw string + if err := db.Pool.QueryRow(context.Background(), ` + INSERT INTO invoices (customer_id, branch_id, status, total_amount, subtotal, tax_amount, due_date) + VALUES ($1, `+defaultBranch+`, 'UNPAID', 10, 10, 0, CURRENT_DATE + 30) RETURNING number`, f.customerID).Scan(&raw); err != nil { + t.Fatal(err) + } + if raw != fmt.Sprintf("IN-%06d", before) && counterNext(t, db, "invoice") <= before { + t.Errorf("raw insert numbered %s with the counter at %d", raw, before) + } + if !numberPattern.MatchString(raw) { + t.Fatalf("raw number = %q", raw) + } + inv, _ := f.invoice("1") + var a, b int + fmt.Sscanf(raw, "IN-%d", &a) + fmt.Sscanf(str(t, f.getInvoice(inv).body, "number"), "IN-%d", &b) + if b <= a { + t.Errorf("the Go path numbered %d after the raw insert's %d", b, a) + } +} diff --git a/web/apps/desk/src/components/invoices/SalesDialog.ts b/web/apps/desk/src/components/invoices/SalesDialog.ts new file mode 100644 index 00000000..5ef84fdb --- /dev/null +++ b/web/apps/desk/src/components/invoices/SalesDialog.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { LitElement, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +/** + * A confirm dialog for a lifecycle act on a sales document (void an invoice, + * post or void a credit memo). With `require-reason` it asks for a reason and + * disables the confirm until one is typed; the page shows the server's refusal + * through `error` and keeps the dialog open. Events: `confirm` with + * detail.reason, and `close`. + */ +@customElement('gable-sales-dialog') +export class GableSalesDialog extends LitElement { + createRenderRoot() { return this; } + + @property({ type: Boolean, attribute: 'is-open' }) isOpen = false; + @property({ type: String }) heading = ''; + @property({ type: String }) body = ''; + @property({ type: String, attribute: 'confirm-label' }) confirmLabel = 'Confirm'; + @property({ type: Boolean, attribute: 'require-reason' }) requireReason = false; + @property({ type: Boolean }) danger = false; + @property({ type: Boolean }) busy = false; + @property({ type: String }) error = ''; + + @state() private reason = ''; + + updated(changed: Map) { + if (changed.has('isOpen') && !this.isOpen) this.reason = ''; + } + + private close() { + this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true })); + } + + private submit(e: Event) { + e.preventDefault(); + if (this.requireReason && !this.reason.trim()) return; + this.dispatchEvent(new CustomEvent('confirm', { + detail: { reason: this.reason.trim() }, + bubbles: true, + composed: true, + })); + } + + render() { + if (!this.isOpen) return nothing; + const disabled = this.busy || (this.requireReason && !this.reason.trim()); + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gable-sales-dialog': GableSalesDialog; + } +} diff --git a/web/apps/desk/src/components/invoices/chips.ts b/web/apps/desk/src/components/invoices/chips.ts new file mode 100644 index 00000000..9f90197c --- /dev/null +++ b/web/apps/desk/src/components/invoices/chips.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { html } from 'lit'; + +/** The status chip colours, the same set the order pages use. */ +export type ChipColor = 'default' | 'info' | 'success' | 'warning' | 'error'; + +export function chipClass(color: ChipColor): string { + const base = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border'; + switch (color) { + case 'info': return `${base} bg-blue-500/20 text-blue-400 border-blue-500/50`; + case 'success': return `${base} bg-gable-green/20 text-gable-green border-gable-green/50`; + case 'warning': return `${base} bg-amber-500/20 text-amber-400 border-amber-500/50`; + case 'error': return `${base} bg-red-500/20 text-red-400 border-red-500/50`; + default: return `${base} bg-white/10 text-white border-transparent`; + } +} + +/** The Overdue badge an open invoice past its due date carries beside its status. */ +export function overdueBadge(isOverdue: boolean) { + return isOverdue + ? html`Overdue` + : ''; +} diff --git a/web/apps/desk/src/components/invoices/lines-table.ts b/web/apps/desk/src/components/invoices/lines-table.ts new file mode 100644 index 00000000..26720681 --- /dev/null +++ b/web/apps/desk/src/components/invoices/lines-table.ts @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { html, nothing, type TemplateResult } from 'lit'; +import { formatCents, formatPrice4 } from '../../lib/utils.ts'; +import { creditTextClass, formatQuantity } from '../../lib/credit-display.ts'; + +/** The fields of the shared sales line shape this table reads; an invoice line and a credit memo line both fit. */ +export interface SalesLineView { + id: string; + position: number; + line_type: 'product' | 'kit' | 'component' | 'charge' | 'text'; + parent_line_id: string | null; + charge_code: string | null; + sku: string | null; + description: string; + quantity: string | null; + uom: string | null; + price_uom: string | null; + unit_price_ten_thousandths: number | null; + discount_percent: string | null; + discount_cents: number | null; + discount_reason: string | null; + line_total_cents: number | null; + taxable: boolean; + unit_cost_ten_thousandths: number | null; + cost_cents: number; + restock?: boolean; +} + +export interface LinesTableOptions { + ariaLabel: string; + /** A credit memo: quantities and extensions are negative and shown as credits. */ + credit?: boolean; + /** Show the restock flag column (a credit memo's returned goods). */ + showRestock?: boolean; +} + +/** A line's margin in cents, or null when it carries no cost (a note, a charge, a role without cost). */ +export function lineMarginCents(line: Pick): number | null { + if (line.line_total_cents === null || line.unit_cost_ten_thousandths === null) return null; + return line.line_total_cents - line.cost_cents; +} + +function discountText(line: SalesLineView): string { + if (line.discount_percent !== null) return `${line.discount_percent}%`; + if (line.discount_cents !== null) return formatCents(line.discount_cents); + return ''; +} + +export function renderSalesLines(lines: SalesLineView[], opts: LinesTableOptions): TemplateResult { + const ordered = [...lines].sort((a, b) => a.position - b.position); + const showCost = ordered.some(l => l.unit_cost_ten_thousandths !== null); + const credit = opts.credit === true; + const cols = 6 + (opts.showRestock ? 1 : 0) + (showCost ? 2 : 0); + return html` +
+ + + + + + + + + + ${opts.showRestock ? html`` : nothing} + ${showCost ? html` + + + ` : nothing} + + + + ${ordered.map(line => { + if (line.line_type === 'text') { + return html` + + + + `; + } + const isComponent = line.line_type === 'component' || line.parent_line_id !== null; + const margin = lineMarginCents(line); + return html` + + + + + + + + ${opts.showRestock ? html`` : nothing} + ${showCost ? html` + + + ` : nothing} + + `; + })} + +
ItemQtyUnit priceDiscountTotalTaxRestockCostMargin
${line.description}
+
+ ${line.sku || line.charge_code || line.line_type} + ${line.line_type} +
+
${line.description}
+
+ ${formatQuantity(line.quantity)} + ${line.uom ? html`${line.uom}` : nothing} + + ${line.unit_price_ten_thousandths !== null ? formatPrice4(line.unit_price_ten_thousandths) : '—'} + ${line.price_uom ? html`/ ${line.price_uom}` : nothing} + ${discountText(line)} + ${line.line_total_cents !== null ? formatCents(line.line_total_cents) : '—'} + ${line.taxable ? 'T' : '-'}${line.restock ? 'Yes' : '-'}${line.unit_cost_ten_thousandths !== null ? formatCents(line.cost_cents) : '—'}${margin !== null ? formatCents(margin) : '—'}
+
+ `; +} diff --git a/web/apps/desk/src/lib/credit-display.ts b/web/apps/desk/src/lib/credit-display.ts new file mode 100644 index 00000000..a3ec4472 --- /dev/null +++ b/web/apps/desk/src/lib/credit-display.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +// Display and input helpers for credit memos (ADR 0005 section 6.3). A credit +// memo stores every total, quantity and extension NEGATIVE; storage is never +// flipped. The desk shows those values as credits (the minus sign in the safety +// red token) and lets a user type a positive "how many come back", which is +// negated into the wire's decimal string with string arithmetic, never a float. + +import { formatCents } from './utils.ts'; +import { parseScaled } from './money.ts'; + +const SCALE = 4; + +/** A signed decimal string ("-2", "1.5") to an integer scaled by 10^4; null when it is not one. */ +export function quantityToScaled(text: string | null | undefined): number | null { + if (text === null || text === undefined) return null; + const trimmed = text.trim(); + const negative = trimmed.startsWith('-'); + const scaled = parseScaled(negative ? trimmed.slice(1) : trimmed, SCALE); + if (scaled === null) return null; + return negative ? -scaled : scaled; +} + +/** A scaled integer back to the wire's decimal string: 20000 -> "2", -15000 -> "-1.5". */ +export function scaledToQuantity(scaled: number): string { + const negative = scaled < 0; + const abs = Math.abs(Math.trunc(scaled)); + const whole = Math.floor(abs / 10 ** SCALE); + const frac = String(abs % 10 ** SCALE).padStart(SCALE, '0').replace(/0+$/, ''); + const body = frac === '' ? String(whole) : `${whole}.${frac}`; + return negative && abs !== 0 ? `-${body}` : body; +} + +/** + * What a user types for "quantity returned" ("2", "1.5") to the wire's negative + * decimal string ("-2", "-1.5"). Null unless the text is a positive decimal with + * at most four fraction digits. + */ +export function creditQuantityFromInput(text: string): string | null { + const scaled = parseScaled(text, SCALE); + if (scaled === null || scaled <= 0) return null; + return scaledToQuantity(-scaled); +} + +/** A stored (negative) credit quantity to the positive text a user would type: "-2" -> "2". */ +export function creditQuantityToInput(quantity: string | null | undefined): string { + const scaled = quantityToScaled(quantity); + if (scaled === null) return ''; + return scaledToQuantity(Math.abs(scaled)); +} + +/** The decimal strings added: "24" and "-2" give "22". Null when either is not a decimal. */ +export function addQuantities(a: string, b: string): string | null { + const x = quantityToScaled(a); + const y = quantityToScaled(b); + if (x === null || y === null) return null; + return scaledToQuantity(x + y); +} + +/** + * How much of an invoice line can still be credited: what it billed less what + * the credit memos the page knows of already credited (their quantities are + * negative, so they are added). Never below zero. Null when the line carries no + * quantity (a note line). + */ +export function remainingCreditable(billed: string | null, creditedNegatives: string[]): string | null { + const b = quantityToScaled(billed); + if (b === null) return null; + let left = b; + for (const c of creditedNegatives) { + const n = quantityToScaled(c); + if (n !== null) left += n; + } + return scaledToQuantity(Math.max(left, 0)); +} + +/** A credit in dollars with its minus sign: -4400 -> "-$44.00". Positive and zero amounts render as they are. */ +export function formatCreditCents(cents: number | null | undefined): string { + return formatCents(cents); +} + +/** The text colour class for an amount: the safety red token for a credit, none otherwise. */ +export function creditTextClass(cents: number | null | undefined): string { + return typeof cents === 'number' && cents < 0 ? 'text-red-400' : ''; +} + +/** A line's quantity for display: the sign kept, a note line's missing quantity as a dash. */ +export function formatQuantity(quantity: string | null | undefined): string { + return quantity === null || quantity === undefined || quantity === '' ? '—' : quantity; +} diff --git a/web/apps/desk/src/lib/utils.ts b/web/apps/desk/src/lib/utils.ts index d3fda369..88f7171a 100644 --- a/web/apps/desk/src/lib/utils.ts +++ b/web/apps/desk/src/lib/utils.ts @@ -46,3 +46,13 @@ export function formatPrice4(tenThousandths: number | null | undefined): string const wholeText = new Intl.NumberFormat('en-US').format(whole); return `${negative && abs !== 0 ? '-' : ''}$${wholeText}.${frac}`; } + +// A business date (YYYY-MM-DD, no time zone) rendered in the user's locale. Built +// from its parts so a west-of-UTC machine never shows the day before; anything +// that is not a plain date is returned as it came. +export function formatDay(day: string | null | undefined): string { + if (!day) return '—'; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!m) return day; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).toLocaleDateString(); +} diff --git a/web/apps/desk/src/pages/invoices/InvoiceDetail.ts b/web/apps/desk/src/pages/invoices/InvoiceDetail.ts index e21c5d8d..1050c8c4 100644 --- a/web/apps/desk/src/pages/invoices/InvoiceDetail.ts +++ b/web/apps/desk/src/pages/invoices/InvoiceDetail.ts @@ -6,18 +6,43 @@ import { customElement, property, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { ToastService } from '../../lib/toast-service.ts'; import { InvoiceService } from '../../services/InvoiceService.ts'; +import { CreditMemoService } from '../../services/CreditMemoService.ts'; +import { OrderService } from '../../services/OrderService.ts'; import { paymentService } from '../../services/paymentService.ts'; -import { ReportingService } from '../../services/ReportingService.ts'; -import type { Invoice } from '../../types/invoice.ts'; +import { ApiError, apiErrorMessage } from '../../services/apiError.ts'; +import { type Invoice, formatInvoiceStatus, getInvoiceStatusColor } from '../../types/invoice.ts'; +import { + type CreditMemoSummary, + creditMemoLabel, + formatCreditMemoStatus, + formatReasonCode, + getCreditMemoStatusColor, +} from '../../types/creditMemo.ts'; import type { Payment, CreatePaymentRequest } from '../../types/payment.ts'; -import { Download, CreditCard, Mail, RotateCcw } from 'lucide'; -import { formatCents } from '../../lib/utils.ts'; +import { Download, CreditCard, Mail, RotateCcw, Ban } from 'lucide'; +import { formatCents, formatDay } from '../../lib/utils.ts'; +import { creditTextClass } from '../../lib/credit-display.ts'; +import { chipClass, overdueBadge } from '../../components/invoices/chips.ts'; +import { renderSalesLines } from '../../components/invoices/lines-table.ts'; // Side-effect imports: register child custom elements import '../../components/invoices/PaymentModal.ts'; +import '../../components/invoices/SalesDialog.ts'; const API_URL = import.meta.env.VITE_API_URL || ''; +/** Plain words for the blockers a void can be refused with. */ +export function voidBlockerHint(err: unknown): string { + if (!(err instanceof ApiError)) return apiErrorMessage(err, 'Failed to void invoice'); + if (err.hasBlocker('has_applications')) { + return `${err.message}\nA payment is recorded against this invoice, or an applied credit memo names it. Reverse those first.`; + } + if (err.hasBlocker('has_credit_memos')) { + return `${err.message}\nA credit memo is written against this invoice. Void its credit memos first.`; + } + return err.displayMessage; +} + @customElement('gable-invoice-detail') export class GableInvoiceDetail extends LitElement { createRenderRoot() { return this; } @@ -25,34 +50,47 @@ export class GableInvoiceDetail extends LitElement { @property({ attribute: 'route-id' }) routeId = ''; @state() private invoice: Invoice | null = null; + @state() private orderNumber: string | null = null; @state() private payments: Payment[] = []; + @state() private creditMemos: CreditMemoSummary[] = []; @state() private loading = true; @state() private error = false; @state() private isPaymentModalOpen = false; - @state() private creditMemoReason = ''; - @state() private creditMemoAmount = ''; - @state() private showCreditMemo = false; + @state() private voidOpen = false; + @state() private voidBusy = false; + @state() private voidError = ''; connectedCallback() { super.connectedCallback(); - if (this.routeId) { - this.loadInvoice(this.routeId); - this.loadPayments(this.routeId); - } + if (this.routeId) this.loadAll(this.routeId); } updated(changed: Map) { if (changed.has('routeId') && changed.get('routeId') !== undefined && this.routeId) { this.loading = true; - this.loadInvoice(this.routeId); - this.loadPayments(this.routeId); + this.loadAll(this.routeId); } } + private loadAll(id: string) { + this.loadInvoice(id); + this.loadPayments(id); + this.loadCreditMemos(id); + } + private async loadInvoice(id: string) { try { const data = await InvoiceService.getInvoice(id); this.invoice = data; + this.error = false; + this.orderNumber = null; + if (data.order_id) { + try { + this.orderNumber = (await OrderService.getOrder(data.order_id)).number; + } catch { + // the order number is a convenience: the link still works by id + } + } } catch (err) { console.error(err); this.error = true; @@ -64,61 +102,59 @@ export class GableInvoiceDetail extends LitElement { private async loadPayments(id: string) { try { - const data = await paymentService.getHistory(id); - this.payments = data; + this.payments = await paymentService.getHistory(id); } catch (error) { console.error('Failed to load payments', error); } } - private async handlePayment(input: CreatePaymentRequest) { - await paymentService.createPayment(input); - if (this.routeId) { - await this.loadInvoice(this.routeId); - await this.loadPayments(this.routeId); + private async loadCreditMemos(id: string) { + try { + this.creditMemos = (await CreditMemoService.listCreditMemos({ invoiceId: id, limit: 100 })).items; + } catch (error) { + console.error('Failed to load credit memos', error); } } - private getStatusClass(status: string): string { - if (status === 'UNPAID') return 'bg-amber-500/10 text-amber-500 border-amber-500/20'; - if (status === 'PARTIAL') return 'bg-blue-500/10 text-blue-500 border-blue-500/20'; - if (status === 'PAID') return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'; - return ''; + private async handlePayment(input: CreatePaymentRequest) { + await paymentService.createPayment(input); + if (this.routeId) this.loadAll(this.routeId); } - private handleCreditMemoAmountInput(e: Event) { - this.creditMemoAmount = (e.target as HTMLInputElement).value; + private async handleEmailInvoice() { + if (!this.invoice?.id) return; + try { + await InvoiceService.emailInvoice(this.invoice.id); + ToastService.show('Invoice emailed successfully', 'success'); + } catch (err) { + ToastService.show(apiErrorMessage(err, 'Failed to email invoice'), 'error'); + } } - private handleCreditMemoReasonInput(e: Event) { - this.creditMemoReason = (e.target as HTMLInputElement).value; + private openVoid() { + this.voidError = ''; + this.voidOpen = true; } - private async handleApplyCredit() { + private async confirmVoid(reason: string) { if (!this.invoice) return; - if (!this.creditMemoAmount || !this.creditMemoReason) { - ToastService.show('Enter amount and reason', 'error'); - return; - } - try { - await ReportingService.createCreditMemo(this.invoice.id, Number(this.creditMemoAmount), this.creditMemoReason); - ToastService.show('Credit memo applied', 'success'); - this.showCreditMemo = false; - this.creditMemoAmount = ''; - this.creditMemoReason = ''; - if (this.routeId) this.loadInvoice(this.routeId); - } catch { - ToastService.show('Failed to create credit memo', 'error'); - } - } - - private async handleEmailInvoice() { - if (!this.invoice?.id) return; + this.voidBusy = true; + this.voidError = ''; try { - await InvoiceService.emailInvoice(this.invoice.id); - ToastService.show('Invoice emailed successfully', 'success'); - } catch { - ToastService.show('Failed to email invoice', 'error'); + this.invoice = await InvoiceService.voidInvoice(this.invoice.id, this.invoice.revision, reason); + this.voidOpen = false; + ToastService.show('Invoice voided', 'success'); + } catch (err) { + if (err instanceof ApiError && err.isStaleRevision) { + // someone changed the invoice since it was loaded: show the current one + this.voidOpen = false; + ToastService.show('The invoice changed while you were looking at it. It has been reloaded.', 'error'); + if (this.routeId) this.loadAll(this.routeId); + } else { + this.voidError = voidBlockerHint(err); + } + } finally { + this.voidBusy = false; } } @@ -126,208 +162,220 @@ export class GableInvoiceDetail extends LitElement { if (this.loading) return html`
Loading invoice...
`; if (this.error || !this.invoice) return html`
Failed to load invoice.
`; - const invoice = this.invoice; + const inv = this.invoice; + const isVoid = inv.status === 'void'; + const canPay = inv.status === 'unpaid' || inv.status === 'partial'; + const canVoid = inv.status === 'unpaid'; const totalPaid = this.payments.reduce((sum, p) => sum + p.amount, 0); - const amountDue = invoice.total_amount - totalPaid; return html` -
-
-
-

Invoice #${invoice.id.slice(0, 8)}

-

Order Ref: ${invoice.order_id.slice(0, 8)}

+
+ +
+
+
+

${inv.number}

+ ${formatInvoiceStatus(inv.status)} + ${overdueBadge(inv.is_overdue)} +
+

+ Invoice date ${formatDay(inv.invoice_date)} + · ${inv.currency} + · ${inv.origin === 'pos' ? 'Counter sale' : inv.delivery_type === 'pickup' ? 'Pickup' : 'Delivery'} + ${inv.picked_up_by ? html` · picked up by ${inv.picked_up_by}` : nothing} +

-
- - - - ${invoice.status !== 'PAID' ? html` + ${!isVoid ? html` +
- ` : nothing} - -
+ + ${canPay ? html` + + ` : nothing} + + ${icon(RotateCcw, 18)} Create credit memo + + ${canVoid ? html` + + ` : nothing} +
+ ` : nothing}
-
-
-

Bill To

-
-

${invoice.customer_name || 'Customer'}

-

Acct: ${invoice.customer_id.slice(0, 8)}

+ ${isVoid ? html` +
+
VOID
+
+ This invoice was voided${inv.voided_at ? html` on ${new Date(inv.voided_at).toLocaleString()}` : nothing}${inv.voided_by ? html` by ${inv.voided_by}` : nothing}. + ${inv.void_reason ? html`Reason: ${inv.void_reason}` : nothing}
-
-

Invoice Details

-
-
- Issue Date - ${new Date(invoice.created_at).toLocaleDateString()} -
-
- Terms - ${invoice.payment_terms || 'NET30'} -
-
- Due Date - ${invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : 'Net 30'} + ` : nothing} + +
+ +
+
+
+

Line Items

-
- Status - - ${invoice.status} - + ${renderSalesLines(inv.lines, { ariaLabel: 'Invoice line items' })} +
+
Subtotal${formatCents(inv.subtotal_cents)}
+
+ Tax ${inv.tax_exempt ? '(exempt)' : inv.tax_rate_percent !== null ? `(${inv.tax_rate_percent}%, ${inv.tax_source.replace(/_/g, ' ')})` : `(${inv.tax_source.replace(/_/g, ' ')})`} + ${formatCents(inv.tax_cents)} +
+
Total${formatCents(inv.total_cents)}
+
Open amount${formatCents(inv.open_cents)}
-
-
-
-
-

Line Items

-
- - - - - - - - - - - ${invoice.lines?.map(line => html` - - - - - - - `)} - - - ${invoice.subtotal > 0 && invoice.subtotal !== invoice.total_amount ? html` - - - - - - - - - ` : nothing} - - - - - -
ItemQtyRateAmount
-
${line.product_sku || line.product_id.slice(0, 8)}
- ${line.product_name ? html`
${line.product_name}
` : nothing} -
${line.quantity}${formatCents(line.price_each)}${formatCents(line.quantity * line.price_each)}
Subtotal${formatCents(invoice.subtotal)}
Tax (${(invoice.tax_rate * 100).toFixed(2)}%)${formatCents(invoice.tax_amount)}
Total Due${formatCents(invoice.total_amount)}
-
- - - ${this.payments.length > 0 ? html` -
-
-

Payment History

- Paid: ${formatCents(totalPaid)} + +
+
+

Credit memos

+ ${!isVoid ? html`New credit memo` : nothing} +
+ ${this.creditMemos.length === 0 ? html` +

No credit memos against this invoice.

+ ` : html` +
+ + + + + + + + + + + ${this.creditMemos.map(cm => html` + + + + + + + `)} + +
NumberReasonTotalStatus
${creditMemoLabel(cm)}${formatReasonCode(cm.reason_code)}${formatCents(cm.total_cents)}${formatCreditMemoStatus(cm.status)}
+
+ `}
- - - - - - - - - - - ${this.payments.map(p => html` - - - - - - - `)} - -
DateMethodReferenceAmount
${new Date(p.created_at).toLocaleString()}${p.method}${p.reference || '-'}${formatCents(p.amount)}
-
- ` : nothing} - - ${this.showCreditMemo ? html` -
-

- ${icon(RotateCcw, 16, 'w-4 h-4 text-amber-400')} - Issue Credit Memo -

-
-
- - + ${this.payments.length > 0 ? html` +
+
+

Payment History

+ Paid: ${formatCents(totalPaid)} +
+
+ + + + + + + + + + + ${this.payments.map(p => html` + + + + + + + `)} + +
DateMethodReferenceAmount
${new Date(p.created_at).toLocaleString()}${p.method}${p.reference || '-'}${formatCents(p.amount)}
+
-
- - + ` : nothing} +
+ + +
+
+

Bill To

+
+

${inv.customer_name || 'Customer'}

+

Account: ${inv.customer_id.slice(0, 8)}

+ ${inv.ship_to ? html` +

+ ${inv.ship_to.name}
+ ${inv.ship_to.line1}${inv.ship_to.line2 ? html`
${inv.ship_to.line2}` : nothing}
+ ${inv.ship_to.city}, ${inv.ship_to.region} ${inv.ship_to.postal_code} +

+ ` : nothing}
-
- - + +
+

Terms and references

+
+
Due date
${formatDay(inv.due_date)}
+ ${inv.discount_percent !== null ? html` +
Early payment
${inv.discount_percent}%${inv.discount_due_date ? html` by ${formatDay(inv.discount_due_date)}` : nothing}
+ ` : nothing} +
Order
${inv.order_id ? html`${this.orderNumber ?? inv.order_id.slice(0, 8)}` : html`counter sale`}
+ ${inv.job_id ? html`
Job
${inv.job_id.slice(0, 8)}
` : nothing} +
Currency
${inv.currency}
+
Origin
${inv.origin === 'pos' ? 'Counter' : 'Order'}
+
Delivery
${inv.delivery_type === 'pickup' ? 'Pickup' : 'Delivery'}
+ ${inv.picked_up_by ? html`
Picked up by
${inv.picked_up_by}
` : nothing} + ${inv.paid_at ? html`
Paid
${new Date(inv.paid_at).toLocaleDateString()}
` : nothing} +
- ` : nothing} +
- ${invoice.id ? html` - { this.isPaymentModalOpen = false; }} - @save=${(e: CustomEvent) => this.handlePayment(e.detail)} - .invoiceId=${invoice.id} - .amountDue=${amountDue > 0 ? amountDue : 0} - > - ` : nothing} + { this.isPaymentModalOpen = false; }} + @save=${(e: CustomEvent) => this.handlePayment(e.detail)} + .invoiceId=${inv.id} + .amountDue=${inv.open_cents > 0 ? inv.open_cents : 0} + > + + { this.voidOpen = false; }} + @confirm=${(e: CustomEvent<{ reason: string }>) => this.confirmVoid(e.detail.reason)} + >
`; } diff --git a/web/apps/desk/src/pages/invoices/InvoiceList.ts b/web/apps/desk/src/pages/invoices/InvoiceList.ts index a9e24b65..fe862c5f 100644 --- a/web/apps/desk/src/pages/invoices/InvoiceList.ts +++ b/web/apps/desk/src/pages/invoices/InvoiceList.ts @@ -1,27 +1,51 @@ // SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors -import { LitElement, html } from 'lit'; +import { LitElement, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; +import { icon } from '../../lib/icons.ts'; import { router } from '../../lib/router.ts'; +import { ArrowRight } from 'lucide'; import { InvoiceService } from '../../services/InvoiceService.ts'; -import type { Invoice } from '../../types/invoice.ts'; +import { CustomerService } from '../../services/CustomerService.ts'; +import { apiErrorMessage } from '../../services/apiError.ts'; +import { + type InvoiceSummary, + type InvoiceStatus, + INVOICE_STATUSES, + formatInvoiceStatus, + getInvoiceStatusColor, +} from '../../types/invoice.ts'; +import { chipClass, overdueBadge } from '../../components/invoices/chips.ts'; import { onBranchChanged } from '../../lib/branch-listener.ts'; -import { formatCents } from '../../lib/utils.ts'; +import { formatCents, formatDay } from '../../lib/utils.ts'; + +const PAGE_SIZE = 50; @customElement('gable-invoice-list') export class GableInvoiceList extends LitElement { createRenderRoot() { return this; } - @state() private invoices: Invoice[] = []; + @state() private invoices: InvoiceSummary[] = []; @state() private loading = true; + @state() private refreshing = false; @state() private error: string | null = null; + @state() private status: InvoiceStatus | 'all' = 'all'; + @state() private overdueOnly = false; + @state() private customerId = ''; + @state() private customers: { id: string; name: string }[] = []; + @state() private nextCursor: string | null = null; + @state() private total: number | undefined; private _unsubBranch: (() => void) | null = null; connectedCallback() { super.connectedCallback(); this.loadInvoices(); - this._unsubBranch = onBranchChanged(() => this.loadInvoices()); + this.loadCustomers(); + this._unsubBranch = onBranchChanged(() => { + this.loading = true; + this.loadInvoices(); + }); } disconnectedCallback() { @@ -32,39 +56,77 @@ export class GableInvoiceList extends LitElement { } } + private filterParams() { + return { + status: this.status === 'all' ? undefined : this.status, + customerId: this.customerId || undefined, + overdue: this.overdueOnly ? true : undefined, + limit: PAGE_SIZE, + }; + } + + private async loadCustomers() { + try { + const all = await CustomerService.listAllCustomers(); + this.customers = all.map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.name.localeCompare(b.name)); + } catch (err) { + // the customer filter is a convenience: the list works without it + console.error('Failed to load customers for the filter', err); + } + } + private async loadInvoices() { try { this.error = null; - this.loading = true; - const data = await InvoiceService.listInvoices(); - this.invoices = data; + const page = await InvoiceService.listInvoices({ ...this.filterParams(), includeTotal: true }); + this.invoices = page.items; + this.nextCursor = page.next_cursor; + this.total = page.total; } catch (err) { console.error('Failed to load invoices:', err); - this.error = err instanceof Error ? err.message : 'Failed to load invoices'; + this.error = apiErrorMessage(err, 'Failed to load invoices'); } finally { this.loading = false; + this.refreshing = false; } } - private getStatusClass(status: string): string { - if (status === 'UNPAID') return 'bg-amber-500/10 text-amber-500 border-amber-500/20'; - if (status === 'PAID') return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'; - if (status === 'OVERDUE') return 'bg-red-500/10 text-red-500 border-red-500/20'; - return ''; + private async loadMore() { + if (!this.nextCursor) return; + try { + const page = await InvoiceService.listInvoices({ ...this.filterParams(), cursor: this.nextCursor }); + this.invoices = [...this.invoices, ...page.items]; + this.nextCursor = page.next_cursor; + } catch (err) { + this.error = apiErrorMessage(err, 'Failed to load more invoices'); + } + } + + private refilter() { + this.refreshing = true; + this.loadInvoices(); + } + + private emptyText(): string { + const parts: string[] = []; + if (this.status !== 'all') parts.push(formatInvoiceStatus(this.status)); + if (this.overdueOnly) parts.push('overdue'); + if (this.customerId) parts.push('for this customer'); + return parts.length > 0 ? `No invoices match: ${parts.join(', ')}.` : 'No invoices have been billed yet. Fulfil an order to create one.'; } render() { if (this.loading) { - return html`
Loading financials...
`; + return html`
Loading invoices...
`; } - if (this.error) { + if (this.error && this.invoices.length === 0) { return html`

Failed to load

${this.error}

+
+ ` : nothing} + `}
`; } diff --git a/web/apps/desk/src/services/CreditMemoService.ts b/web/apps/desk/src/services/CreditMemoService.ts new file mode 100644 index 00000000..083aa52c --- /dev/null +++ b/web/apps/desk/src/services/CreditMemoService.ts @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +// The credit memo module on the wire contract (ADR 0005 section 6.3): the cursor +// list envelope, create and update of a draft, and the transitions route (post a +// draft to open, void a draft or an open memo). Every amount is a negative +// integer of cents. + +import type { + CreditMemo, + CreditMemoPage, + CreditMemoRequest, + CreditMemoStatus, + CreditMemoSummary, +} from '../types/creditMemo.ts'; +import { fetchWithAuth } from './fetchClient'; +import { parseApiError, ifMatch } from './apiError'; + +const API_URL = import.meta.env.VITE_API_URL || ''; + +export interface ListCreditMemosParams { + status?: CreditMemoStatus | CreditMemoStatus[]; + customerId?: string; + invoiceId?: string; + jobId?: string; + limit?: number; + cursor?: string | null; + includeTotal?: boolean; +} + +export function buildCreditMemoQuery(params: ListCreditMemosParams = {}): string { + const q = new URLSearchParams(); + const statuses = Array.isArray(params.status) ? params.status : params.status ? [params.status] : []; + if (statuses.length > 0) q.set('status', statuses.join(',')); + if (params.customerId) q.set('customer_id', params.customerId); + if (params.invoiceId) q.set('invoice_id', params.invoiceId); + if (params.jobId) q.set('job_id', params.jobId); + if (params.limit !== undefined) q.set('limit', String(params.limit)); + if (params.cursor) q.set('cursor', params.cursor); + if (params.includeTotal) q.set('include', 'total'); + return q.toString(); +} + +async function expectOk(response: Response, fallback: string): Promise { + if (response.ok) return response; + throw await parseApiError(response, fallback); +} + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +export const CreditMemoService = { + /** One page of credit memos, newest first; the cursor walks forward. */ + async listCreditMemos(params: ListCreditMemosParams = {}): Promise { + const query = buildCreditMemoQuery(params); + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/credit-memos${query ? `?${query}` : ''}`), + 'Failed to fetch credit memos', + ); + return response.json(); + }, + + /** Every credit memo of a filter, walking the cursor. */ + async allCreditMemos(params: Omit = {}): Promise { + const out: CreditMemoSummary[] = []; + let cursor: string | null = null; + do { + const page: CreditMemoPage = await this.listCreditMemos({ ...params, cursor, limit: params.limit ?? 200 }); + out.push(...page.items); + cursor = page.next_cursor; + } while (cursor); + return out; + }, + + async getCreditMemo(id: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/credit-memos/${id}`), + 'Failed to fetch credit memo', + ); + return response.json(); + }, + + /** Creates a draft credit memo; nothing posts until it is posted. */ + async createCreditMemo(request: CreditMemoRequest): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/credit-memos`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify(request), + }), + 'Failed to create credit memo', + ); + return response.json(); + }, + + /** Replaces a draft's header and lines on the client's revision (409 credit_memo_not_draft otherwise). */ + async updateCreditMemo(id: string, revision: number, request: CreditMemoRequest): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/credit-memos/${id}`, { + method: 'PUT', + headers: { ...JSON_HEADERS, 'If-Match': ifMatch(revision) }, + body: JSON.stringify({ ...request, revision }), + }), + 'Failed to update credit memo', + ); + return response.json(); + }, + + async transition(id: string, revision: number, to: CreditMemoStatus, reason?: string): Promise { + const body: Record = { to, revision }; + if (reason !== undefined) body.reason = reason; + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/credit-memos/${id}/transitions`, { + method: 'POST', + headers: { ...JSON_HEADERS, 'If-Match': ifMatch(revision) }, + body: JSON.stringify(body), + }), + 'Failed to change credit memo status', + ); + return response.json(); + }, + + /** Posts a draft: assigns the number, restocks the restock lines and posts the entry. */ + async post(id: string, revision: number): Promise { + return this.transition(id, revision, 'open'); + }, + + /** Voids a draft or an open memo; a reason is required. */ + async voidCreditMemo(id: string, revision: number, reason: string): Promise { + return this.transition(id, revision, 'void', reason); + }, +}; diff --git a/web/apps/desk/src/services/InvoiceService.ts b/web/apps/desk/src/services/InvoiceService.ts index 766685bf..bb8f4a82 100644 --- a/web/apps/desk/src/services/InvoiceService.ts +++ b/web/apps/desk/src/services/InvoiceService.ts @@ -1,35 +1,100 @@ // SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors -import type { Invoice } from '../types/invoice'; +// The invoice module on the wire contract (ADR 0001; ADR 0005 section 6): the +// cursor list envelope, the transitions route (a void takes the revision and a +// reason), integer cents money and the one error envelope. + +import type { Invoice, InvoicePage, InvoiceStatus, InvoiceSummary } from '../types/invoice.ts'; import { fetchWithAuth } from './fetchClient'; +import { parseApiError, ifMatch } from './apiError'; const API_URL = import.meta.env.VITE_API_URL || ''; +export interface ListInvoicesParams { + status?: InvoiceStatus | InvoiceStatus[]; + customerId?: string; + jobId?: string; + shipToId?: string; + orderId?: string; + overdue?: boolean; + limit?: number; + cursor?: string | null; + includeTotal?: boolean; +} + +export function buildInvoiceQuery(params: ListInvoicesParams = {}): string { + const q = new URLSearchParams(); + const statuses = Array.isArray(params.status) ? params.status : params.status ? [params.status] : []; + if (statuses.length > 0) q.set('status', statuses.join(',')); + if (params.customerId) q.set('customer_id', params.customerId); + if (params.jobId) q.set('job_id', params.jobId); + if (params.shipToId) q.set('ship_to_id', params.shipToId); + if (params.orderId) q.set('order_id', params.orderId); + if (params.overdue !== undefined) q.set('overdue', String(params.overdue)); + if (params.limit !== undefined) q.set('limit', String(params.limit)); + if (params.cursor) q.set('cursor', params.cursor); + if (params.includeTotal) q.set('include', 'total'); + return q.toString(); +} + +async function expectOk(response: Response, fallback: string): Promise { + if (response.ok) return response; + throw await parseApiError(response, fallback); +} + export const InvoiceService = { - async listInvoices(): Promise { - const response = await fetchWithAuth(`${API_URL}/api/v1/invoices`); - if (!response.ok) { - throw new Error('Failed to fetch invoices'); - } - const payload = await response.json(); - return Array.isArray(payload) ? payload : (payload?.data ?? []); + /** One page of invoices, newest first; the cursor walks forward. */ + async listInvoices(params: ListInvoicesParams = {}): Promise { + const query = buildInvoiceQuery(params); + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/invoices${query ? `?${query}` : ''}`), + 'Failed to fetch invoices', + ); + return response.json(); + }, + + /** Every invoice of a filter, walking the cursor. */ + async allInvoices(params: Omit = {}): Promise { + const out: InvoiceSummary[] = []; + let cursor: string | null = null; + do { + const page: InvoicePage = await this.listInvoices({ ...params, cursor, limit: params.limit ?? 200 }); + out.push(...page.items); + cursor = page.next_cursor; + } while (cursor); + return out; }, async getInvoice(id: string): Promise { - const response = await fetchWithAuth(`${API_URL}/api/v1/invoices/${id}`); - if (!response.ok) { - throw new Error('Failed to fetch invoice'); - } + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/invoices/${id}`), + 'Failed to fetch invoice', + ); + return response.json(); + }, + + /** + * Voids an unpaid invoice on the client's revision (roles admin, owner, + * finance). A payment or an applied credit memo refuses it with a 409 whose + * blocker code (has_applications, has_credit_memos) is in the error details. + */ + async voidInvoice(id: string, revision: number, reason: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/invoices/${id}/transitions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'If-Match': ifMatch(revision) }, + body: JSON.stringify({ to: 'void', revision, reason }), + }), + 'Failed to void invoice', + ); return response.json(); }, async emailInvoice(id: string): Promise { - const response = await fetchWithAuth(`${API_URL}/api/v1/invoices/${id}/email`, { - method: 'POST' - }); - if (!response.ok) { - throw new Error('Failed to email invoice'); - } - } + await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/invoices/${id}/email`, { method: 'POST' }), + 'Failed to email invoice', + ); + }, }; diff --git a/web/apps/desk/src/services/ReportingService.test.ts b/web/apps/desk/src/services/ReportingService.test.ts index 116da989..8d47f22c 100644 --- a/web/apps/desk/src/services/ReportingService.test.ts +++ b/web/apps/desk/src/services/ReportingService.test.ts @@ -2,14 +2,9 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors /** - * The dollars -> cents *write* path. - * - * `formatCents()` guards the read side of the ERP money boundary; this guards - * the write side. A credit memo is entered by a human in dollars and must reach - * the backend as integer cents. Two ways to get this wrong: forget the *100 - * (issue a $0.74 credit instead of $73.88), or trust float multiplication - * (19.99 * 100 === 1998.9999999999998, which truncates to 1998 — a one-cent - * shortfall that will not reconcile). + * The reporting read paths: query-string construction and the descriptive + * error each failing report endpoint throws. (Credit memos moved to the + * credit memo module, services/CreditMemoService.ts.) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { ReportingService } from './ReportingService' @@ -21,12 +16,6 @@ function jsonResponse(body: unknown, status = 200): Response { }) } -/** The JSON body the service actually sent on its most recent call. */ -function sentBody(mock: ReturnType): Record { - const init = mock.mock.calls[mock.mock.calls.length - 1][1] as RequestInit - return JSON.parse(init.body as string) as Record -} - let fetchMock: ReturnType /** A fresh Response per call — a Response body can only be read once. */ @@ -43,59 +32,6 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('ReportingService.createCreditMemo — dollars in, cents out', () => { - it('converts whole dollars to cents', async () => { - await ReportingService.createCreditMemo('inv-1', 100, 'damaged goods') - expect(sentBody(fetchMock).amount_cents).toBe(10_000) - }) - - it('converts the $73.88 case to 7388 cents', async () => { - await ReportingService.createCreditMemo('inv-1', 73.88, 'short shipment') - expect(sentBody(fetchMock).amount_cents).toBe(7388) - }) - - it('rounds away float multiplication error instead of truncating', async () => { - // 19.99 * 100 === 1998.9999999999998 in IEEE-754. Truncation would send 1998. - await ReportingService.createCreditMemo('inv-1', 19.99, 'price adjustment') - expect(sentBody(fetchMock).amount_cents).toBe(1999) - }) - - it('rounds the other classic float offender', async () => { - // 1.005 * 100 === 100.49999999999999 - await ReportingService.createCreditMemo('inv-1', 1.005, 'rounding') - expect(sentBody(fetchMock).amount_cents).toBe(100) - }) - - it('sends an integer, never a fractional cent', async () => { - await ReportingService.createCreditMemo('inv-1', 12.345, 'odd amount') - const cents = sentBody(fetchMock).amount_cents as number - expect(Number.isInteger(cents)).toBe(true) - }) - - it('handles zero and negative amounts without changing magnitude', async () => { - await ReportingService.createCreditMemo('inv-1', 0, 'zeroed out') - expect(sentBody(fetchMock).amount_cents).toBe(0) - - await ReportingService.createCreditMemo('inv-1', -50.25, 'reversal') - expect(sentBody(fetchMock).amount_cents).toBe(-5025) - }) - - it('passes the reason through untouched and POSTs to the invoice sub-resource', async () => { - await ReportingService.createCreditMemo('inv-42', 5, 'wrong species delivered') - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toContain('/api/v1/invoices/inv-42/credit-memo') - expect(init.method).toBe('POST') - expect(sentBody(fetchMock).reason).toBe('wrong species delivered') - }) - - it('throws on a non-2xx response instead of returning a partial memo', async () => { - fetchMock.mockImplementation(respondWith({ error: 'invoice already closed' }, 409)) - await expect( - ReportingService.createCreditMemo('inv-1', 10, 'too late'), - ).rejects.toThrow('Failed to create credit memo') - }) -}) - describe('ReportingService query-string construction', () => { it('omits the query string entirely when no date is given', async () => { fetchMock.mockImplementation(respondWith({})) diff --git a/web/apps/desk/src/services/ReportingService.ts b/web/apps/desk/src/services/ReportingService.ts index ea162eb0..277d1a4b 100644 --- a/web/apps/desk/src/services/ReportingService.ts +++ b/web/apps/desk/src/services/ReportingService.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors import type { DailyTillReport, SalesSummaryReport } from '../types/reporting'; -import type { ARAgingReport, CustomerStatement, CreditMemo } from '../types/invoice'; +import type { ARAgingReport, CustomerStatement } from '../types/invoice'; import { fetchWithAuth } from './fetchClient'; const API_URL = import.meta.env.VITE_API_URL || ''; @@ -44,16 +44,5 @@ export const ReportingService = { const response = await fetchWithAuth(url); if (!response.ok) throw new Error('Failed to fetch customer statement'); return response.json(); - }, - - async createCreditMemo(invoiceId: string, amount: number, reason: string): Promise { - // `amount` is entered in dollars; the backend expects integer cents (`amount_cents`). - const response = await fetchWithAuth(`${API_URL}/api/v1/invoices/${invoiceId}/credit-memo`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ amount_cents: Math.round(amount * 100), reason }), - }); - if (!response.ok) throw new Error('Failed to create credit memo'); - return response.json(); } }; diff --git a/web/apps/desk/src/services/apiError.ts b/web/apps/desk/src/services/apiError.ts index ee92f800..65aeb8af 100644 --- a/web/apps/desk/src/services/apiError.ts +++ b/web/apps/desk/src/services/apiError.ts @@ -33,6 +33,11 @@ export class ApiError extends Error { return this.code === 'stale_revision'; } + /** True when a 409 carries the named blocker (for example has_applications) in its details. */ + hasBlocker(code: string): boolean { + return this.details.some(d => d.code === code); + } + /** The message, with each field problem or blocker listed on its own line for validation_failed and invalid_state_transition. */ get displayMessage(): string { if ((this.code === 'validation_failed' || this.code === 'invalid_state_transition') && this.details.length > 0) { diff --git a/web/apps/desk/src/types/creditMemo.ts b/web/apps/desk/src/types/creditMemo.ts new file mode 100644 index 00000000..c2c3057e --- /dev/null +++ b/web/apps/desk/src/types/creditMemo.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +// The credit memo on the wire contract (ADR 0005 section 6.3), derived from the +// generated client. Every total, quantity and extension is NEGATIVE; a draft has +// no number until it is posted. + +import type { components } from '@gable/api-client'; + +type Schemas = components['schemas']; + +export type CreditMemo = Schemas['CreditMemo']; +export type CreditMemoSummary = Schemas['CreditMemoSummary']; +export type CreditMemoLine = Schemas['CreditMemoLine']; +export type CreditMemoPage = Schemas['CreditMemoPage']; +export type CreditMemoRequest = Schemas['CreditMemoRequest']; +export type CreditMemoLineRequest = Schemas['CreditMemoLineRequest']; +export type CreditMemoTransitionRequest = Schemas['CreditMemoTransitionRequest']; +export type CreditMemoStatus = Schemas['CreditMemoStatus']; +export type CreditReasonCode = CreditMemoSummary['reason_code']; + +export const CREDIT_MEMO_STATUSES: CreditMemoStatus[] = ['draft', 'open', 'partial', 'applied', 'void']; +export const CREDIT_REASON_CODES: CreditReasonCode[] = ['return', 'price_adjustment', 'damage', 'other']; + +const REASON_LABELS: Record = { + return: 'Return', + price_adjustment: 'Price adjustment', + damage: 'Damage', + other: 'Other', +}; + +export const formatReasonCode = (code: CreditReasonCode): string => REASON_LABELS[code] ?? code; + +export const formatCreditMemoStatus = (status: CreditMemoStatus): string => + status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + +export type CreditMemoStatusColor = 'default' | 'info' | 'success' | 'warning' | 'error'; + +export const getCreditMemoStatusColor = (status: CreditMemoStatus): CreditMemoStatusColor => { + switch (status) { + case 'draft': return 'default'; + case 'open': return 'warning'; + case 'partial': return 'info'; + case 'applied': return 'success'; + case 'void': return 'error'; + default: return 'default'; + } +}; + +/** The number for display: a draft has none yet. */ +export const creditMemoLabel = (memo: { number: string | null }): string => memo.number ?? 'Draft'; diff --git a/web/apps/desk/src/types/invoice.ts b/web/apps/desk/src/types/invoice.ts index 3b883c13..ab099530 100644 --- a/web/apps/desk/src/types/invoice.ts +++ b/web/apps/desk/src/types/invoice.ts @@ -1,50 +1,40 @@ // SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors -export type InvoiceStatus = 'UNPAID' | 'PARTIAL' | 'PAID' | 'VOID' | 'OVERDUE'; -export type PaymentTerms = 'COD' | 'DUE_ON_RECEIPT' | 'NET30' | 'NET60' | 'NET90'; +// The invoice on the wire contract (ADR 0001; ADR 0005 section 6): every shape +// is derived from the generated client so a contract change is a compile error. +// A lowercase status, integer cents money, decimal string quantities, the scaled +// unit price, and an overdue flag the server computes (OVERDUE is not a status). +// The reporting shapes at the foot belong to the unconverted reporting module. -export interface Invoice { - id: string; - order_id: string; - customer_id: string; - customer_name?: string; - status: InvoiceStatus; - subtotal: number; - tax_rate: number; - tax_amount: number; - total_amount: number; - payment_terms: PaymentTerms; - due_date?: string; - paid_at?: string; - created_at: string; - updated_at: string; +import type { components } from '@gable/api-client'; - // Relations - lines?: InvoiceLine[]; -} +type Schemas = components['schemas']; -export interface InvoiceLine { - id: string; - invoice_id: string; - product_id: string; - product_sku?: string; - product_name?: string; - quantity: number; - price_each: number; - created_at: string; -} +export type Invoice = Schemas['Invoice']; +export type InvoiceSummary = Schemas['InvoiceSummary']; +export type InvoiceLine = Schemas['InvoiceLine']; +export type InvoicePage = Schemas['InvoicePage']; +export type InvoiceStatus = Schemas['InvoiceStatus']; +export type InvoiceTransitionRequest = Schemas['InvoiceTransitionRequest']; -export interface CreditMemo { - id: string; - invoice_id?: string; - customer_id: string; - amount: number; - reason: string; - status: 'PENDING' | 'APPLIED' | 'VOID'; - created_at: string; - applied_at?: string; -} +export const INVOICE_STATUSES: InvoiceStatus[] = ['unpaid', 'partial', 'paid', 'void', 'written_off']; + +export type InvoiceStatusColor = 'default' | 'info' | 'success' | 'warning' | 'error'; + +export const getInvoiceStatusColor = (status: InvoiceStatus): InvoiceStatusColor => { + switch (status) { + case 'unpaid': return 'warning'; + case 'partial': return 'info'; + case 'paid': return 'success'; + case 'void': return 'error'; + case 'written_off': return 'default'; + default: return 'default'; + } +}; + +export const formatInvoiceStatus = (status: InvoiceStatus): string => + status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); export interface ARAgingBucket { customer_id: string; From beaa75c02ad060caf89468c04925d6c82e356a64 Mon Sep 17 00:00:00 2001 From: Gable v1 Coder Date: Thu, 8 Oct 2026 13:37:30 -0700 Subject: [PATCH 09/25] invoices and credit memos: transaction proofs, migration test, branch wall through serve, exposure lock, relieved cost and discount carried items Co-Authored-By: Claude Sonnet 5.5 --- .../app/serve/wire_branch_wall_test.go | 108 +++- core/internal/invoice/credit_build_test.go | 113 +++++ core/internal/invoice/credit_test.go | 5 +- core/internal/invoice/migration097_test.go | 313 ++++++++++++ core/internal/invoice/service.go | 8 + core/internal/invoice/tx_test.go | 440 +++++++++++++++++ core/internal/invoice/wire_test.go | 179 +++++++ core/internal/salesdoc/salesdoc_test.go | 53 ++ web/apps/desk/src/app.ts | 4 + .../src/components/invoices/SalesDialog.ts | 2 +- web/apps/desk/src/lib/credit-display.test.ts | 96 ++++ web/apps/desk/src/lib/workspace.test.ts | 7 + web/apps/desk/src/lib/workspace.ts | 5 + .../credit-memos/CreditMemoDetail.test.ts | 139 ++++++ .../pages/credit-memos/CreditMemoDetail.ts | 218 +++++++++ .../pages/credit-memos/CreditMemoForm.test.ts | 242 +++++++++ .../src/pages/credit-memos/CreditMemoForm.ts | 463 ++++++++++++++++++ .../pages/credit-memos/CreditMemoList.test.ts | 79 +++ .../src/pages/credit-memos/CreditMemoList.ts | 236 +++++++++ .../src/pages/invoices/InvoiceDetail.test.ts | 211 ++++++++ .../src/pages/invoices/InvoiceList.test.ts | 100 ++++ web/apps/desk/src/pages/orders/OrderDetail.ts | 18 +- .../src/pages/portal/PortalInvoices.test.ts | 68 +++ .../desk/src/pages/portal/PortalInvoices.ts | 19 +- web/apps/desk/src/routes.ts | 4 + .../src/services/CreditMemoService.test.ts | 104 ++++ .../desk/src/services/InvoiceService.test.ts | 136 +++++ web/apps/desk/src/test/salesFixtures.ts | 163 ++++++ web/apps/desk/src/types/portal.ts | 8 +- 29 files changed, 3528 insertions(+), 13 deletions(-) create mode 100644 core/internal/invoice/credit_build_test.go create mode 100644 core/internal/invoice/migration097_test.go create mode 100644 core/internal/invoice/tx_test.go create mode 100644 web/apps/desk/src/lib/credit-display.test.ts create mode 100644 web/apps/desk/src/pages/credit-memos/CreditMemoDetail.test.ts create mode 100644 web/apps/desk/src/pages/credit-memos/CreditMemoDetail.ts create mode 100644 web/apps/desk/src/pages/credit-memos/CreditMemoForm.test.ts create mode 100644 web/apps/desk/src/pages/credit-memos/CreditMemoForm.ts create mode 100644 web/apps/desk/src/pages/credit-memos/CreditMemoList.test.ts create mode 100644 web/apps/desk/src/pages/credit-memos/CreditMemoList.ts create mode 100644 web/apps/desk/src/pages/invoices/InvoiceDetail.test.ts create mode 100644 web/apps/desk/src/pages/invoices/InvoiceList.test.ts create mode 100644 web/apps/desk/src/pages/portal/PortalInvoices.test.ts create mode 100644 web/apps/desk/src/services/CreditMemoService.test.ts create mode 100644 web/apps/desk/src/services/InvoiceService.test.ts create mode 100644 web/apps/desk/src/test/salesFixtures.ts diff --git a/core/internal/app/serve/wire_branch_wall_test.go b/core/internal/app/serve/wire_branch_wall_test.go index 14c0c3c1..bb705f6c 100644 --- a/core/internal/app/serve/wire_branch_wall_test.go +++ b/core/internal/app/serve/wire_branch_wall_test.go @@ -80,6 +80,7 @@ type wallFixture struct { docCust uuid.UUID orderA, orderB uuid.UUID invA, invB uuid.UUID + memoA, memoB uuid.UUID db *database.DB } @@ -88,7 +89,7 @@ func newWallFixture(t *testing.T, db *database.DB, multiBranch bool) *wallFixtur ctx := context.Background() f := &wallFixture{db: db, branchA: uuid.New(), branchB: uuid.New(), yardA: uuid.New(), yardB: uuid.New(), productID: uuid.New(), vendorID: uuid.New(), poA: uuid.New(), poB: uuid.New(), poLineA: uuid.New(), poLineB: uuid.New(), - docCust: uuid.New(), orderA: uuid.New(), orderB: uuid.New(), invA: uuid.New(), invB: uuid.New()} + docCust: uuid.New(), orderA: uuid.New(), orderB: uuid.New(), invA: uuid.New(), invB: uuid.New(), memoA: uuid.New(), memoB: uuid.New()} for _, r := range []struct { id uuid.UUID typ string @@ -151,6 +152,20 @@ func newWallFixture(t *testing.T, db *database.DB, multiBranch bool) *wallFixtur t.Fatalf("seed invoice: %v", err) } } + // One posted credit memo per branch, so the credit memo path-id routes act + // on real records of each branch; branch A carries a tax rate so a free + // credit memo can be created there. + if _, err := db.Pool.Exec(ctx, `UPDATE locations SET default_tax_rate = 0.05 WHERE id = $1`, f.branchA); err != nil { + t.Fatalf("branch rate: %v", err) + } + for _, m := range []struct{ id, inv, branch uuid.UUID }{{f.memoA, f.invA, f.branchA}, {f.memoB, f.invB, f.branchB}} { + if _, err := db.Pool.Exec(ctx, `INSERT INTO credit_memos (id, invoice_id, customer_id, branch_id, currency, reason_code, reason, amount, status, number, + memo_date, subtotal, tax_amount, total_amount, tax_rate) + VALUES ($1, $2, $3, $4, 'USD', 'OTHER', 'wall', 1, 'OPEN', credit_memo_next_number(), CURRENT_DATE, -1, 0, -1, 0)`, + m.id, m.inv, f.docCust, m.branch); err != nil { + t.Fatalf("seed credit memo: %v", err) + } + } for _, sub := range []string{"u-a"} { if _, err := db.Pool.Exec(ctx, `INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ($1, $2, TRUE, 'test')`, sub, f.branchA); err != nil { t.Fatalf("seed grant: %v", err) @@ -158,6 +173,9 @@ func newWallFixture(t *testing.T, db *database.DB, multiBranch bool) *wallFixtur } t.Cleanup(func() { _, _ = db.Pool.Exec(ctx, `DELETE FROM user_locations WHERE user_sub = 'u-a'`) + _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_id IN (SELECT id FROM credit_memos WHERE customer_id = $1)`, f.docCust) + _, _ = db.Pool.Exec(ctx, `DELETE FROM credit_memo_lines WHERE credit_memo_id IN (SELECT id FROM credit_memos WHERE customer_id = $1)`, f.docCust) + _, _ = db.Pool.Exec(ctx, `DELETE FROM credit_memos WHERE customer_id = $1`, f.docCust) _, _ = db.Pool.Exec(ctx, `DELETE FROM invoices WHERE id IN ($1, $2)`, f.invA, f.invB) _, _ = db.Pool.Exec(ctx, `DELETE FROM orders WHERE id IN ($1, $2)`, f.orderA, f.orderB) _, _ = db.Pool.Exec(ctx, `DELETE FROM customer_branches WHERE customer_id = $1`, f.docCust) @@ -207,6 +225,7 @@ func newWallFixture(t *testing.T, db *database.DB, multiBranch bool) *wallFixtur orderSvc := order.NewService(order.NewRepository(db)).WithTxRunner(db) docHandler := document.NewHandler(docSvc, orderSvc, invoiceSvc, customer.NewService(customer.NewRepository(db)), notification.NewLogEmailService(slog.Default())) wall.documents(mux, docHandler) + wall.invoices(mux, invoice.NewService(invoice.NewRepository(db), glSvc, accountSvc, db).WithOutbox(outbox.NewWriter(db, "")).WithOrders(orderSvc).WithStock(invSvc)) f.srv = httptest.NewServer(asRole(mux)) t.Cleanup(f.srv.Close) return f @@ -1221,3 +1240,90 @@ func TestBranchWall_PORefreshReorderTargets(t *testing.T) { adminPoint, adminQty, wantPoint, wantQty) } } + +// The invoice and credit memo routes through serve's wiring: every read is held +// to the caller's wall (a context branch, else the caller's grants, else every +// branch for an administrator), a record outside it is a 404 on every route +// that names it, the list covers the caller's branches only, and a branch a +// credit memo create names is held to the payload branch rule (403 naming +// branch_id). +func TestBranchWall_InvoiceAndCreditMemoRoutes(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newWallFixture(t, db, true) + A, B := f.branchA.String(), f.branchB.String() + + const ok = http.StatusOK + const no = http.StatusForbidden + const gone = http.StatusNotFound + free := func(branch string) string { + b := "" + if branch != "" { + b = fmt.Sprintf(`"branch_id":%q,`, branch) + } + return fmt.Sprintf(`{%s"customer_id":%q,"reason_code":"price_adjustment","reason":"wall","lines":[{"line_type":"charge","charge_code":"ADJUST","quantity":"-1","unit_price_ten_thousandths":1000}]}`, b, f.docCust) + } + for _, c := range []struct { + name, method, path, body, role, sub, header string + want int + }{ + // reads of a record by id + {"get own invoice", "GET", "/api/v1/invoices/" + f.invA.String(), "", "sales", "u-a", A, ok}, + {"get foreign invoice, header A", "GET", "/api/v1/invoices/" + f.invB.String(), "", "sales", "u-a", A, gone}, + {"get foreign invoice, no header", "GET", "/api/v1/invoices/" + f.invB.String(), "", "sales", "u-a", "", gone}, + {"get own invoice, no header", "GET", "/api/v1/invoices/" + f.invA.String(), "", "sales", "u-a", "", ok}, + {"get foreign invoice, admin", "GET", "/api/v1/invoices/" + f.invB.String(), "", "admin", "boss", "", ok}, + {"get own credit memo", "GET", "/api/v1/credit-memos/" + f.memoA.String(), "", "finance", "u-a", A, ok}, + {"get foreign credit memo, header A", "GET", "/api/v1/credit-memos/" + f.memoB.String(), "", "finance", "u-a", A, gone}, + {"get foreign credit memo, no header", "GET", "/api/v1/credit-memos/" + f.memoB.String(), "", "finance", "u-a", "", gone}, + {"get foreign credit memo, admin", "GET", "/api/v1/credit-memos/" + f.memoB.String(), "", "admin", "boss", "", ok}, + // writes that address a record by path id + {"void foreign invoice, no header", "POST", "/api/v1/invoices/" + f.invB.String() + "/transitions", `{"to":"void","revision":1,"reason":"x"}`, "finance", "u-a", "", gone}, + {"void foreign invoice, header A", "POST", "/api/v1/invoices/" + f.invB.String() + "/transitions", `{"to":"void","revision":1,"reason":"x"}`, "finance", "u-a", A, gone}, + {"void own invoice with a credit memo, header A", "POST", "/api/v1/invoices/" + f.invA.String() + "/transitions", `{"to":"void","revision":1,"reason":"x"}`, "finance", "u-a", A, http.StatusConflict}, + {"post foreign credit memo, no header", "POST", "/api/v1/credit-memos/" + f.memoB.String() + "/transitions", `{"to":"open","revision":1}`, "finance", "u-a", "", gone}, + {"void foreign credit memo, header A", "POST", "/api/v1/credit-memos/" + f.memoB.String() + "/transitions", `{"to":"void","revision":1,"reason":"x"}`, "finance", "u-a", A, gone}, + {"edit foreign credit memo, no header", "PUT", "/api/v1/credit-memos/" + f.memoB.String(), strings.Replace(free(""), "{", `{"revision":1,`, 1), "sales", "u-a", "", gone}, + // the payload branch rule on the create + {"create naming a foreign branch, header A", "POST", "/api/v1/credit-memos", free(B), "sales", "u-a", A, no}, + {"create naming a foreign branch, no header", "POST", "/api/v1/credit-memos", free(B), "sales", "u-a", "", no}, + {"create naming its own branch", "POST", "/api/v1/credit-memos", free(A), "sales", "u-a", A, http.StatusCreated}, + {"create naming its own branch, no header", "POST", "/api/v1/credit-memos", free(A), "sales", "u-a", "", http.StatusCreated}, + {"create naming a foreign branch, admin", "POST", "/api/v1/credit-memos", free(B), "admin", "boss", "", http.StatusConflict}, // branch B has no tax rate: a refusal, not the wall + {"create against a foreign invoice, header A", "POST", "/api/v1/credit-memos", fmt.Sprintf(`{"invoice_id":%q,"reason_code":"return","reason":"x","lines":[{"line_type":"charge","charge_code":"ADJUST","quantity":"-1","unit_price_ten_thousandths":1000}]}`, f.invB), "sales", "u-a", A, http.StatusBadRequest}, + // a role the module does not serve + {"warehouse reads an invoice", "GET", "/api/v1/invoices/" + f.invA.String(), "", "warehouse", "u-a", A, no}, + } { + if got := f.call(t, c.method, c.path, c.body, c.role, c.sub, c.header); got != c.want { + t.Errorf("%s: %d, want %d", c.name, got, c.want) + } + } + + // the lists cover the caller's branches only + for _, c := range []struct { + name, path, role, sub, header string + wantA, wantB bool + idA, idB string + }{ + {"invoices, header A", "/api/v1/invoices?limit=200", "sales", "u-a", A, true, false, f.invA.String(), f.invB.String()}, + {"invoices, no header", "/api/v1/invoices?limit=200", "sales", "u-a", "", true, false, f.invA.String(), f.invB.String()}, + {"invoices, a user with no grants", "/api/v1/invoices?limit=200", "sales", "u-none", "", false, false, f.invA.String(), f.invB.String()}, + {"invoices, admin", "/api/v1/invoices?limit=200&customer_id=" + f.docCust.String(), "admin", "boss", "", true, true, f.invA.String(), f.invB.String()}, + {"credit memos, header A", "/api/v1/credit-memos?limit=200", "finance", "u-a", A, true, false, f.memoA.String(), f.memoB.String()}, + {"credit memos, no header", "/api/v1/credit-memos?limit=200", "finance", "u-a", "", true, false, f.memoA.String(), f.memoB.String()}, + {"credit memos, a user with no grants", "/api/v1/credit-memos?limit=200", "finance", "u-none", "", false, false, f.memoA.String(), f.memoB.String()}, + {"credit memos, admin", "/api/v1/credit-memos?limit=200&customer_id=" + f.docCust.String(), "admin", "boss", "", true, true, f.memoA.String(), f.memoB.String()}, + } { + status, body := f.callBody(t, "GET", c.path, "", c.role, c.sub, c.header) + if status != http.StatusOK { + t.Errorf("%s: %d, want 200", c.name, status) + continue + } + if got := strings.Contains(string(body), c.idA); got != c.wantA { + t.Errorf("%s: branch A's record present = %v, want %v", c.name, got, c.wantA) + } + if got := strings.Contains(string(body), c.idB); got != c.wantB { + t.Errorf("%s: branch B's record present = %v, want %v", c.name, got, c.wantB) + } + } +} diff --git a/core/internal/invoice/credit_build_test.go b/core/internal/invoice/credit_build_test.go new file mode 100644 index 00000000..4c8b8dc0 --- /dev/null +++ b/core/internal/invoice/credit_build_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice + +import ( + "math/rand" + "testing" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/google/uuid" +) + +// RULE (ADR 0005 2.4 applied to credits): the share of a line's amount for r of +// the billed units telescopes: pieces taken in any order and size, each as the +// difference of two cumulative shares, sum to the whole to the cent, and never +// run backwards. +func TestSharePiecesSumToTheWhole(t *testing.T) { + rng := rand.New(rand.NewSource(7)) + for trial := 0; trial < 500; trial++ { + whole := int64(rng.Intn(100000) + 1) // cents + billed := int64(rng.Intn(200000) + 10000) // scale 4 + var done, got int64 // quantity credited so far, cents credited so far + for done < billed { + piece := int64(rng.Intn(int(billed-done))) + 1 + cum := share(whole, done+piece, billed) + if cum < got { + t.Fatalf("whole %d billed %d: the cumulative share ran backwards (%d after %d)", whole, billed, cum, got) + } + got, done = cum, done+piece + } + if got != whole { + t.Fatalf("whole %d billed %d: the pieces sum to %d", whole, billed, got) + } + } + if share(1000, 0, 10) != 0 || share(1000, 10, 10) != 1000 || share(-1000, 5, 10) != -500 { + t.Error("share edge cases") + } +} + +func creditLineOf(total int64) InvoiceLine { + qty := httpx.Quantity(100000) // 10 + one := salesdoc.One + price := httpx.Price(55000) + lt := httpx.Cents(total) + uom := "PCS" + var l InvoiceLine + l.ID = uuid.New() + l.LineType = salesdoc.LineProduct + l.Quantity, l.UOM, l.PriceUOM, l.UOMQty, l.PriceUOMQty = &qty, &uom, &uom, &one, &one + l.UnitPrice, l.LineTotal, l.Taxable = &price, <, true + l.Description = "line" + return l +} + +// RULE: a credit line returning part of an invoice line takes its amount from +// the line's charged total in proportion, the earlier credits telescoped, and a +// line cannot credit more than it billed. +func TestCreditFromInvoiceLine(t *testing.T) { + src := creditLineOf(5500) + d := &CreditLineInput{InvoiceLineID: &src.ID, Quantity: -30000} // 3 of 10 + cl, err := creditFromInvoiceLine("lines[0]", d, &src, Credited{}, Credited{}) + if err != nil { + t.Fatal(err) + } + if got := int64(*cl.LineTotal); got != -1650 || *cl.Quantity != -30000 { + t.Errorf("3 of 10 on 5500 = %d (%v), want -1650", got, *cl.Quantity) + } + // 3 earlier credited (1650), 3 more: 3300 cumulative, a piece of 1650 + cl, err = creditFromInvoiceLine("lines[0]", d, &src, Credited{Quantity: 30000, TotalCents: 1650}, Credited{Quantity: 30000}) + if err != nil || int64(*cl.LineTotal) != -1650 { + t.Fatalf("second piece = %v %v", cl.LineTotal, err) + } + // the last 4 take the remainder to the cent + last := &CreditLineInput{InvoiceLineID: &src.ID, Quantity: -40000} + cl, err = creditFromInvoiceLine("lines[0]", last, &src, Credited{Quantity: 60000, TotalCents: 3300}, Credited{Quantity: 60000}) + if err != nil || int64(*cl.LineTotal) != -2200 { + t.Fatalf("last piece = %v %v", cl.LineTotal, err) + } + // over the line: exceeds_billed, counting the drafts + _, err = creditFromInvoiceLine("lines[0]", d, &src, Credited{Quantity: 60000, TotalCents: 3300}, Credited{Quantity: 80000}) + if err == nil { + t.Fatal("crediting 3 with 8 already counted of 10 was accepted") + } + // restock without a product is refused + rs := &CreditLineInput{InvoiceLineID: &src.ID, Quantity: -10000, Restock: true} + if _, err = creditFromInvoiceLine("lines[0]", rs, &src, Credited{}, Credited{}); err == nil { + t.Error("restocking a non stock line was accepted") + } +} + +// RULE (ADR 0005 3): the cost that comes back on a restocked line is the +// invoice line's own cost in proportion, never more than that line relieved. +func TestRestockCostComesBackAtTheOriginalCost(t *testing.T) { + src := creditLineOf(5500) + pid := uuid.New() + src.ProductID = &pid + src.CostCents = 3250 + unit := httpx.Price(32500) + src.UnitCost = &unit + d := &CreditLineInput{InvoiceLineID: &src.ID, Quantity: -40000, Restock: true} + cl, err := creditFromInvoiceLine("lines[0]", d, &src, Credited{}, Credited{}) + if err != nil || int64(cl.CostCents) != -1300 || !cl.Restock { + t.Fatalf("restock cost = %v %v, want -1300", cl.CostCents, err) + } + // 9 earlier restocked at a cost of 3250 less 130: only what is left comes back + big := &CreditLineInput{InvoiceLineID: &src.ID, Quantity: -10000, Restock: true} + cl, err = creditFromInvoiceLine("lines[0]", big, &src, Credited{Quantity: 90000, TotalCents: 4950, CostCents: 3200}, Credited{Quantity: 90000}) + if err != nil || int64(cl.CostCents) != -50 { + t.Errorf("last restock = %v %v, want the remaining -50 not -325", cl.CostCents, err) + } +} diff --git a/core/internal/invoice/credit_test.go b/core/internal/invoice/credit_test.go index 292c4b00..a0ff58c6 100644 --- a/core/internal/invoice/credit_test.go +++ b/core/internal/invoice/credit_test.go @@ -523,11 +523,12 @@ func TestCreditMemoWithNoInvoice(t *testing.T) { {"line_type": "product", "description": "Freight allowance", "quantity": "-1", "uom": "EA", "unit_price_ten_thousandths": 100000}, {"line_type": "text", "description": "per the account manager"}, }} - r := f.do("POST", "/api/v1/credit-memos", body, "Idempotency-Key", "cm-once") + key := "cm-once-" + uuid.NewString() + r := f.do("POST", "/api/v1/credit-memos", body, "Idempotency-Key", key) if r.status != 201 { t.Fatalf("create = %d: %s", r.status, r.raw) } - if again := f.do("POST", "/api/v1/credit-memos", body, "Idempotency-Key", "cm-once"); again.header.Get("Idempotency-Replayed") != "true" || str(t, again.body, "id") != str(t, r.body, "id") { + if again := f.do("POST", "/api/v1/credit-memos", body, "Idempotency-Key", key); again.header.Get("Idempotency-Replayed") != "true" || str(t, again.body, "id") != str(t, r.body, "id") { t.Errorf("the replay = %d replayed %q", again.status, again.header.Get("Idempotency-Replayed")) } if n := countOf(t, db, `SELECT count(*) FROM credit_memos WHERE customer_id = $1`, f.customerID); n != 1 { diff --git a/core/internal/invoice/migration097_test.go b/core/internal/invoice/migration097_test.go new file mode 100644 index 00000000..09662585 --- /dev/null +++ b/core/internal/invoice/migration097_test.go @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice_test + +// Migration 097 on rows that exist (recipe step 3, ADR 0005 section 13, C2-3): +// the schema up to 096 is built in a scratch database, invoices and credit memos +// are written in the shape the base left them (OVERDUE statuses, free text +// terms, a timestamp due date, PENDING and APPLIED credit memos), then 097 is +// applied and each backfill is read back: the gapless numbers in (created_at, +// id) order and the counter past them, OVERDUE mapped by the payments recorded, +// the due date to a branch date, the terms by id with the discount snapshot, the +// text columns dropped, the void columns, the credit memo statuses, totals, +// currency, branch, numbers and the ADJUST line. The down file then rolls the +// shape back and the migration applies again. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +func scratch097(t *testing.T) (*pgx.Conn, *[]string) { + t.Helper() + db := testutil.RequireDB(t) + base, err := url.Parse(db.Pool.Config().ConnString()) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + admin, err := pgx.Connect(ctx, base.String()) + if err != nil { + t.Fatal(err) + } + suffix := make([]byte, 6) + if _, err := rand.Read(suffix); err != nil { + admin.Close(ctx) + t.Skipf("cannot create a scratch database: %v", err) + } + name := "gv1_c23mig_" + hex.EncodeToString(suffix) + if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, name)); err != nil { + admin.Close(ctx) + t.Skipf("cannot create a scratch database: %v", err) + } + t.Cleanup(func() { + _, _ = admin.Exec(context.Background(), fmt.Sprintf(`DROP DATABASE %q WITH (FORCE)`, name)) + admin.Close(ctx) + }) + scratch := *base + scratch.Path = "/" + name + cfg, err := pgx.ParseConfig(scratch.String()) + if err != nil { + t.Fatal(err) + } + var notices []string + cfg.OnNotice = func(_ *pgconn.PgConn, n *pgconn.Notice) { notices = append(notices, n.Message) } + conn, err := pgx.ConnectConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close(context.Background()) }) + return conn, ¬ices +} + +func files097(t *testing.T) (before []string, target string) { + t.Helper() + all, err := filepath.Glob("../../migrations/*.sql") + if err != nil || len(all) == 0 { + t.Fatalf("no migrations found: %v", err) + } + sort.Strings(all) + for _, f := range all { + base := filepath.Base(f) + switch { + case strings.HasSuffix(base, "_down.sql"): + case strings.HasPrefix(base, "097_"): + target = f + case base < "097_": + before = append(before, f) + } + } + if target == "" { + t.Fatal("migration 097 not found") + } + return before, target +} + +func apply097(t *testing.T, conn *pgx.Conn, file string) { + t.Helper() + sql, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(context.Background(), string(sql)); err != nil { + t.Fatalf("applying %s: %v", filepath.Base(file), err) + } +} + +func scalar[T any](t *testing.T, conn *pgx.Conn, sql string, args ...any) T { + t.Helper() + var v T + if err := conn.QueryRow(context.Background(), sql, args...).Scan(&v); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return v +} + +const ( + m97Cust = "00000000-0000-0000-0000-0000000097c1" + m97CustCAD = "00000000-0000-0000-0000-0000000097c2" + m97Prod = "00000000-0000-0000-0000-0000000097d1" + m97OLine = "00000000-0000-0000-0000-0000000097b1" + m97Order = "00000000-0000-0000-0000-0000000097a1" + m97Unpaid = "00000000-0000-0000-0000-0000000097e1" + m97Over = "00000000-0000-0000-0000-0000000097e2" // OVERDUE with a payment recorded + m97Late = "00000000-0000-0000-0000-0000000097e3" // OVERDUE with none + m97Paid = "00000000-0000-0000-0000-0000000097e4" + m97Void = "00000000-0000-0000-0000-0000000097e5" + m97Own = "00000000-0000-0000-0000-0000000097e6" // free text terms that match nothing + m97Pending = "00000000-0000-0000-0000-0000000097f1" + m97Applied = "00000000-0000-0000-0000-0000000097f2" // names an invoice + m97Loose = "00000000-0000-0000-0000-0000000097f3" // names none + m97VoidCM = "00000000-0000-0000-0000-0000000097f4" +) + +func seed097(t *testing.T, conn *pgx.Conn) { + t.Helper() + branch := `(SELECT id FROM locations WHERE type = 'BRANCH' LIMIT 1)` + sql := fmt.Sprintf(` + UPDATE payment_terms SET discount_percent = 2, discount_days = 10 WHERE code = 'NET30'; + UPDATE system_settings SET value = 'USD' WHERE key = 'currency.default'; + INSERT INTO customers (id, name, account_number, primary_branch_id, currency) VALUES + ('%[1]s','Legacy Co','M97A',%[2]s,NULL), ('%[3]s','Canadian Co','M97B',%[2]s,'CAD'); + INSERT INTO products (id, sku, description, uom_primary, base_price) VALUES ('%[4]s','M97-STUD','stud','PCS',5.5); + INSERT INTO orders (id, customer_id, status, total_amount, branch_id, currency, delivery_type, created_at) + VALUES ('%[5]s','%[1]s','FULFILLED',55,%[2]s,'USD','PICKUP', now() - interval '10 days'); + INSERT INTO order_lines (id, order_id, product_id, quantity, unit_price, description, uom, price_uom, uom_qty, price_uom_qty, line_total, priced_unit_price, override_reason, price_adjusted_by, position, created_at) + VALUES ('%[6]s','%[5]s','%[4]s',10,5.5,'stud','PCS','PCS',1,1,55,5.25,'matched a quote','amy',0,now()); + INSERT INTO invoices (id, order_id, customer_id, status, total_amount, subtotal, tax_amount, payment_terms, due_date, branch_id, created_at, updated_at) VALUES + ('%[7]s','%[5]s','%[1]s','UNPAID',100,100,0,'NET30', now() + interval '20 days', %[2]s, now() - interval '9 days', now()), + ('%[8]s',NULL,'%[1]s','OVERDUE',100,100,0,'Net 45', now() - interval '10 days', %[2]s, now() - interval '8 days', now()), + ('%[9]s',NULL,'%[1]s','OVERDUE',100,100,0,'NET30', now() - interval '10 days', %[2]s, now() - interval '7 days', now()), + ('%[10]s',NULL,'%[1]s','PAID',100,100,0,'COD', now() - interval '30 days', %[2]s, now() - interval '6 days', now()), + ('%[11]s',NULL,'%[1]s','VOID',100,100,0,'NET30', NULL, %[2]s, now() - interval '5 days', now()), + ('%[12]s',NULL,'%[3]s','UNPAID',100,100,0,'2%% 10 net 45 eom', now() + interval '5 days', %[2]s, now() - interval '4 days', now()); + INSERT INTO invoice_lines (invoice_id, order_line_id, product_id, quantity, price_each, created_at, description, uom, price_uom, uom_qty, price_uom_qty, unit_price, line_total) + VALUES ('%[7]s','%[6]s','%[4]s',10,5.5,now(),'stud','PCS','PCS',1,1,5.5,55); + INSERT INTO payments (invoice_id, amount, method, reference) VALUES ('%[8]s', 40, 'CHECK', 'P1'); + INSERT INTO credit_memos (id, invoice_id, customer_id, amount, reason, status, created_at, applied_at) VALUES + ('%[13]s',NULL,'%[1]s',12.5,'pending memo','PENDING', now() - interval '3 days', NULL), + ('%[14]s','%[7]s','%[1]s',20,'applied to an invoice','APPLIED', now() - interval '2 days', now()), + ('%[15]s',NULL,'%[3]s',30,'applied, no invoice','APPLIED', now() - interval '1 days', now()), + ('%[16]s',NULL,'%[1]s',5,'void memo','VOID', now() - interval '12 hours', NULL); + `, m97Cust, branch, m97CustCAD, m97Prod, m97Order, m97OLine, m97Unpaid, m97Over, m97Late, m97Paid, m97Void, m97Own, m97Pending, m97Applied, m97Loose, m97VoidCM) + if _, err := conn.Exec(context.Background(), sql); err != nil { + t.Fatalf("legacy rows: %v", err) + } +} + +func TestMigration097_BackfillsRowsThatExist(t *testing.T) { + conn, notices := scratch097(t) + before, target := files097(t) + for _, f := range before { + apply097(t, conn, f) + } + seed097(t, conn) + apply097(t, conn, target) + _ = notices + + // the numbers: IN- in (created_at, id) order, the counter set past them + var numbers []string + rows, err := conn.Query(context.Background(), `SELECT number FROM invoices ORDER BY created_at, id`) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var n string + _ = rows.Scan(&n) + numbers = append(numbers, n) + } + rows.Close() + if want := "[IN-000001 IN-000002 IN-000003 IN-000004 IN-000005 IN-000006]"; fmt.Sprint(numbers) != want { + t.Errorf("invoice numbers = %v, want %s in creation order", numbers, want) + } + if got := scalar[int64](t, conn, `SELECT next_value FROM document_counters WHERE series = 'invoice'`); got != 7 { + t.Errorf("invoice counter = %d, want 7", got) + } + if got := scalar[string](t, conn, `SELECT invoice_next_number()`); got != "IN-000007" { + t.Errorf("the next mint = %s, want IN-000007", got) + } + + // OVERDUE is gone: PARTIAL where a payment is recorded, else UNPAID + for id, want := range map[string]string{m97Unpaid: "UNPAID", m97Over: "PARTIAL", m97Late: "UNPAID", m97Paid: "PAID", m97Void: "VOID"} { + if got := scalar[string](t, conn, `SELECT status FROM invoices WHERE id = $1`, id); got != want { + t.Errorf("invoice %s status = %s, want %s", id[len(id)-2:], got, want) + } + } + if _, err := conn.Exec(context.Background(), `UPDATE invoices SET status = 'OVERDUE' WHERE id = $1`, m97Unpaid); err == nil { + t.Error("the status CHECK still accepts OVERDUE") + } + + // due_date is a date in the branch's calendar; the void row has none + if got := scalar[string](t, conn, `SELECT pg_typeof(due_date)::text FROM invoices WHERE id = $1`, m97Unpaid); got != "date" { + t.Errorf("due_date type = %s, want date", got) + } + if got := scalar[*string](t, conn, `SELECT due_date::text FROM invoices WHERE id = $1`, m97Void); got != nil { + t.Errorf("a null due date became %v", *got) + } + + // terms by id: the legacy text mapped without changing meaning, the discount snapshot + terms := func(id string) string { + return scalar[string](t, conn, `SELECT t.code FROM invoices i JOIN payment_terms t ON t.id = i.payment_terms_id WHERE i.id = $1`, id) + } + if terms(m97Unpaid) != "NET30" || terms(m97Over) != "NET45" || terms(m97Paid) != "COD" { + t.Errorf("terms = %s %s %s, want NET30 NET45 COD", terms(m97Unpaid), terms(m97Over), terms(m97Paid)) + } + if own := terms(m97Own); own == "NET30" || own == "NET45" { + t.Errorf("text that matches nothing took %s, want a term of its own", own) + } + if got := scalar[string](t, conn, `SELECT discount_percent::text || ' ' || (discount_due_date - invoice_date)::text FROM invoices WHERE id = $1`, m97Unpaid); got != "2.0000 10" { + t.Errorf("discount snapshot = %q, want 2.0000 10 (NET30 carried a 2 percent 10 day discount)", got) + } + if got := scalar[*string](t, conn, `SELECT discount_percent::text FROM invoices WHERE id = $1`, m97Paid); got != nil { + t.Errorf("COD took a discount %v", *got) + } + for _, c := range []struct{ table, col string }{{"invoices", "payment_terms"}, {"customers", "payment_terms"}} { + if n := scalar[int](t, conn, `SELECT count(*) FROM information_schema.columns WHERE table_name = $1 AND column_name = $2`, c.table, c.col); n != 0 { + t.Errorf("%s.%s survived the migration", c.table, c.col) + } + } + + // void columns, revision, the price audit trail on the invoice line + if got := scalar[bool](t, conn, `SELECT voided_at IS NOT NULL AND voided_on IS NOT NULL AND revision = 1 FROM invoices WHERE id = $1`, m97Void); !got { + t.Error("the void invoice has no void columns") + } + if got := scalar[string](t, conn, `SELECT priced_unit_price::text || '/' || override_reason || '/' || price_adjusted_by FROM invoice_lines WHERE invoice_id = $1`, m97Unpaid); got != "5.2500/matched a quote/amy" { + t.Errorf("invoice line audit trail = %q, want it copied from the order line", got) + } + + // credit memos + cm := func(id string) string { + return scalar[string](t, conn, `SELECT status || '|' || COALESCE(number, '-') || '|' || currency || '|' || total_amount::text || '|' || subtotal::text || '|' || reason_code FROM credit_memos WHERE id = $1`, id) + } + for id, want := range map[string]string{ + m97Pending: "DRAFT|-|USD|-12.50|-12.50|OTHER", + m97Applied: "APPLIED|CM-000001|USD|-20.00|-20.00|OTHER", + m97Loose: "OPEN|CM-000002|CAD|-30.00|-30.00|OTHER", + m97VoidCM: "VOID|CM-000003|USD|-5.00|-5.00|OTHER", + } { + if got := cm(id); got != want { + t.Errorf("credit memo %s = %q, want %q", id[len(id)-2:], got, want) + } + } + if got := scalar[int64](t, conn, `SELECT next_value FROM document_counters WHERE series = 'credit_memo'`); got != 4 { + t.Errorf("credit memo counter = %d, want 4 (the draft took no number)", got) + } + if got := scalar[bool](t, conn, `SELECT branch_id = (SELECT branch_id FROM invoices WHERE id = $2) AND memo_date IS NOT NULL FROM credit_memos WHERE id = $1`, m97Applied, m97Unpaid); !got { + t.Error("an invoice's credit memo did not take its branch") + } + if got := scalar[bool](t, conn, `SELECT voided_at IS NOT NULL AND voided_on IS NOT NULL FROM credit_memos WHERE id = $1`, m97VoidCM); !got { + t.Error("the void credit memo has no void columns") + } + // one ADJUST charge line each: quantity -1 EA, unit price the amount, untaxed + if got := scalar[string](t, conn, `SELECT l.line_type || '|' || cc.code || '|' || l.quantity::text || '|' || l.uom || '|' || l.unit_price::text || '|' || l.line_total::text || '|' || l.taxable::text + FROM credit_memo_lines l JOIN charge_codes cc ON cc.id = l.charge_code_id WHERE l.credit_memo_id = $1`, m97Applied); got != "CHARGE|ADJUST|-1.0000|EA|20.0000|-20.00|false" { + t.Errorf("the migrated credit memo line = %q", got) + } + if n := scalar[int](t, conn, `SELECT count(*) FROM credit_memo_lines`); n != 4 { + t.Errorf("%d credit memo lines, want one per memo (4)", n) + } + // a draft carries no number, a posted memo does (the CHECK) + if _, err := conn.Exec(context.Background(), `UPDATE credit_memos SET number = 'CM-9' WHERE id = $1`, m97Pending); err == nil { + t.Error("a draft was given a number") + } + if _, err := conn.Exec(context.Background(), `UPDATE credit_memos SET number = NULL WHERE id = $1`, m97Applied); err == nil { + t.Error("a posted memo lost its number") + } + + // the down file rolls the shape back; the migration then applies again + down, err := os.ReadFile("../../migrations/down/097_invoices_wire_contract_down.sql") + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(context.Background(), string(down)); err != nil { + t.Fatalf("down: %v", err) + } + for _, c := range []struct{ table, col string }{{"invoices", "number"}, {"credit_memos", "number"}, {"invoices", "payment_terms_id"}} { + if n := scalar[int](t, conn, `SELECT count(*) FROM information_schema.columns WHERE table_name = $1 AND column_name = $2`, c.table, c.col); n != 0 { + t.Errorf("%s.%s survived the down file", c.table, c.col) + } + } + if got := scalar[string](t, conn, `SELECT payment_terms FROM invoices WHERE id = $1`, m97Over); got != "NET45" { + t.Errorf("the down file restored the terms text as %q, want NET45", got) + } + if got := scalar[string](t, conn, `SELECT status FROM credit_memos WHERE id = $1`, m97Pending); got != "PENDING" { + t.Errorf("the down file restored a draft as %s, want PENDING", got) + } + apply097(t, conn, target) + if got := scalar[int64](t, conn, `SELECT count(*) FROM invoices WHERE number LIKE 'IN-%'`); got != 6 { + t.Errorf("after down and up again %d invoices are numbered, want 6", got) + } +} diff --git a/core/internal/invoice/service.go b/core/internal/invoice/service.go index ac2d4206..3d6e31d3 100644 --- a/core/internal/invoice/service.go +++ b/core/internal/invoice/service.go @@ -71,6 +71,7 @@ type TxRunner interface { } type Service struct { + tx TxRunner repo Repository gl *gl.Service account account.Service @@ -93,6 +94,10 @@ func (s *Service) WithAuditLog(l *audit.Logger) *Service { s.auditLog = l; retur // WithOutbox sets the event writer. func (s *Service) WithOutbox(e EventRecorder) *Service { s.events = e; return s } +// WithTxRunner replaces the database as the transaction runner (a test gates +// transactions with it; serve leaves the database). +func (s *Service) WithTxRunner(tx TxRunner) *Service { s.tx = tx; return s } + // WithBranchGuard sets the payload branch rule for the write routes. func (s *Service) WithBranchGuard(g BranchGuard) *Service { s.branches = g; return s } @@ -145,6 +150,9 @@ func (s *Service) store() (Store, error) { } func (s *Service) inTx(ctx context.Context, fn func(ctx context.Context) error) error { + if s.tx != nil { + return s.tx.RunInTx(ctx, fn) + } if s.db == nil { return fn(ctx) } diff --git a/core/internal/invoice/tx_test.go b/core/internal/invoice/tx_test.go new file mode 100644 index 00000000..bade6be7 --- /dev/null +++ b/core/internal/invoice/tx_test.go @@ -0,0 +1,440 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package invoice_test + +// The transaction proofs of the recipe (step 2) for every write of the module: +// a failing event write rolls the act back (no row, no entry, no stock move, no +// subledger move, no audit row); three contenders at pool size 4 finish with one +// winner where they race for one document; and the gated saturation test holds +// as many contenders as the pool has connections INSIDE their transactions +// before any statement runs, so a statement that reached for the pool instead of +// the transaction would starve the pool and the deadline would fire. + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/invoice" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +// flaky fails the event types it is told to. +type flaky struct { + inner invoice.EventRecorder + mu sync.Mutex + fail map[string]bool +} + +func (f *flaky) set(typ string, fail bool) { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail == nil { + f.fail = map[string]bool{} + } + f.fail[typ] = fail +} + +func (f *flaky) Write(ctx context.Context, ev outbox.Event) error { + f.mu.Lock() + failing := f.fail[ev.Type] + f.mu.Unlock() + if failing { + return fmt.Errorf("outbox insert failed") + } + return f.inner.Write(ctx, ev) +} + +// snapshot is the observable state a rolled back act must leave untouched. +type snapshot struct { + stock string + balance int64 + entries, auditRows int64 + invoiceRev, memoRev int64 + invoiceStatus, memoStatus string +} + +func (f *fixture) snap(invoiceID, memoID string) snapshot { + f.t.Helper() + s := snapshot{stock: f.stock(), balance: f.balance()} + s.entries = countOf(f.t, f.db, `SELECT count(*) FROM gl_journal_entries e WHERE e.source_ref_id IN (SELECT id FROM invoices WHERE customer_id = $1 UNION SELECT id FROM credit_memos WHERE customer_id = $1) + OR e.reverses_entry_id IN (SELECT gl_entry_id FROM invoices WHERE customer_id = $1 UNION SELECT gl_entry_id FROM credit_memos WHERE customer_id = $1)`, f.customerID) + s.auditRows = countOf(f.t, f.db, `SELECT count(*) FROM audit_log WHERE entity_type IN ('invoice', 'credit_memo') AND (entity_id IN (SELECT id FROM invoices WHERE customer_id = $1) OR entity_id IN (SELECT id FROM credit_memos WHERE customer_id = $1))`, f.customerID) + if invoiceID != "" { + _ = f.db.Pool.QueryRow(context.Background(), `SELECT revision, status FROM invoices WHERE id = $1`, invoiceID).Scan(&s.invoiceRev, &s.invoiceStatus) + } + if memoID != "" { + _ = f.db.Pool.QueryRow(context.Background(), `SELECT revision, status FROM credit_memos WHERE id = $1`, memoID).Scan(&s.memoRev, &s.memoStatus) + } + return s +} + +// RULE (ADR 0003 section 3, ADR 0005 14.2): a failing event write rolls the whole +// act back, for each kind of write the module makes. +func TestFailingEventWriteRollsEachActBack(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + events := &flaky{inner: outbox.NewWriter(db, "")} + f := newFixture(t, db, func(f *fixture) { f.invoices.WithOutbox(events) }) + invID, orderID := f.invoice("10") + line := f.firstLineID(invID) + + // credit_memo.created: no memo, no lines + events.set("credit_memo.created", true) + before := f.snap(invID, "") + if r := f.do("POST", "/api/v1/credit-memos", f.creditBody(invID, returnLine(line, "-4", true))); r.status < 500 { + t.Fatalf("create with a failing event = %d, want 5xx", r.status) + } + if n := countOf(t, db, `SELECT count(*) FROM credit_memos WHERE customer_id = $1`, f.customerID); n != 0 { + t.Errorf("%d credit memos after a failed create", n) + } + if after := f.snap(invID, ""); after != before { + t.Errorf("a failed create changed the state: %+v -> %+v", before, after) + } + events.set("credit_memo.created", false) + cm := f.createCredit(f.creditBody(invID, returnLine(line, "-4", true))) + id := str(t, cm.body, "id") + + // credit_memo.updated: the draft is as it was + events.set("credit_memo.updated", true) + edit := f.creditBody(invID, returnLine(line, "-2", false)) + if r := f.do("PUT", "/api/v1/credit-memos/"+id, edit, "If-Match", `"1"`); r.status < 500 { + t.Fatalf("update with a failing event = %d, want 5xx", r.status) + } + got := f.do("GET", "/api/v1/credit-memos/"+id, nil) + if rev(t, got) != 1 || num(t, got.body, "subtotal_cents") != -2200 { + t.Errorf("a failed update changed the draft: revision %d subtotal %v", rev(t, got), got.body["subtotal_cents"]) + } + events.set("credit_memo.updated", false) + + // credit_memo.posted: still a draft, no number, no entry, no restock, no balance move + events.set("credit_memo.posted", true) + before = f.snap(invID, id) + if r := f.postCredit(id, 1); r.status < 500 { + t.Fatalf("post with a failing event = %d, want 5xx", r.status) + } + if after := f.snap(invID, id); after != before { + t.Errorf("a failed post changed the state: %+v -> %+v", before, after) + } + if got := f.do("GET", "/api/v1/credit-memos/"+id, nil); got.body["number"] != nil || str(t, got.body, "status") != "draft" { + t.Errorf("after a failed post the memo is %v %v", got.body["status"], got.body["number"]) + } + events.set("credit_memo.posted", false) + if r := f.postCredit(id, 1); r.status != 200 { + t.Fatalf("post = %d: %s", r.status, r.raw) + } + + // credit_memo.voided on a posted, restocking memo: still open, stock as posted + events.set("credit_memo.voided", true) + before = f.snap(invID, id) + if r := f.do("POST", "/api/v1/credit-memos/"+id+"/transitions", map[string]any{"to": "void", "revision": 2, "reason": "x"}); r.status < 500 { + t.Fatalf("void with a failing event = %d, want 5xx", r.status) + } + if after := f.snap(invID, id); after != before { + t.Errorf("a failed credit memo void changed the state: %+v -> %+v", before, after) + } + events.set("credit_memo.voided", false) + if r := f.do("POST", "/api/v1/credit-memos/"+id+"/transitions", map[string]any{"to": "void", "revision": 2, "reason": "x"}); r.status != 200 { + t.Fatalf("void = %d: %s", r.status, r.raw) + } + + // invoice.voided: unpaid, nothing reversed, the order still fulfilled + events.set("invoice.voided", true) + before = f.snap(invID, "") + if r := f.voidInvoice(invID, rev(t, f.getInvoice(invID)), "x"); r.status < 500 { + t.Fatalf("invoice void with a failing event = %d, want 5xx", r.status) + } + if after := f.snap(invID, ""); after != before { + t.Errorf("a failed invoice void changed the state: %+v -> %+v", before, after) + } + if o := f.do("GET", "/api/v1/orders/"+orderID, nil); str(t, o.body, "status") != "fulfilled" { + t.Errorf("a failed invoice void reopened the order: %v", o.body["status"]) + } + if n, _ := f.reversalLegs(invID); n != 0 { + t.Errorf("%d reversal entries after a failed void", n) + } + events.set("invoice.voided", false) + // the same act now succeeds: nothing was left half done + if r := f.voidInvoice(invID, rev(t, f.getInvoice(invID)), "x"); r.status != 200 { + t.Fatalf("void = %d: %s", r.status, r.raw) + } +} + +// three racers, one document: exactly one wins the post, one wins the void. +func TestThreeContendersAtPool4(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDBMaxConns(t, 4) + f := newFixture(t, db) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // 1. three drafts on one invoice posted at once: each a distinct number, the + // invoice credited exactly once per unit, no deadlock + invID, _ := f.invoice("9") + line := f.firstLineID(invID) + var drafts []string + for i := 0; i < 3; i++ { + drafts = append(drafts, str(t, f.createCredit(f.creditBody(invID, returnLine(line, "-3", true))).body, "id")) + } + var wg sync.WaitGroup + numbers := make(chan string, 3) + for _, d := range drafts { + wg.Add(1) + go func() { + defer wg.Done() + r := f.postCredit(d, 1) + if r.status != 200 { + t.Errorf("concurrent post = %d: %s", r.status, r.raw) + return + } + numbers <- str(t, r.body, "number") + }() + } + wg.Wait() + close(numbers) + seen := map[string]bool{} + for n := range numbers { + if seen[n] { + t.Errorf("number %s minted twice", n) + } + seen[n] = true + } + if len(seen) != 3 { + t.Errorf("%d numbers, want 3 distinct", len(seen)) + } + if f.stock() != "100.0000/0.0000" { + t.Errorf("stock after three restocks of 3 = %s, want 100 (9 billed, 9 returned)", f.stock()) + } + if f.balance() != 0 { + t.Errorf("balance after crediting the whole invoice = %d, want 0", f.balance()) + } + if n := countOf(t, db, `SELECT count(*) FROM events_outbox WHERE type = 'credit_memo.posted' AND entity_id = ANY($1)`, toUUIDs(drafts)); n != 3 { + t.Errorf("%d credit_memo.posted events, want 3", n) + } + + // 2. three racers on one invoice's revision: exactly one void wins + inv2, _ := f.invoice("1") + r2 := rev(t, f.getInvoice(inv2)) + var winners, losers int32 + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + switch r := f.voidInvoice(inv2, r2, "race"); r.status { + case 200: + atomic.AddInt32(&winners, 1) + case http.StatusConflict: + atomic.AddInt32(&losers, 1) + default: + t.Errorf("racing void = %d: %s", r.status, r.raw) + } + }() + } + wg.Wait() + if winners != 1 || losers != 2 { + t.Errorf("winners %d losers %d, want exactly one winner and two refused", winners, losers) + } + if n := countOf(t, db, `SELECT count(*) FROM events_outbox WHERE type = 'invoice.voided' AND entity_id = $1`, inv2); n != 1 { + t.Errorf("%d invoice.voided events, want 1", n) + } + + // 3. mixed writers (voids of distinct invoices, credit memo creates) while a + // reader pages the list on the same four connections + var invoices []string + for i := 0; i < 3; i++ { + id, _ := f.invoice("1") + invoices = append(invoices, id) + } + stop := make(chan struct{}) + readerDone := make(chan error, 1) + go func() { + for { + select { + case <-stop: + readerDone <- nil + return + default: + } + if _, _, _, err := f.invoices.ListInvoices(ctx, invoice.ListFilter{Limit: 5, CustomerID: &f.customerID}, true); err != nil { + readerDone <- err + return + } + } + }() + for _, id := range invoices { + wg.Add(2) + go func() { + defer wg.Done() + if r := f.voidInvoice(id, rev(t, f.getInvoice(id)), "mixed"); r.status != 200 { + t.Errorf("mixed void = %d: %s", r.status, r.raw) + } + }() + go func() { + defer wg.Done() + body := map[string]any{"customer_id": f.customerID.String(), "reason_code": "price_adjustment", "reason": "mixed", + "lines": []map[string]any{{"line_type": "charge", "charge_code": "ADJUST", "quantity": "-1", "unit_price_ten_thousandths": 1000}}} + if r := f.do("POST", "/api/v1/credit-memos", body); r.status != 201 { + t.Errorf("mixed create = %d: %s", r.status, r.raw) + } + }() + } + wg.Wait() + close(stop) + if err := <-readerDone; err != nil { + t.Errorf("reader: %v", err) + } + if ctx.Err() != nil { + t.Fatal("the contenders did not finish inside the deadline: a transaction waited on a second pool connection") + } +} + +func toUUIDs(ids []string) []uuid.UUID { + out := make([]uuid.UUID, len(ids)) + for i, s := range ids { + out[i] = uuid.MustParse(s) + } + return out +} + +// gatedTx is a TxRunner that makes the first `want` transactions meet inside +// their transactions before any of them runs a statement. +type gatedTx struct { + db *database.DB + want int32 + entered int32 + gate sync.WaitGroup +} + +func newGatedTx(db *database.DB, want int) *gatedTx { + g := &gatedTx{db: db, want: int32(want)} + g.gate.Add(want) + return g +} + +func (g *gatedTx) RunInTx(ctx context.Context, fn func(ctx context.Context) error) error { + return g.db.RunInTx(ctx, func(txCtx context.Context) error { + if atomic.AddInt32(&g.entered, 1) <= g.want { + g.gate.Done() + g.gate.Wait() + } + return fn(txCtx) + }) +} + +// Saturation: as many contenders as the pool has connections, held inside their +// transactions at a gate, for each kind of write. A statement that reached for a +// second connection from the pool would leave four holders each waiting for a +// fifth that never frees, and the deadline would fire. +func TestSaturationNeedsNoSecondConnection(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDBMaxConns(t, 4) + f := newFixture(t, db) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + const contenders = 4 + // the documents each phase works on, one per contender, prepared on a roomy path + type doc struct{ invoice, line string } + var docs []doc + for i := 0; i < 5*contenders; i++ { + id, _ := f.invoice("1") + docs = append(docs, doc{id, f.firstLineID(id)}) + } + var postable, voidable []string // drafts to post, posted memos to void + for i := 0; i < contenders; i++ { + postable = append(postable, str(t, f.createCredit(f.creditBody(docs[i].invoice, returnLine(docs[i].line, "-1", true))).body, "id")) + m := f.createCredit(f.creditBody(docs[contenders+i].invoice, returnLine(docs[contenders+i].line, "-1", true))) + id := str(t, m.body, "id") + if r := f.postCredit(id, 1); r.status != 200 { + t.Fatalf("prepare a posted memo = %d: %s", r.status, r.raw) + } + voidable = append(voidable, id) + } + var drafts []string // for update and draft void + for i := 0; i < 2*contenders; i++ { + drafts = append(drafts, str(t, f.createCredit(f.creditBody(docs[2*contenders+i%contenders].invoice, returnLine(docs[2*contenders+i%contenders].line, "-0.5", false))).body, "id")) + } + + logger := slog.Default() + glSvc := gl.NewService(gl.NewRepository(db), nil, logger) + acct := account.NewService(account.NewRepository(db), db, logger) + stock := inventory.NewService(inventory.NewRepository(db)) + phase := func(name string, run func(svc *invoice.Service, i int) error) { + t.Helper() + svc := invoice.NewService(invoice.NewRepository(db), glSvc, acct, db).WithAuditLog(audit.NewLogger(db)). + WithOutbox(outbox.NewWriter(db, "")).WithStock(stock).WithOrders(f.orders).WithTxRunner(newGatedTx(db, contenders)) + var wg sync.WaitGroup + errs := make(chan error, contenders) + for i := 0; i < contenders; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := run(svc, i); err != nil { + errs <- err + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Errorf("%s: %v", name, err) + } + if ctx.Err() != nil { + t.Fatalf("%s: four contenders at pool size 4 did not finish: a transaction waited on a second pool connection", name) + } + } + draftFor := func(inv, line string) *invoice.CreditInput { + req := invoice.CreditRequest{InvoiceID: &inv, ReasonCode: ptr("return"), Reason: ptr("saturation"), + Lines: []invoice.CreditLineRequest{{InvoiceLineID: &line, Quantity: []byte(`"-0.25"`)}}} + d, err := req.Parse() + if err != nil { + t.Fatal(err) + } + return d + } + one := int64(1) + + phase("credit memo create", func(svc *invoice.Service, i int) error { + d := docs[3*contenders+i] + _, err := svc.CreateCreditMemo(ctx, draftFor(d.invoice, d.line), "tx") + return err + }) + phase("credit memo update", func(svc *invoice.Service, i int) error { + d := docs[2*contenders+i] + _, err := svc.UpdateCreditMemo(ctx, uuid.MustParse(drafts[i]), draftFor(d.invoice, d.line), invoice.Precondition{Revision: &one}, "tx") + return err + }) + phase("credit memo post", func(svc *invoice.Service, i int) error { + _, err := svc.TransitionCreditMemo(ctx, uuid.MustParse(postable[i]), invoice.CreditOpen, invoice.Precondition{Revision: &one}, invoice.Transition{Actor: "tx"}) + return err + }) + phase("credit memo void (posted)", func(svc *invoice.Service, i int) error { + two := int64(2) + _, err := svc.TransitionCreditMemo(ctx, uuid.MustParse(voidable[i]), invoice.CreditVoid, invoice.Precondition{Revision: &two}, invoice.Transition{Actor: "tx", Reason: "sat"}) + return err + }) + phase("credit memo void (draft)", func(svc *invoice.Service, i int) error { + _, err := svc.TransitionCreditMemo(ctx, uuid.MustParse(drafts[contenders+i]), invoice.CreditVoid, invoice.Precondition{Revision: &one}, invoice.Transition{Actor: "tx", Reason: "sat"}) + return err + }) + phase("invoice void", func(svc *invoice.Service, i int) error { + d := docs[4*contenders+i] // no credit memo names these + _, err := svc.VoidInvoice(ctx, uuid.MustParse(d.invoice), invoice.Precondition{Revision: &one}, invoice.Transition{Actor: "tx", Reason: "sat"}) + return err + }) +} + +func ptr[T any](v T) *T { return &v } diff --git a/core/internal/invoice/wire_test.go b/core/internal/invoice/wire_test.go index aaa783fd..abef4ede 100644 --- a/core/internal/invoice/wire_test.go +++ b/core/internal/invoice/wire_test.go @@ -582,3 +582,182 @@ func TestRawInsertNumbersThroughTheCounter(t *testing.T) { t.Errorf("the Go path numbered %d after the raw insert's %d", b, a) } } + +// holdCredit takes the customer's credit serialization (the advisory lock the +// order's confirm, release and fulfilment take, ADR 0005 section 11 step 1a) in +// a transaction of its own and returns the function that releases it. +func holdCredit(t *testing.T, f *fixture) (release func()) { + t.Helper() + ctx := context.Background() + tx, err := f.db.Pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('order-credit:' || $1::text, 0))`, f.customerID.String()); err != nil { + t.Fatal(err) + } + released := false + release = func() { + if !released { + released = true + _ = tx.Rollback(ctx) + } + } + t.Cleanup(release) + return release +} + +// finishes reports whether the act completes within the wait. +func finishes(done <-chan resp, wait time.Duration) (resp, bool) { + select { + case r := <-done: + return r, true + case <-time.After(wait): + return resp{}, false + } +} + +// RULE (ADR 0005 11, step 1a; carried from C2-2b): every act that adds to a +// customer's exposure takes the per customer credit serialization. An invoice +// void re-opens billed quantity on its order, and voiding a posted credit memo +// puts its credit back on the receivable: both wait behind a holder of the +// customer's lock. Voiding a DRAFT adds nothing to the exposure and does not wait. +func TestExposureAddingActsTakeTheCustomerCreditLock(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + invID, _ := f.invoice("4") + line := f.firstLineID(invID) + posted := f.createCredit(f.creditBody(invID, returnLine(line, "-1", false))) + postedID := str(t, posted.body, "id") + if r := f.postCredit(postedID, 1); r.status != 200 { + t.Fatalf("post = %d", r.status) + } + draft := f.createCredit(f.creditBody(invID, returnLine(line, "-1", false))) + draftID := str(t, draft.body, "id") + other, _ := f.invoice("1") + + asyncDo := func(method, path string, body any) <-chan resp { + ch := make(chan resp, 1) + go func() { ch <- doOn(t, f.srv.URL, method, path, body) }() + return ch + } + release := holdCredit(t, f) + + // a draft's void is not an exposure act: it completes while the lock is held + d := asyncDo("POST", "/api/v1/credit-memos/"+draftID+"/transitions", map[string]any{"to": "void", "revision": 1, "reason": "draft"}) + if r, ok := finishes(d, 3*time.Second); !ok || r.status != 200 { + t.Fatalf("voiding a draft waited on the credit lock (finished %v)", ok) + } + // a posted credit memo's void waits + c := asyncDo("POST", "/api/v1/credit-memos/"+postedID+"/transitions", map[string]any{"to": "void", "revision": 2, "reason": "posted"}) + // an invoice void waits + v := asyncDo("POST", "/api/v1/invoices/"+other+"/transitions", map[string]any{"to": "void", "revision": 1, "reason": "invoice"}) + if _, ok := finishes(c, 400*time.Millisecond); ok { + t.Error("a posted credit memo's void ran while the customer's credit lock was held") + } + if _, ok := finishes(v, 400*time.Millisecond); ok { + t.Error("an invoice void ran while the customer's credit lock was held") + } + release() + if r, ok := finishes(c, 5*time.Second); !ok || r.status != 200 { + t.Errorf("the credit memo void after the release: finished %v status %d", ok, r.status) + } + if r, ok := finishes(v, 5*time.Second); !ok || r.status != 200 { + t.Errorf("the invoice void after the release: finished %v status %d: %s", ok, r.status, r.raw) + } +} + +// RULE (carried from C2-2b round 2, ADR 0005 8.4): the relieved cost read excludes +// void invoices. A non stock line's bill relieves 1030 from the linked receipts' +// posted values pro rata; once the first bill is voided its relief is no longer +// relieved, so the re-bill of the whole line takes the receipts' full value, and +// 1030 nets out to what the receipts posted (1333), not to the 889 left after a +// voided invoice's 444 was counted as spent. +func TestRelievedCostReadExcludesVoidInvoices(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + ctx := context.Background() + vendor, po := uuid.New(), uuid.New() + mustExec(t, db, `INSERT INTO vendors (id, name) VALUES ($1, $2)`, vendor, "rv-"+vendor.String()[:8]) + mustExec(t, db, `INSERT INTO purchase_orders (id, vendor_id, status, source, branch_id) VALUES ($1, $2, 'RECEIVED', 'SPECIAL_ORDER', `+defaultBranch+`)`, po, vendor) + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM purchase_order_lines WHERE po_id = $1`, po) + _, _ = db.Pool.Exec(ctx, `DELETE FROM purchase_orders WHERE id = $1`, po) + _, _ = db.Pool.Exec(ctx, `DELETE FROM vendors WHERE id = $1`, vendor) + }) + r := f.do("POST", "/api/v1/orders", map[string]any{"customer_id": f.customerID.String(), "delivery_type": "pickup", "lines": []map[string]any{{ + "description": "Custom millwork", "quantity": "3", "uom": "EA", "unit_price_ten_thousandths": 100000, + "is_special_order": true, "special_order_unit_cost_ten_thousandths": 30000}}}) + if r.status != 201 { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + orderID := str(t, r.body, "id") + lineID := str(t, r.body["lines"].([]any)[0].(map[string]any), "id") + mustExec(t, db, `INSERT INTO purchase_order_lines (id, po_id, description, quantity, cost, qty_received, linked_so_line_id, created_at) VALUES ($1, $2, 'first', 1, 3.33, 1, $3, now() - interval '1 minute')`, uuid.New(), po, lineID) + mustExec(t, db, `INSERT INTO purchase_order_lines (id, po_id, description, quantity, cost, qty_received, linked_so_line_id) VALUES ($1, $2, 'second', 2, 5.00, 2, $3)`, uuid.New(), po, lineID) + c := f.do("POST", "/api/v1/orders/"+orderID+"/transitions", map[string]any{"to": "confirmed", "revision": 1}) + if c.status != 200 { + t.Fatalf("confirm = %d: %s", c.status, c.raw) + } + first, rv := f.fulfil(orderID, rev(t, c), []map[string]any{{"order_line_id": lineID, "quantity": "1"}}) + if _, legs := f.entryLegs(first); legs["5010"].debit != 444 { + t.Fatalf("first bill relieved %+v, want 444", legs["5010"]) + } + _ = rv + if v := f.voidInvoice(first, rev(t, f.getInvoice(first)), "billed the wrong line"); v.status != 200 { + t.Fatalf("void = %d: %s", v.status, v.raw) + } + o := f.do("GET", "/api/v1/orders/"+orderID, nil) + rebill, _ := f.fulfil(orderID, rev(t, o), nil) // all 3 now + if _, legs := f.entryLegs(rebill); legs["5010"].debit != 1333 || legs["1030"].credit != 1333 { + t.Errorf("the re-bill relieved %+v / %+v, want the receipts' full 1333 (the void invoice's 444 is not spent)", legs["5010"], legs["1030"]) + } +} + +// RULE (carried from C2-2b round 2, ADR 0005 2.4): an invoice line's discount on a +// partial piece is the gross extension of the piece less its net, so every +// invoice line satisfies total + discount = gross piece to the cent and the +// shares over the invoices sum to the order line's discount exactly. Prorating +// the discount on its own (round(2 x 1 / 3) = 1) disagreed with the line total +// split: a line of 3 at 3.3333 with a 0.02 discount billed 1 then 2 carried a +// discount of 1 cent on a first piece whose net is its whole 3.33. +func TestPartialInvoiceLineDiscountsSumExactly(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + f := newFixture(t, db) + product := uuid.New() + mustExec(t, db, `INSERT INTO products (id, sku, description, uom_primary, base_price, average_unit_cost) VALUES ($1, $2, 'thirds', 'PCS', 3.3333, 1)`, product, "TH-"+product.String()[:8]) + mustExec(t, db, `INSERT INTO inventory (product_id, location_id, location, quantity, allocated) VALUES ($1, $2, 'Y', 50, 0)`, product, f.yardID) + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM inventory WHERE product_id = $1`, product) + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM invoice_lines WHERE product_id = $1`, product) + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM order_lines WHERE product_id = $1`, product) + }) + orderID, r := f.confirmedOrder(map[string]any{"product_id": product.String(), "quantity": "3", "discount_cents": 2, "discount_reason": "contract"}) + line := f.do("GET", "/api/v1/orders/"+orderID, nil).body["lines"].([]any)[0].(map[string]any)["id"].(string) + first, r2 := f.fulfil(orderID, r, []map[string]any{{"order_line_id": line, "quantity": "1"}}) + second, _ := f.fulfil(orderID, r2, nil) + + type piece struct{ total, discount int64 } + read := func(inv string) piece { + l := f.getInvoice(inv).body["lines"].([]any)[0].(map[string]any) + p := piece{total: num(t, l, "line_total_cents")} + if l["discount_cents"] != nil { + p.discount = num(t, l, "discount_cents") + } + return p + } + a, b := read(first), read(second) + // gross pieces: 3.3333 x 1 = 333, 3.3333 x 3 = 1000 so the rest is 667 + if a.total+a.discount != 333 || b.total+b.discount != 667 { + t.Errorf("pieces %+v and %+v: total + discount must equal the gross pieces 333 and 667", a, b) + } + if a.discount+b.discount != 2 { + t.Errorf("discounts %d + %d, want the order line's 2 exactly", a.discount, b.discount) + } + if a.total+b.total != 998 { + t.Errorf("totals %d + %d, want 998 (1000 less the discount)", a.total, b.total) + } +} diff --git a/core/internal/salesdoc/salesdoc_test.go b/core/internal/salesdoc/salesdoc_test.go index c4869509..d14625d3 100644 --- a/core/internal/salesdoc/salesdoc_test.go +++ b/core/internal/salesdoc/salesdoc_test.go @@ -566,3 +566,56 @@ func TestBilledTotalPiecesStayNonNegative(t *testing.T) { t.Errorf("%d of 200000 random lines billed a negative piece", negative) } } + +// RULE (carried from C2-2b, ADR 0005 2.4): the invoice line's amount discount +// share is its gross piece less its net piece, so line total + discount equals +// the gross extension of the piece to the cent on every invoice, and the shares +// over any sequence of partial invoices sum to the order line's discount exactly. +func TestBilledDiscountSumsExactly(t *testing.T) { + rng := rand.New(rand.NewSource(11)) + one := One + for trial := 0; trial < 500; trial++ { + ordered := httpx.Quantity(rng.Intn(1000000) + 10000) + price := httpx.Price(rng.Intn(900000) + 100) + pair := httpx.Quantity(rng.Intn(3000) + 1000) + gross, err := httpx.Extend(ordered, pair, one, price) + if err != nil { + t.Fatal(err) + } + if gross < 100 { + continue + } + d := httpx.Cents(rng.Int63n(int64(gross)-1) + 1) + line := Line{Quantity: &ordered, UOMQty: &pair, PriceUOMQty: &one, UnitPrice: &price, DiscountAmount: &d} + var done httpx.Quantity + var sumTotal, sumDiscount, sumGross httpx.Cents + for done < ordered { + piece := httpx.Quantity(rng.Intn(int(ordered-done))) + 1 + net, err := BilledTotal(&line, done, done+piece) + if err != nil { + t.Fatal(err) + } + share, err := BilledDiscount(&line, done, done+piece) + if err != nil { + t.Fatal(err) + } + hi, _ := httpx.Extend(done+piece, pair, one, price) + lo := httpx.Cents(0) + if done > 0 { + lo, _ = httpx.Extend(done, pair, one, price) + } + if net+share != hi-lo { + t.Fatalf("trial %d: total %d + discount %d != the gross piece %d", trial, net, share, hi-lo) + } + sumTotal, sumDiscount, sumGross = sumTotal+net, sumDiscount+share, sumGross+(hi-lo) + _ = sumGross + done += piece + } + if sumDiscount != d { + t.Fatalf("trial %d: the pieces' discounts sum to %d, want the order line's %d", trial, sumDiscount, d) + } + if sumTotal != gross-d { + t.Fatalf("trial %d: the pieces' totals sum to %d, want %d", trial, sumTotal, gross-d) + } + } +} diff --git a/web/apps/desk/src/app.ts b/web/apps/desk/src/app.ts index fcfa0b5d..57c89044 100644 --- a/web/apps/desk/src/app.ts +++ b/web/apps/desk/src/app.ts @@ -118,6 +118,10 @@ export class GableApp extends LitElement { '/orders/:id': 'gable-order-detail', '/invoices': 'gable-invoice-list', '/invoices/:id': 'gable-invoice-detail', + '/credit-memos': 'gable-credit-memo-list', + '/credit-memos/new': 'gable-credit-memo-form', + '/credit-memos/:id/edit': 'gable-credit-memo-form', + '/credit-memos/:id': 'gable-credit-memo-detail', '/reports/daily-till': 'gable-daily-till', '/reports/ar-aging': 'gable-ar-aging-report', '/reports/customer-statement': 'gable-customer-statement', diff --git a/web/apps/desk/src/components/invoices/SalesDialog.ts b/web/apps/desk/src/components/invoices/SalesDialog.ts index 5ef84fdb..f6272728 100644 --- a/web/apps/desk/src/components/invoices/SalesDialog.ts +++ b/web/apps/desk/src/components/invoices/SalesDialog.ts @@ -26,7 +26,7 @@ export class GableSalesDialog extends LitElement { @state() private reason = ''; - updated(changed: Map) { + willUpdate(changed: Map) { if (changed.has('isOpen') && !this.isOpen) this.reason = ''; } diff --git a/web/apps/desk/src/lib/credit-display.test.ts b/web/apps/desk/src/lib/credit-display.test.ts new file mode 100644 index 00000000..bf28210c --- /dev/null +++ b/web/apps/desk/src/lib/credit-display.test.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The credit memo display and input helpers: stored quantities and totals are + * negative and are never flipped in storage; a user types a positive "how many + * come back" and string arithmetic carries it to the wire. + */ +import { describe, it, expect } from 'vitest' +import { + addQuantities, + creditQuantityFromInput, + creditQuantityToInput, + creditTextClass, + formatCreditCents, + formatQuantity, + quantityToScaled, + remainingCreditable, + scaledToQuantity, +} from './credit-display' +import { formatDay } from './utils' + +describe('credit quantities', () => { + it('negates what a user types into the wire decimal string', () => { + expect(creditQuantityFromInput('2')).toBe('-2') + expect(creditQuantityFromInput('1.5')).toBe('-1.5') + expect(creditQuantityFromInput(' 0.0625 ')).toBe('-0.0625') + }) + + it('refuses zero, negatives, text and too many fraction digits', () => { + expect(creditQuantityFromInput('0')).toBeNull() + expect(creditQuantityFromInput('-2')).toBeNull() + expect(creditQuantityFromInput('two')).toBeNull() + expect(creditQuantityFromInput('')).toBeNull() + expect(creditQuantityFromInput('1.00001')).toBeNull() + }) + + it('shows a stored negative quantity as the positive text a user would type', () => { + expect(creditQuantityToInput('-2')).toBe('2') + expect(creditQuantityToInput('-1.5')).toBe('1.5') + expect(creditQuantityToInput(null)).toBe('') + }) + + it('round trips through the scaled integer without a float', () => { + for (const q of ['0.1', '-0.1', '24', '-24', '1234.5678', '-0.0001']) { + expect(scaledToQuantity(quantityToScaled(q) as number)).toBe(q) + } + expect(scaledToQuantity(0)).toBe('0') + }) + + it('adds decimal strings exactly: 0.1 + 0.2 is 0.3', () => { + expect(addQuantities('0.1', '0.2')).toBe('0.3') + expect(addQuantities('24', '-2')).toBe('22') + expect(addQuantities('x', '1')).toBeNull() + }) +}) + +describe('remainingCreditable', () => { + it('is billed less the credits already written, which are negative', () => { + expect(remainingCreditable('24', [])).toBe('24') + expect(remainingCreditable('24', ['-2'])).toBe('22') + expect(remainingCreditable('24', ['-2', '-0.5'])).toBe('21.5') + }) + + it('never goes below zero and has no figure for a note line', () => { + expect(remainingCreditable('2', ['-3'])).toBe('0') + expect(remainingCreditable(null, ['-1'])).toBeNull() + }) +}) + +describe('credit money display', () => { + it('keeps the minus sign on a credit and marks it with the safety red token', () => { + expect(formatCreditCents(-4400)).toBe('-$44.00') + expect(creditTextClass(-4400)).toBe('text-red-400') + }) + + it('leaves positive and zero amounts unmarked and never shows -$0.00', () => { + expect(creditTextClass(0)).toBe('') + expect(creditTextClass(100)).toBe('') + expect(formatCreditCents(0)).toBe('$0.00') + expect(formatCreditCents(-0)).toBe('$0.00') + }) + + it('shows a missing quantity as a dash', () => { + expect(formatQuantity(null)).toBe('—') + expect(formatQuantity('-2')).toBe('-2') + }) +}) + +describe('formatDay', () => { + it('renders a business date without a time zone shift', () => { + expect(formatDay('2026-01-01')).toBe(new Date(2026, 0, 1).toLocaleDateString()) + expect(formatDay(null)).toBe('—') + expect(formatDay('not a date')).toBe('not a date') + }) +}) diff --git a/web/apps/desk/src/lib/workspace.test.ts b/web/apps/desk/src/lib/workspace.test.ts index 4cf1c273..0ae5c65b 100644 --- a/web/apps/desk/src/lib/workspace.test.ts +++ b/web/apps/desk/src/lib/workspace.test.ts @@ -64,6 +64,13 @@ describe('zoneForPath', () => { expect(zone?.label).toBe('Invoicing') expect(zone?.icon).toBeDefined() }) + + it('files the credit memo pages under the Invoicing tab, with both entries in its menu', () => { + for (const path of ['/credit-memos', '/credit-memos/new', '/credit-memos/abc', '/credit-memos/abc/edit']) { + expect(zoneForPath(path)?.key).toBe('invoice') + } + expect(menuForKey('invoice').map((m) => m.path)).toEqual(['/invoices', '/credit-memos']) + }) }) describe('menuForKey', () => { diff --git a/web/apps/desk/src/lib/workspace.ts b/web/apps/desk/src/lib/workspace.ts index 4b64a40a..6400cbea 100644 --- a/web/apps/desk/src/lib/workspace.ts +++ b/web/apps/desk/src/lib/workspace.ts @@ -66,6 +66,11 @@ const staticZones: AppZone[] = [ ] }, { prefix: '/invoices', key: 'invoice', label: 'Invoicing', icon: Receipt, menu: [ { label: 'Invoices', path: '/invoices' }, + { label: 'Credit Memos', path: '/credit-memos' }, + ] }, + { prefix: '/credit-memos', key: 'invoice', label: 'Invoicing', icon: Receipt, menu: [ + { label: 'Invoices', path: '/invoices' }, + { label: 'Credit Memos', path: '/credit-memos' }, ] }, { prefix: '/reports/daily-till', key: 'pos', label: 'Daily Till', icon: CreditCard, menu: [ { label: 'Daily Till', path: '/reports/daily-till' }, diff --git a/web/apps/desk/src/pages/credit-memos/CreditMemoDetail.test.ts b/web/apps/desk/src/pages/credit-memos/CreditMemoDetail.test.ts new file mode 100644 index 00000000..e23451cd --- /dev/null +++ b/web/apps/desk/src/pages/credit-memos/CreditMemoDetail.test.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The credit memo page (C2-3): negative cents and quantities shown as credits + * and never flipped, a draft with no number, and post and void on the loaded + * revision. + */ +import { describe, it, expect, afterEach, vi } from 'vitest' +import './CreditMemoDetail' +import type { GableCreditMemoDetail } from './CreditMemoDetail' +import { mountAsync, text, flush } from '../../test/dom' +import { creditMemo, creditLine, invoice, routedFetch, INVOICE_ID } from '../../test/salesFixtures' +import type { CreditMemo } from '../../types/creditMemo' + +const MEMO_ID = creditMemo().id + +function stub(memo: CreditMemo, extra: Record unknown> = {}) { + const state = routedFetch({ + [`/api/v1/credit-memos/${MEMO_ID}`]: () => memo, + [`/api/v1/invoices/${INVOICE_ID}`]: () => invoice(), + ...extra, + }) + vi.stubGlobal('fetch', vi.fn(state.fn)) + return state +} + +function buttonByText(el: Element, label: string): HTMLButtonElement { + return Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes(label)) as HTMLButtonElement +} + +describe('gable-credit-memo-detail', () => { + let el: GableCreditMemoDetail + afterEach(() => vi.unstubAllGlobals()) + + it('shows a draft as Draft with its explanation and Post, Edit and Void', async () => { + stub(creditMemo()) + el = await mountAsync('gable-credit-memo-detail', { routeId: MEMO_ID }) + expect(text(el.querySelector('h1'))).toBe('Draft') + expect(text(el.querySelector('[data-testid="draft-banner"]'))).toContain('no number') + expect(buttonByText(el, 'Post')).toBeTruthy() + expect(el.querySelector(`a[href="/credit-memos/${MEMO_ID}/edit"]`)).not.toBeNull() + expect(buttonByText(el, 'Void')).toBeTruthy() + expect(text(el)).toContain('IN-000042') + }) + + it('keeps stored signs: negative quantity and extension are shown as stored, in red', async () => { + stub(creditMemo()) + el = await mountAsync('gable-credit-memo-detail', { routeId: MEMO_ID }) + const row = el.querySelector('tr[data-line-type="product"]')! + expect(text(row)).toContain('-2') + expect(text(row)).toContain('-$64.80') + expect(row.querySelectorAll('.text-red-400').length).toBeGreaterThanOrEqual(2) + const totals = text(el.querySelector('[data-testid="credit-totals"]')) + expect(totals).toContain('-$64.80') + expect(totals).toContain('-$5.75') + expect(text(el.querySelector('[data-testid="credit-total"]'))).toBe('-$70.55') + expect(totals).toContain('8.875%') + }) + + it('shows the restock flag per line', async () => { + stub(creditMemo({ lines: [creditLine({ restock: true }), creditLine({ id: 'l2', position: 1, restock: false, sku: 'X' })] })) + el = await mountAsync('gable-credit-memo-detail', { routeId: MEMO_ID }) + const cells = Array.from(el.querySelectorAll('tr[data-line-type="product"]')).map(r => text(r.children[6])) + expect(cells).toEqual(['Yes', '-']) + }) + + it('posts after a confirm that says what posting does, on the loaded revision', async () => { + const state = stub(creditMemo({ revision: 3 }), { + [`POST /api/v1/credit-memos/${MEMO_ID}/transitions`]: () => creditMemo({ status: 'open', number: 'CM-000009', revision: 4 }), + }) + el = await mountAsync('gable-credit-memo-detail', { routeId: MEMO_ID }) + buttonByText(el, 'Post').click() + await el.updateComplete + const dialog = el.querySelector('gable-sales-dialog')! + const body = text(dialog) + expect(body).toContain('assigns') + expect(body).toContain('number') + expect(body).toContain('restock') + expect(body).toContain('ledger') + ;(Array.from(dialog.querySelectorAll('button')).find(b => b.textContent?.includes('Post credit memo')) as HTMLButtonElement).click() + await flush() + await flush() + await el.updateComplete + const call = state.calls.find(c => c.url.pathname.endsWith('/transitions'))! + expect(JSON.parse(String(call.init!.body))).toEqual({ to: 'open', revision: 3 }) + expect(text(el.querySelector('h1'))).toBe('CM-000009') + expect(text(el)).toContain('Open') + expect(el.querySelector('[data-testid="draft-banner"]')).toBeNull() + expect(el.querySelector('a[href$="/edit"]')).toBeNull() + }) + + it('voids with a required reason and shows the VOID banner', async () => { + const state = stub(creditMemo({ status: 'open', number: 'CM-000009', revision: 2 }), { + [`POST /api/v1/credit-memos/${MEMO_ID}/transitions`]: () => creditMemo({ status: 'void', number: 'CM-000009', revision: 3, void_reason: 'entered twice' }), + }) + el = await mountAsync('gable-credit-memo-detail', { routeId: MEMO_ID }) + buttonByText(el, 'Void').click() + await el.updateComplete + const dialog = el.querySelector('gable-sales-dialog')! + const confirmBtn = Array.from(dialog.querySelectorAll('button')).find(b => b.textContent?.includes('Void credit memo')) as HTMLButtonElement + expect(confirmBtn.disabled).toBe(true) + const textarea = dialog.querySelector('textarea') as HTMLTextAreaElement + textarea.value = 'entered twice' + textarea.dispatchEvent(new Event('input')) + await flush() + await (dialog as unknown as { updateComplete: Promise }).updateComplete + confirmBtn.click() + await flush() + await flush() + await el.updateComplete + const call = state.calls.find(c => c.url.pathname.endsWith('/transitions'))! + expect(JSON.parse(String(call.init!.body))).toEqual({ to: 'void', revision: 2, reason: 'entered twice' }) + expect(text(el.querySelector('[data-testid="void-banner"]'))).toContain('entered twice') + }) + + it('shows the server refusal inside the dialog and keeps it open', async () => { + stub(creditMemo({ status: 'open', number: 'CM-000009' }), { + [`POST /api/v1/credit-memos/${MEMO_ID}/transitions`]: () => + new Response(JSON.stringify({ error: { code: 'conflict', message: 'the credit memo has been used: reverse its applications and refunds before voiding it', details: [{ code: 'has_applications', message: 'x' }] }, meta: {} }), { status: 409, headers: { 'Content-Type': 'application/json' } }), + }) + el = await mountAsync('gable-credit-memo-detail', { routeId: MEMO_ID }) + buttonByText(el, 'Void').click() + await el.updateComplete + const dialog = el.querySelector('gable-sales-dialog')! + const textarea = dialog.querySelector('textarea') as HTMLTextAreaElement + textarea.value = 'why' + textarea.dispatchEvent(new Event('input')) + await flush() + await (dialog as unknown as { updateComplete: Promise }).updateComplete + ;(Array.from(dialog.querySelectorAll('button')).find(b => b.textContent?.includes('Void credit memo')) as HTMLButtonElement).click() + await flush() + await flush() + await el.updateComplete + await (dialog as unknown as { updateComplete: Promise }).updateComplete + expect(text(el.querySelector('[data-testid="dialog-error"]'))).toContain('has been used') + expect(el.querySelector('[role="dialog"]')).not.toBeNull() + }) +}) diff --git a/web/apps/desk/src/pages/credit-memos/CreditMemoDetail.ts b/web/apps/desk/src/pages/credit-memos/CreditMemoDetail.ts new file mode 100644 index 00000000..4a25b282 --- /dev/null +++ b/web/apps/desk/src/pages/credit-memos/CreditMemoDetail.ts @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { LitElement, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { icon } from '../../lib/icons.ts'; +import { ToastService } from '../../lib/toast-service.ts'; +import { CreditMemoService } from '../../services/CreditMemoService.ts'; +import { InvoiceService } from '../../services/InvoiceService.ts'; +import { ApiError, apiErrorMessage } from '../../services/apiError.ts'; +import { + type CreditMemo, + creditMemoLabel, + formatCreditMemoStatus, + formatReasonCode, + getCreditMemoStatusColor, +} from '../../types/creditMemo.ts'; +import { Check, Ban, Pencil } from 'lucide'; +import { formatCents, formatDay } from '../../lib/utils.ts'; +import { creditTextClass } from '../../lib/credit-display.ts'; +import { chipClass } from '../../components/invoices/chips.ts'; +import { renderSalesLines } from '../../components/invoices/lines-table.ts'; + +import '../../components/invoices/SalesDialog.ts'; + +type Pending = 'post' | 'void' | null; + +@customElement('gable-credit-memo-detail') +export class GableCreditMemoDetail extends LitElement { + createRenderRoot() { return this; } + + @property({ attribute: 'route-id' }) routeId = ''; + + @state() private memo: CreditMemo | null = null; + @state() private invoiceNumber: string | null = null; + @state() private loading = true; + @state() private error = false; + @state() private pending: Pending = null; + @state() private busy = false; + @state() private dialogError = ''; + + connectedCallback() { + super.connectedCallback(); + if (this.routeId) this.load(this.routeId); + } + + updated(changed: Map) { + if (changed.has('routeId') && changed.get('routeId') !== undefined && this.routeId) { + this.loading = true; + this.load(this.routeId); + } + } + + private async load(id: string) { + try { + this.memo = await CreditMemoService.getCreditMemo(id); + this.error = false; + this.invoiceNumber = null; + if (this.memo.invoice_id) { + try { + this.invoiceNumber = (await InvoiceService.getInvoice(this.memo.invoice_id)).number; + } catch { + // the invoice number is a convenience: the link still works by id + } + } + } catch (err) { + console.error(err); + this.error = true; + ToastService.show('Failed to load credit memo', 'error'); + } finally { + this.loading = false; + } + } + + private openDialog(kind: Pending) { + this.dialogError = ''; + this.pending = kind; + } + + private async confirm(reason: string) { + if (!this.memo || !this.pending) return; + const kind = this.pending; + this.busy = true; + this.dialogError = ''; + try { + if (kind === 'post') { + this.memo = await CreditMemoService.post(this.memo.id, this.memo.revision); + ToastService.show(this.memo.number ? `Credit memo ${this.memo.number} posted` : 'Credit memo posted', 'success'); + } else { + this.memo = await CreditMemoService.voidCreditMemo(this.memo.id, this.memo.revision, reason); + ToastService.show('Credit memo voided', 'success'); + } + this.pending = null; + } catch (err) { + if (err instanceof ApiError && err.isStaleRevision) { + this.pending = null; + ToastService.show('The credit memo changed while you were looking at it. It has been reloaded.', 'error'); + if (this.routeId) this.load(this.routeId); + } else { + this.dialogError = apiErrorMessage(err, 'The credit memo could not be changed'); + } + } finally { + this.busy = false; + } + } + + render() { + if (this.loading) return html`
Loading credit memo...
`; + if (this.error || !this.memo) return html`
Failed to load credit memo.
`; + + const cm = this.memo; + const isDraft = cm.status === 'draft'; + const isVoid = cm.status === 'void'; + const canVoid = isDraft || cm.status === 'open'; + return html` +
+
+
+
+

${creditMemoLabel(cm)}

+ ${formatCreditMemoStatus(cm.status)} +
+

+ Credit memo dated ${formatDay(cm.memo_date)} · ${cm.currency} · ${formatReasonCode(cm.reason_code)} +

+
+
+ ${isDraft ? html` + + ${icon(Pencil, 18)} Edit + ` : nothing} + ${canVoid ? html` + + ` : nothing} +
+
+ + ${isDraft ? html` +
+ Draft. + This credit memo has no number and has moved nothing yet. Post it to assign its number, restock the returned goods and post the credit to the ledger. +
+ ` : nothing} + ${isVoid ? html` +
+
VOID
+
+ This credit memo was voided${cm.voided_at ? html` on ${new Date(cm.voided_at).toLocaleString()}` : nothing}. + ${cm.void_reason ? html`Reason: ${cm.void_reason}` : nothing} +
+
+ ` : nothing} + +
+
+
+
+

Credited lines

+
+ ${renderSalesLines(cm.lines, { ariaLabel: 'Credit memo lines', credit: true, showRestock: true })} +
+
Subtotal${formatCents(cm.subtotal_cents)}
+
+ Tax ${cm.tax_rate_percent !== null ? `(${cm.tax_rate_percent}%)` : ''} + ${formatCents(cm.tax_cents)} +
+
Total credit${formatCents(cm.total_cents)}
+
Still unapplied${formatCents(cm.open_cents)}
+
+
+
+ +
+
+

Customer

+
+

${cm.customer_name || 'Customer'}

+

Invoice: + ${cm.invoice_id + ? html`${this.invoiceNumber ?? cm.invoice_id.slice(0, 8)}` + : html`none`} +

+
+
+
+

Reason

+

${cm.reason}

+
+
+
+ + { this.pending = null; }} + @confirm=${(e: CustomEvent<{ reason: string }>) => this.confirm(e.detail.reason)} + > +
+ `; + } +} diff --git a/web/apps/desk/src/pages/credit-memos/CreditMemoForm.test.ts b/web/apps/desk/src/pages/credit-memos/CreditMemoForm.test.ts new file mode 100644 index 00000000..5279a4ca --- /dev/null +++ b/web/apps/desk/src/pages/credit-memos/CreditMemoForm.test.ts @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The credit memo form (C2-3): quantities typed positive and sent as negative + * decimal strings, validated against billed less already credited, a restock + * flag per stocked line, free ADJUST lines, and an edit that keeps the revision. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './CreditMemoForm' +import { buildCreditMemoRequest, creditableLines, estimateCreditCents } from './CreditMemoForm' +import type { GableCreditMemoForm } from './CreditMemoForm' +import { mountAsync, text, flush } from '../../test/dom' +import { creditMemo, creditMemoSummary, creditLine, invoice, invoiceLine, routedFetch, INVOICE_ID } from '../../test/salesFixtures' + +const LINE = invoiceLine() + +function row(qtyInput: string, remaining = '24', restock = true) { + return { line: LINE, qtyInput, restock, remaining } +} + +describe('buildCreditMemoRequest', () => { + const base = { invoiceId: INVOICE_ID, reasonCode: 'return' as const, reason: 'split boards' } + + it('turns a typed positive quantity into the wire negative with the restock flag', () => { + const { request, errors } = buildCreditMemoRequest(base, [row('2')], []) + expect(errors).toEqual({}) + expect(request).toEqual({ + invoice_id: INVOICE_ID, + reason_code: 'return', + reason: 'split boards', + lines: [{ invoice_line_id: LINE.id, quantity: '-2', restock: true }], + }) + }) + + it('refuses more than is left to credit and names the remaining quantity', () => { + const { errors } = buildCreditMemoRequest(base, [row('3', '2.5')], []) + expect(errors[`return:${LINE.id}`]).toBe('Only 2.5 remain to credit') + }) + + it('refuses zero, text and negatives', () => { + for (const bad of ['0', 'abc', '-1']) { + const { errors } = buildCreditMemoRequest(base, [row(bad)], []) + expect(errors[`return:${LINE.id}`]).toBe('Enter a quantity greater than zero') + } + }) + + it('skips blank rows and asks for at least one line and a reason', () => { + const { errors } = buildCreditMemoRequest({ ...base, reason: ' ' }, [row('')], []) + expect(errors.lines).toBeTruthy() + expect(errors.reason).toBeTruthy() + }) + + it('builds a free ADJUST line with a scaled price and a negative quantity', () => { + const { request, errors } = buildCreditMemoRequest(base, [row('')], [{ description: 'Goodwill', qtyInput: '1', priceInput: '0.50' }]) + expect(errors).toEqual({}) + expect(request.lines).toEqual([{ line_type: 'charge', charge_code: 'ADJUST', quantity: '-1', unit_price_ten_thousandths: 5000, description: 'Goodwill' }]) + }) + + it('refuses a free line with no description or a zero price', () => { + expect(buildCreditMemoRequest(base, [], [{ description: '', qtyInput: '1', priceInput: '5' }]).errors['free:0']).toBe('Describe the adjustment') + expect(buildCreditMemoRequest(base, [], [{ description: 'x', qtyInput: '1', priceInput: '0' }]).errors['free:0']).toBe('Enter a price greater than zero') + }) + + it('names the customer for a memo with no invoice', () => { + const noInvoice = { customerId: 'c-1', reasonCode: 'other' as const, reason: 'goodwill' } + const { request } = buildCreditMemoRequest(noInvoice, [], [{ description: 'Goodwill', qtyInput: '1', priceInput: '5' }]) + expect(request.customer_id).toBe('c-1') + expect(request.invoice_id).toBeUndefined() + expect(buildCreditMemoRequest({ ...noInvoice, customerId: '' }, [], []).errors.customer).toBeTruthy() + }) + + it('does not send restock for a charge line', () => { + const charge = invoiceLine({ id: 'ch', line_type: 'charge', product_id: null, sku: null, charge_code: 'FREIGHT' }) + const { request } = buildCreditMemoRequest(base, [{ line: charge, qtyInput: '1', restock: true, remaining: '1' }], []) + expect(request.lines[0]).toEqual({ invoice_line_id: 'ch', quantity: '-1' }) + }) +}) + +describe('estimateCreditCents', () => { + it('prices each credit from its invoice line, negative, with integer arithmetic', () => { + expect(estimateCreditCents([row('2')], [])).toBe(-6480) + expect(estimateCreditCents([row('0.5')], [])).toBe(-1620) + }) + + it('adds the free lines and ignores unparseable rows', () => { + expect(estimateCreditCents([row('x')], [{ description: 'a', qtyInput: '1', priceInput: '2.50' }])).toBe(-250) + expect(estimateCreditCents([], [])).toBe(0) + }) +}) + +describe('creditableLines', () => { + it('offers products, kits and charges, never a note or a component', () => { + const inv = invoice({ + lines: [ + invoiceLine({ id: 'p', position: 2 }), + invoiceLine({ id: 't', line_type: 'text', position: 0 }), + invoiceLine({ id: 'c', line_type: 'component', position: 1 }), + invoiceLine({ id: 'k', line_type: 'kit', position: 3 }), + ], + }) + expect(creditableLines(inv).map(l => l.id)).toEqual(['p', 'k']) + }) +}) + +describe('gable-credit-memo-form', () => { + let el: GableCreditMemoForm + afterEach(() => { + vi.unstubAllGlobals() + window.history.replaceState(null, '', '/') + }) + + function stubAll(extra: Record unknown> = {}) { + const state = routedFetch({ + [`/api/v1/invoices/${INVOICE_ID}`]: () => invoice(), + '/api/v1/credit-memos': () => ({ items: [], next_cursor: null, limit: 200 }), + ...extra, + }) + vi.stubGlobal('fetch', vi.fn(state.fn)) + return state + } + + beforeEach(() => { + window.history.replaceState(null, '', `/credit-memos/new?invoice_id=${INVOICE_ID}`) + }) + + it('explains draft versus posted and lists the invoice lines with what is left to credit', async () => { + stubAll() + el = await mountAsync('gable-credit-memo-form') + expect(text(el.querySelector('[data-testid="draft-explainer"]'))).toContain('Nothing moves until you post it') + const table = el.querySelector('table[aria-label="Invoice lines to credit"]')! + expect(text(table)).toContain('LUM-248-PREM') + expect(text(table)).toContain('24') + expect(el.querySelector('input[aria-label="Restock LUM-248-PREM"]')).not.toBeNull() + }) + + it('reduces what is left by the credit memos already written against the invoice', async () => { + stubAll({ + '/api/v1/credit-memos': () => ({ items: [creditMemoSummary({ id: 'm1', status: 'open' }), creditMemoSummary({ id: 'm2', status: 'void' })], next_cursor: null, limit: 200 }), + '/api/v1/credit-memos/m1': () => creditMemo({ id: 'm1', lines: [creditLine({ quantity: '-5' })] }), + }) + el = await mountAsync('gable-credit-memo-form') + const cells = Array.from(el.querySelectorAll('table[aria-label="Invoice lines to credit"] tbody tr td')) + expect(text(cells[2])).toBe('19') + }) + + it('shows a live total as a credit while the quantity is typed', async () => { + stubAll() + el = await mountAsync('gable-credit-memo-form') + const qty = el.querySelector('input[aria-label="Credit quantity for LUM-248-PREM"]') as HTMLInputElement + qty.value = '2' + qty.dispatchEvent(new Event('input')) + await el.updateComplete + expect(text(el.querySelector('[data-testid="credit-estimate"]'))).toContain('-$64.80') + }) + + it('creates the draft with negative decimal strings and goes to it', async () => { + const state = stubAll({ + 'POST /api/v1/credit-memos': () => new Response(JSON.stringify(creditMemo({ id: 'new-1' })), { status: 201, headers: { 'Content-Type': 'application/json' } }), + }) + el = await mountAsync('gable-credit-memo-form') + const qty = el.querySelector('input[aria-label="Credit quantity for LUM-248-PREM"]') as HTMLInputElement + qty.value = '2' + qty.dispatchEvent(new Event('input')) + const reason = el.querySelector('#cm-reason') as HTMLTextAreaElement + reason.value = 'two boards split' + reason.dispatchEvent(new Event('input')) + await el.updateComplete + ;(Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes('Create draft')) as HTMLButtonElement).click() + await flush() + await flush() + const post = state.calls.find(c => c.init?.method === 'POST')! + expect(JSON.parse(String(post.init!.body))).toEqual({ + invoice_id: INVOICE_ID, + reason_code: 'return', + reason: 'two boards split', + lines: [{ invoice_line_id: LINE.id, quantity: '-2', restock: true }], + }) + expect(window.location.pathname).toBe('/credit-memos/new-1') + }) + + it('shows the validation beside the input and sends nothing', async () => { + const state = stubAll() + el = await mountAsync('gable-credit-memo-form') + const qty = el.querySelector('input[aria-label="Credit quantity for LUM-248-PREM"]') as HTMLInputElement + qty.value = '99' + qty.dispatchEvent(new Event('input')) + await el.updateComplete + ;(Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes('Create draft')) as HTMLButtonElement).click() + await el.updateComplete + expect(text(el)).toContain('Only 24 remain to credit') + expect(text(el)).toContain('Give a reason') + expect(state.calls.some(c => c.init?.method === 'POST')).toBe(false) + }) + + it('shows the server refusal (exceeds_billed) as a banner', async () => { + stubAll({ + 'POST /api/v1/credit-memos': () => new Response(JSON.stringify({ error: { code: 'conflict', message: 'line 1 would credit more than was billed', details: [{ code: 'exceeds_billed', message: 'x' }] }, meta: {} }), { status: 409, headers: { 'Content-Type': 'application/json' } }), + }) + el = await mountAsync('gable-credit-memo-form') + const qty = el.querySelector('input[aria-label="Credit quantity for LUM-248-PREM"]') as HTMLInputElement + qty.value = '1' + qty.dispatchEvent(new Event('input')) + const reason = el.querySelector('#cm-reason') as HTMLTextAreaElement + reason.value = 'x' + reason.dispatchEvent(new Event('input')) + await el.updateComplete + ;(Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes('Create draft')) as HTMLButtonElement).click() + await flush() + await flush() + await el.updateComplete + expect(text(el.querySelector('[data-testid="form-error"]'))).toContain('more than was billed') + }) + + it('edits a draft: prefilled from its lines, saved on its revision', async () => { + window.history.replaceState(null, '', '/credit-memos/m9/edit') + const draft = creditMemo({ id: 'm9', revision: 5, lines: [creditLine({ quantity: '-2', restock: false })] }) + const state = stubAll({ + '/api/v1/credit-memos/m9': (_u, init) => (init?.method === 'PUT' ? creditMemo({ id: 'm9', revision: 6 }) : draft), + }) + el = await mountAsync('gable-credit-memo-form', { routeId: 'm9' }) + expect(text(el.querySelector('h1'))).toBe('Edit draft credit memo') + const qty = el.querySelector('input[aria-label="Credit quantity for LUM-248-PREM"]') as HTMLInputElement + expect(qty.value).toBe('2') + expect((el.querySelector('input[aria-label="Restock LUM-248-PREM"]') as HTMLInputElement).checked).toBe(false) + ;(Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes('Save draft')) as HTMLButtonElement).click() + await flush() + await flush() + const put = state.calls.find(c => c.init?.method === 'PUT')! + const body = JSON.parse(String(put.init!.body)) + expect(body.revision).toBe(5) + expect((put.init!.headers as Headers).get('If-Match')).toBe('"5"') + expect(body.lines).toEqual([{ invoice_line_id: LINE.id, quantity: '-2', restock: false }]) + }) + + it('refuses to edit a memo that is no longer a draft', async () => { + window.history.replaceState(null, '', '/credit-memos/m9/edit') + stubAll({ '/api/v1/credit-memos/m9': () => creditMemo({ id: 'm9', status: 'open', number: 'CM-000001' }) }) + el = await mountAsync('gable-credit-memo-form', { routeId: 'm9' }) + expect(text(el)).toContain('Only a draft credit memo can be edited') + }) +}) diff --git a/web/apps/desk/src/pages/credit-memos/CreditMemoForm.ts b/web/apps/desk/src/pages/credit-memos/CreditMemoForm.ts new file mode 100644 index 00000000..86a32b27 --- /dev/null +++ b/web/apps/desk/src/pages/credit-memos/CreditMemoForm.ts @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { LitElement, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { router } from '../../lib/router.ts'; +import { ToastService } from '../../lib/toast-service.ts'; +import { InvoiceService } from '../../services/InvoiceService.ts'; +import { CreditMemoService } from '../../services/CreditMemoService.ts'; +import { CustomerService } from '../../services/CustomerService.ts'; +import { ApiError, apiErrorMessage, fieldErrorMap } from '../../services/apiError.ts'; +import type { Invoice, InvoiceLine } from '../../types/invoice.ts'; +import { + type CreditMemo, + type CreditMemoLineRequest, + type CreditMemoRequest, + type CreditReasonCode, + CREDIT_REASON_CODES, + formatReasonCode, +} from '../../types/creditMemo.ts'; +import { formatCents, formatPrice4 } from '../../lib/utils.ts'; +import { dollarsToTenThousandths, extensionCents, tenThousandthsToInput } from '../../lib/money.ts'; +import { + creditQuantityFromInput, + creditQuantityToInput, + quantityToScaled, + remainingCreditable, +} from '../../lib/credit-display.ts'; + +/** One invoice line the user may return: what they typed and whether the goods go back on hand. */ +interface ReturnRow { + line: InvoiceLine; + qtyInput: string; + restock: boolean; + /** What is still creditable: billed less the credits the page knows of. */ + remaining: string; +} + +/** A free line: a price adjustment or a fee, charge code ADJUST. */ +interface FreeRow { + description: string; + qtyInput: string; + priceInput: string; +} + +/** The invoice lines a credit memo can name: sold goods and charges, never a note or a kit component. */ +export function creditableLines(invoice: Invoice): InvoiceLine[] { + return invoice.lines + .filter(l => l.line_type === 'product' || l.line_type === 'kit' || l.line_type === 'charge') + .sort((a, b) => a.position - b.position); +} + +/** + * The request the form's rows make. Returns the problems per row key instead + * when an input is invalid, so the page shows them beside the inputs. + */ +export function buildCreditMemoRequest( + base: { invoiceId?: string; customerId?: string; reasonCode: CreditReasonCode; reason: string }, + returns: ReturnRow[], + frees: FreeRow[], +): { request: CreditMemoRequest; errors: Record } { + const errors: Record = {}; + const lines: CreditMemoLineRequest[] = []; + returns.forEach(r => { + if (r.qtyInput.trim() === '') return; + const qty = creditQuantityFromInput(r.qtyInput); + if (qty === null) { + errors[`return:${r.line.id}`] = 'Enter a quantity greater than zero'; + return; + } + const asked = quantityToScaled(creditQuantityToInput(qty)) ?? 0; + const left = quantityToScaled(r.remaining) ?? 0; + if (asked > left) { + errors[`return:${r.line.id}`] = `Only ${r.remaining} remain to credit`; + return; + } + const req: CreditMemoLineRequest = { invoice_line_id: r.line.id, quantity: qty }; + if (r.line.line_type === 'product' && r.line.product_id !== null) req.restock = r.restock; + lines.push(req); + }); + frees.forEach((f, i) => { + if (f.description.trim() === '' && f.priceInput.trim() === '') return; + const qty = creditQuantityFromInput(f.qtyInput); + const price = dollarsToTenThousandths(f.priceInput); + if (f.description.trim() === '') errors[`free:${i}`] = 'Describe the adjustment'; + else if (qty === null) errors[`free:${i}`] = 'Enter a quantity greater than zero'; + else if (price === null || price <= 0) errors[`free:${i}`] = 'Enter a price greater than zero'; + else lines.push({ line_type: 'charge', charge_code: 'ADJUST', quantity: qty, unit_price_ten_thousandths: price, description: f.description.trim() }); + }); + if (lines.length === 0 && Object.keys(errors).length === 0) errors.lines = 'Add at least one line to credit'; + if (base.reason.trim() === '') errors.reason = 'Give a reason'; + const request: CreditMemoRequest = { reason_code: base.reasonCode, reason: base.reason.trim(), lines }; + if (base.invoiceId) request.invoice_id = base.invoiceId; + else if (base.customerId) request.customer_id = base.customerId; + else errors.customer = 'Choose a customer'; + return { request, errors }; +} + +/** The credit before tax the rows add up to, in cents, negative: each line is priced from its invoice line. */ +export function estimateCreditCents(returns: ReturnRow[], frees: FreeRow[]): number { + let total = 0; + for (const r of returns) { + const qty = creditQuantityFromInput(r.qtyInput); + if (qty === null || r.line.unit_price_ten_thousandths === null) continue; + total -= extensionCents(creditQuantityToInput(qty), r.line.unit_price_ten_thousandths, r.line.uom_qty ?? '1', r.line.price_uom_qty ?? '1'); + } + for (const f of frees) { + const qty = creditQuantityFromInput(f.qtyInput); + const price = dollarsToTenThousandths(f.priceInput); + if (qty === null || price === null) continue; + total -= extensionCents(creditQuantityToInput(qty), price); + } + return total; +} + +@customElement('gable-credit-memo-form') +export class GableCreditMemoForm extends LitElement { + createRenderRoot() { return this; } + + /** Set on the edit route (/credit-memos/:id/edit); empty on /credit-memos/new. */ + @property({ attribute: 'route-id' }) routeId = ''; + + @state() private invoice: Invoice | null = null; + @state() private editing: CreditMemo | null = null; + @state() private customers: { id: string; name: string }[] = []; + @state() private customerId = ''; + @state() private returns: ReturnRow[] = []; + @state() private frees: FreeRow[] = []; + @state() private reasonCode: CreditReasonCode = 'return'; + @state() private reason = ''; + @state() private loading = true; + @state() private loadError = ''; + @state() private saving = false; + @state() private errors: Record = {}; + @state() private formError = ''; + + connectedCallback() { + super.connectedCallback(); + this.init(); + } + + private async init() { + try { + if (this.routeId) { + const memo = await CreditMemoService.getCreditMemo(this.routeId); + if (memo.status !== 'draft') { + this.loadError = 'Only a draft credit memo can be edited.'; + return; + } + this.editing = memo; + this.reasonCode = memo.reason_code; + this.reason = memo.reason; + this.customerId = memo.customer_id; + if (memo.invoice_id) await this.loadInvoice(memo.invoice_id, memo); + else this.frees = memo.lines.map(l => this.freeFromLine(l)); + } else { + const invoiceId = new URLSearchParams(window.location.search).get('invoice_id'); + if (invoiceId) await this.loadInvoice(invoiceId, null); + else await this.loadCustomers(); + } + if (this.frees.length === 0) this.frees = [{ description: '', qtyInput: '1', priceInput: '' }]; + } catch (err) { + console.error(err); + this.loadError = apiErrorMessage(err, 'Failed to load the credit memo form'); + } finally { + this.loading = false; + } + } + + private freeFromLine(l: CreditMemo['lines'][number]): FreeRow { + return { + description: l.description, + qtyInput: creditQuantityToInput(l.quantity) || '1', + priceInput: l.unit_price_ten_thousandths !== null ? tenThousandthsToInput(l.unit_price_ten_thousandths) : '', + }; + } + + private async loadCustomers() { + const all = await CustomerService.listAllCustomers(); + this.customers = all.map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Loads the invoice and the credits already written against it, so each + * line shows what is still creditable. The page knows the credit memos of + * this invoice only; the server holds the final word (409 exceeds_billed). + */ + private async loadInvoice(invoiceId: string, editing: CreditMemo | null) { + const invoice = await InvoiceService.getInvoice(invoiceId); + this.invoice = invoice; + this.customerId = invoice.customer_id; + const summaries = await CreditMemoService.allCreditMemos({ invoiceId }); + const credited = new Map(); + for (const s of summaries) { + if (s.status === 'void' || s.id === editing?.id) continue; + const full = await CreditMemoService.getCreditMemo(s.id); + for (const l of full.lines) { + if (l.invoice_line_id && l.quantity) { + credited.set(l.invoice_line_id, [...(credited.get(l.invoice_line_id) ?? []), l.quantity]); + } + } + } + this.returns = creditableLines(invoice).map(line => { + const existing = editing?.lines.find(l => l.invoice_line_id === line.id); + return { + line, + qtyInput: existing ? creditQuantityToInput(existing.quantity) : '', + restock: existing ? existing.restock : true, + remaining: remainingCreditable(line.quantity, credited.get(line.id) ?? []) ?? '0', + }; + }); + if (editing) this.frees = editing.lines.filter(l => !l.invoice_line_id).map(l => this.freeFromLine(l)); + } + + private setReturn(i: number, patch: Partial) { + this.returns = this.returns.map((r, j) => (j === i ? { ...r, ...patch } : r)); + this.errors = {}; + } + + private setFree(i: number, patch: Partial) { + this.frees = this.frees.map((f, j) => (j === i ? { ...f, ...patch } : f)); + this.errors = {}; + } + + private async save() { + this.formError = ''; + const { request, errors } = buildCreditMemoRequest( + { invoiceId: this.invoice?.id, customerId: this.customerId, reasonCode: this.reasonCode, reason: this.reason }, + this.returns, + this.frees, + ); + this.errors = errors; + if (Object.keys(errors).length > 0) return; + this.saving = true; + try { + const saved = this.editing + ? await CreditMemoService.updateCreditMemo(this.editing.id, this.editing.revision, request) + : await CreditMemoService.createCreditMemo(request); + ToastService.show(this.editing ? 'Draft credit memo saved' : 'Draft credit memo created', 'success'); + router.navigate(`/credit-memos/${saved.id}`); + } catch (err) { + if (err instanceof ApiError && err.isStaleRevision && this.editing) { + ToastService.show('The credit memo changed while you were editing. It has been reloaded.', 'error'); + this.loading = true; + await this.init(); + } else { + this.formError = apiErrorMessage(err, 'Failed to save the credit memo'); + this.errors = fieldErrorMap(err); + } + } finally { + this.saving = false; + } + } + + private cancel() { + router.navigate(this.editing ? `/credit-memos/${this.editing.id}` : this.invoice ? `/invoices/${this.invoice.id}` : '/credit-memos'); + } + + render() { + if (this.loading) return html`
Loading...
`; + if (this.loadError) { + return html` +
+

Cannot open the form

+

${this.loadError}

+ Back to credit memos +
+ `; + } + + const estimate = estimateCreditCents(this.returns, this.frees); + const title = this.editing ? 'Edit draft credit memo' : 'New credit memo'; + return html` +
+
+

${title}

+

+ ${this.invoice + ? html`Against invoice ${this.invoice.number} for ${this.invoice.customer_name}` + : 'A credit with no invoice: adjustment lines only. To return goods, start from the invoice.'} +

+
+ +
+ Saved as a draft first. + Nothing moves until you post it. Posting assigns the credit memo number, puts the lines marked restock back on hand, and posts the credit to the ledger. + A draft can still be edited or voided. +
+ + ${this.formError ? html`` : nothing} + + ${!this.invoice ? html` +
+ + + ${this.errors.customer ? html`` : nothing} +
+ ` : html` +
+
+

Goods and charges to credit

+

Type the quantity coming back. A credit takes its price from the invoice line and cannot exceed what was billed.

+
+
+ + + + + + + + + + + + + ${this.returns.map((r, i) => html` + + + + + + + + + `)} + +
ItemBilledCan creditUnit priceCredit qtyRestock
+
${r.line.sku || r.line.charge_code || r.line.line_type}
+
${r.line.description}
+
${r.line.quantity}${r.line.uom ? html` ${r.line.uom}` : nothing}${r.remaining}${r.line.unit_price_ten_thousandths !== null ? formatPrice4(r.line.unit_price_ten_thousandths) : '—'} + this.setReturn(i, { qtyInput: (e.target as HTMLInputElement).value })} + class="bg-black/30 border ${this.errors[`return:${r.line.id}`] ? 'border-red-500' : 'border-white/10'} rounded px-3 py-1.5 text-white font-mono text-sm w-28 text-right disabled:opacity-40" + /> + ${this.errors[`return:${r.line.id}`] ? html`` : nothing} + + ${r.line.line_type === 'product' && r.line.product_id !== null ? html` + this.setReturn(i, { restock: (e.target as HTMLInputElement).checked })} + class="accent-[#00FFA3]" + /> + ` : html`-`} +
+
+
+ `} + +
+
+
+

Price adjustment or fee

+

A free line, booked as charge ADJUST: a price correction, a restocking fee given back, a goodwill credit.

+
+ +
+ ${this.frees.map((f, i) => html` +
+ this.setFree(i, { description: (e.target as HTMLInputElement).value })} + class="bg-black/30 border ${this.errors[`free:${i}`] ? 'border-red-500' : 'border-white/10'} rounded px-3 py-1.5 text-white text-sm" + /> + this.setFree(i, { qtyInput: (e.target as HTMLInputElement).value })} + class="bg-black/30 border border-white/10 rounded px-3 py-1.5 text-white font-mono text-sm text-right" + /> + this.setFree(i, { priceInput: (e.target as HTMLInputElement).value })} + class="bg-black/30 border border-white/10 rounded px-3 py-1.5 text-white font-mono text-sm text-right" + /> + + ${this.errors[`free:${i}`] ? html`` : nothing} +
+ `)} +
+ +
+
+ + +
+
+ + + ${this.errors.reason ? html`` : nothing} +
+
+ + ${this.errors.lines ? html`` : nothing} + +
+
+
Credit before tax (estimate)
+
${formatCents(estimate)}
+
Tax and discounts are worked out by the server when the draft is saved.
+
+
+ + +
+
+
+ `; + } +} diff --git a/web/apps/desk/src/pages/credit-memos/CreditMemoList.test.ts b/web/apps/desk/src/pages/credit-memos/CreditMemoList.test.ts new file mode 100644 index 00000000..6e3e1411 --- /dev/null +++ b/web/apps/desk/src/pages/credit-memos/CreditMemoList.test.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './CreditMemoList' +import type { GableCreditMemoList } from './CreditMemoList' +import { mountAsync, text, flush } from '../../test/dom' +import { creditMemoSummary, routedFetch } from '../../test/salesFixtures' + +describe('gable-credit-memo-list', () => { + let el: GableCreditMemoList + let state: ReturnType + + beforeEach(() => { + state = routedFetch({ + '/api/v1/credit-memos': () => ({ + items: [ + creditMemoSummary({ id: 'a', number: 'CM-000002', status: 'open', total_cents: -4400 }), + creditMemoSummary({ id: 'b', number: null, status: 'draft', total_cents: -7055 }), + creditMemoSummary({ id: 'c', number: 'CM-000001', status: 'void', total_cents: -100, reason_code: 'damage' }), + ], + next_cursor: 'more', + limit: 50, + total: 7, + }), + '/api/v1/customers': () => ({ items: [], next_cursor: null, limit: 200 }), + }) + vi.stubGlobal('fetch', vi.fn(state.fn)) + }) + afterEach(() => vi.unstubAllGlobals()) + + it('shows a draft as Draft, a posted memo by number, and the total as a credit', async () => { + el = await mountAsync('gable-credit-memo-list') + const rows = Array.from(el.querySelectorAll('tbody tr')) + expect(text(rows[0])).toContain('CM-000002') + expect(text(rows[0])).toContain('-$44.00') + expect(rows[0].querySelector('td.text-red-400')).not.toBeNull() + expect(text(rows[1])).toContain('Draft') + expect(text(rows[1])).toContain('-$70.55') + expect(text(rows[2])).toContain('Damage') + expect(text(rows[2])).toContain('Void') + expect(text(el.querySelector('[data-testid="credit-memo-total"]'))).toBe('7 credit memos') + }) + + it('pages by cursor and filters by status under the contract names', async () => { + el = await mountAsync('gable-credit-memo-list') + const more = Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes('Load more')) as HTMLButtonElement + more.click() + await flush() + await el.updateComplete + let last = state.calls.filter(c => c.url.pathname === '/api/v1/credit-memos').pop()! + expect(last.url.searchParams.get('cursor')).toBe('more') + const select = el.querySelector('select[aria-label="Status"]') as HTMLSelectElement + select.value = 'draft' + select.dispatchEvent(new Event('change')) + await flush() + await el.updateComplete + last = state.calls.filter(c => c.url.pathname === '/api/v1/credit-memos').pop()! + expect(last.url.searchParams.get('status')).toBe('draft') + expect(last.url.searchParams.has('cursor')).toBe(false) + }) + + it('reads an invoice filter from the address', async () => { + window.history.replaceState(null, '', '/credit-memos?invoice_id=11111111-2222-4333-8444-555555555555') + try { + el = await mountAsync('gable-credit-memo-list') + const first = state.calls.find(c => c.url.pathname === '/api/v1/credit-memos')! + expect(first.url.searchParams.get('invoice_id')).toBe('11111111-2222-4333-8444-555555555555') + } finally { + window.history.replaceState(null, '', '/') + } + }) + + it('shows an empty state', async () => { + vi.stubGlobal('fetch', vi.fn(routedFetch({ '/api/v1/credit-memos': () => ({ items: [], next_cursor: null, limit: 50, total: 0 }), '/api/v1/customers': () => ({ items: [], next_cursor: null, limit: 200 }) }).fn)) + const empty = await mountAsync('gable-credit-memo-list') + expect(empty.querySelector('[data-testid="credit-memo-empty"]')?.textContent).toContain('No credit memos') + }) +}) diff --git a/web/apps/desk/src/pages/credit-memos/CreditMemoList.ts b/web/apps/desk/src/pages/credit-memos/CreditMemoList.ts new file mode 100644 index 00000000..d0b35a0c --- /dev/null +++ b/web/apps/desk/src/pages/credit-memos/CreditMemoList.ts @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { LitElement, html, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { icon } from '../../lib/icons.ts'; +import { router } from '../../lib/router.ts'; +import { ArrowRight } from 'lucide'; +import { CreditMemoService } from '../../services/CreditMemoService.ts'; +import { CustomerService } from '../../services/CustomerService.ts'; +import { apiErrorMessage } from '../../services/apiError.ts'; +import { + type CreditMemoSummary, + type CreditMemoStatus, + CREDIT_MEMO_STATUSES, + creditMemoLabel, + formatCreditMemoStatus, + formatReasonCode, + getCreditMemoStatusColor, +} from '../../types/creditMemo.ts'; +import { chipClass } from '../../components/invoices/chips.ts'; +import { onBranchChanged } from '../../lib/branch-listener.ts'; +import { formatCents, formatDay } from '../../lib/utils.ts'; +import { creditTextClass } from '../../lib/credit-display.ts'; + +const PAGE_SIZE = 50; + +@customElement('gable-credit-memo-list') +export class GableCreditMemoList extends LitElement { + createRenderRoot() { return this; } + + @state() private memos: CreditMemoSummary[] = []; + @state() private loading = true; + @state() private refreshing = false; + @state() private error: string | null = null; + @state() private status: CreditMemoStatus | 'all' = 'all'; + @state() private customerId = ''; + @state() private invoiceId = ''; + @state() private customers: { id: string; name: string }[] = []; + @state() private nextCursor: string | null = null; + @state() private total: number | undefined; + private _unsubBranch: (() => void) | null = null; + + connectedCallback() { + super.connectedCallback(); + // an invoice link may carry its filter: /credit-memos?invoice_id=... + this.invoiceId = new URLSearchParams(window.location.search).get('invoice_id') ?? ''; + this.loadMemos(); + this.loadCustomers(); + this._unsubBranch = onBranchChanged(() => { + this.loading = true; + this.loadMemos(); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._unsubBranch) { + this._unsubBranch(); + this._unsubBranch = null; + } + } + + private filterParams() { + return { + status: this.status === 'all' ? undefined : this.status, + customerId: this.customerId || undefined, + invoiceId: this.invoiceId.trim() || undefined, + limit: PAGE_SIZE, + }; + } + + private async loadCustomers() { + try { + const all = await CustomerService.listAllCustomers(); + this.customers = all.map(c => ({ id: c.id, name: c.name })).sort((a, b) => a.name.localeCompare(b.name)); + } catch (err) { + console.error('Failed to load customers for the filter', err); + } + } + + private async loadMemos() { + try { + this.error = null; + const page = await CreditMemoService.listCreditMemos({ ...this.filterParams(), includeTotal: true }); + this.memos = page.items; + this.nextCursor = page.next_cursor; + this.total = page.total; + } catch (err) { + console.error('Failed to load credit memos:', err); + this.error = apiErrorMessage(err, 'Failed to load credit memos'); + } finally { + this.loading = false; + this.refreshing = false; + } + } + + private async loadMore() { + if (!this.nextCursor) return; + try { + const page = await CreditMemoService.listCreditMemos({ ...this.filterParams(), cursor: this.nextCursor }); + this.memos = [...this.memos, ...page.items]; + this.nextCursor = page.next_cursor; + } catch (err) { + this.error = apiErrorMessage(err, 'Failed to load more credit memos'); + } + } + + private refilter() { + this.refreshing = true; + this.loadMemos(); + } + + render() { + if (this.loading) { + return html`
Loading credit memos...
`; + } + + if (this.error && this.memos.length === 0) { + return html` +
+

Failed to load

+

${this.error}

+ +
+ `; + } + + return html` +
+
+
+

Credit Memos

+ ${this.total !== undefined ? html`

${this.total} credit memo${this.total === 1 ? '' : 's'}

` : nothing} +
+ New credit memo +
+ +
+ + + ${this.invoiceId ? html` + + Invoice ${this.invoiceId.slice(0, 8)} + + + ` : nothing} + ${this.refreshing ? html`Updating...` : nothing} +
+ + ${this.error ? html`` : nothing} + + ${this.memos.length === 0 ? html` +
+ ${this.status !== 'all' || this.customerId || this.invoiceId ? 'No credit memos match these filters.' : 'No credit memos yet. Start one from an invoice.'} +
+ ` : html` +
+
+ + + + + + + + + + + + + + + ${this.memos.map(cm => html` + router.navigate(`/credit-memos/${cm.id}`)}> + + + + + + + + + + `)} + +
NumberCustomerInvoiceReasonDateTotalStatus
${creditMemoLabel(cm)}${cm.customer_name || cm.customer_id.slice(0, 8)} + ${cm.invoice_id + ? html` e.stopPropagation()} class="text-blue-400 hover:underline">${cm.invoice_id.slice(0, 8)}` + : html`none`} + ${formatReasonCode(cm.reason_code)}${formatDay(cm.memo_date)}${formatCents(cm.total_cents)}${formatCreditMemoStatus(cm.status)}${icon(ArrowRight, 16, 'text-zinc-500')}
+
+
+ ${this.nextCursor ? html` +
+ +
+ ` : nothing} + `} +
+ `; + } +} diff --git a/web/apps/desk/src/pages/invoices/InvoiceDetail.test.ts b/web/apps/desk/src/pages/invoices/InvoiceDetail.test.ts new file mode 100644 index 00000000..ae6b3adb --- /dev/null +++ b/web/apps/desk/src/pages/invoices/InvoiceDetail.test.ts @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The invoice page on the wire contract (C2-3): the document number, the new + * line shape, cents money and decimal string quantities, the Overdue badge, the + * void dialog with the revision and the server's blockers, the VOID banner, and + * the credit memos listed against the invoice. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './InvoiceDetail' +import type { GableInvoiceDetail } from './InvoiceDetail' +import { mountAsync, text, flush } from '../../test/dom' +import { creditMemoSummary, invoice, invoiceLine, routedFetch, INVOICE_ID, ORDER_ID } from '../../test/salesFixtures' +import type { Invoice } from '../../types/invoice' + +function stub(inv: Invoice, extra: Record unknown> = {}) { + const state = routedFetch({ + [`/api/v1/invoices/${INVOICE_ID}`]: () => inv, + [`/api/v1/invoices/${INVOICE_ID}/payments`]: () => [], + '/api/v1/credit-memos': () => ({ items: [], next_cursor: null, limit: 100 }), + [`/api/v1/orders/${ORDER_ID}`]: () => ({ id: ORDER_ID, number: 'SO-000007' }), + ...extra, + }) + vi.stubGlobal('fetch', vi.fn(state.fn)) + return state +} + +function buttonByText(el: Element, label: string): HTMLButtonElement { + return Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes(label)) as HTMLButtonElement +} + +describe('gable-invoice-detail', () => { + let el: GableInvoiceDetail + afterEach(() => vi.unstubAllGlobals()) + + describe('an unpaid invoice', () => { + beforeEach(() => { stub(invoice()) }) + + it('renders the document number, the lowercase status and the order link by number', async () => { + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + const body = text(el) + expect(body).toContain('IN-000042') + expect(body).toContain('Unpaid') + expect(body).toContain('SO-000007') + expect(el.querySelector(`a[href="/orders/${ORDER_ID}"]`)).not.toBeNull() + }) + + it('renders cents as dollars: line, subtotal, tax with its rate and source, total and open amount', async () => { + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + const totals = text(el.querySelector('[data-testid="invoice-totals"]')) + expect(totals).toContain('$777.60') + expect(totals).toContain('8.875%') + expect(totals).toContain('branch rate') + expect(totals).toContain('$69.02') + expect(totals).toContain('$846.62') + expect(text(el.querySelector('[data-testid="open-amount"]'))).toBe('$846.62') + }) + + it('renders the scaled unit price with its price unit and the decimal string quantity', async () => { + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + const row = text(el.querySelector('tr[data-line-type="product"]')) + expect(row).toContain('LUM-248-PREM') + expect(row).toContain('24') + expect(row).toContain('PCS') + expect(row).toContain('$32.40') + expect(row).toContain('$777.60') + }) + + it('shows cost and margin on the lines for the roles the server sends cost to', async () => { + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + const headers = Array.from(el.querySelectorAll('thead th')).map(th => th.textContent?.trim()) + expect(headers).toContain('Cost') + expect(headers).toContain('Margin') + const row = text(el.querySelector('tr[data-line-type="product"]')) + expect(row).toContain('$547.20') + expect(row).toContain('$230.40') + }) + + it('shows no cost column when no line carries a cost', async () => { + stub(invoice({ lines: [invoiceLine({ unit_cost_ten_thousandths: null, cost_cents: 0 })] })) + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + const headers = Array.from(el.querySelectorAll('thead th')).map(th => th.textContent?.trim()) + expect(headers).not.toContain('Cost') + }) + + it('offers print, email, create credit memo (prefilled) and void', async () => { + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + expect(text(el)).toContain('Print PDF') + expect(text(el)).toContain('Email') + const link = el.querySelector('a[href^="/credit-memos/new"]') as HTMLAnchorElement + expect(link.getAttribute('href')).toBe(`/credit-memos/new?invoice_id=${INVOICE_ID}`) + expect(buttonByText(el, 'Void')).toBeTruthy() + }) + + it('keeps every header button label on one line', async () => { + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + const header = el.querySelector('h1')!.closest('div.border-b')! + for (const b of Array.from(header.querySelectorAll('button, a'))) expect(b.className).toContain('whitespace-nowrap') + }) + }) + + it('shows the Overdue badge from is_overdue and the due date in red', async () => { + stub(invoice({ is_overdue: true, due_date: '2026-07-01' })) + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + expect(el.querySelector('[data-testid="overdue-badge"]')).not.toBeNull() + expect(el.querySelector('[data-testid="due-date"]')!.className).toContain('text-red-400') + }) + + it('shows the discount terms and the ship-to snapshot when the invoice carries them', async () => { + stub(invoice({ + discount_percent: '2.0000', + discount_due_date: '2026-08-21', + ship_to: { id: 's1', code: 'MAIN', name: 'Maple Ridge site', line1: '12 Quarry Rd', line2: null, city: 'Kelowna', region: 'BC', postal_code: 'V1Y 1A1', country: null, phone: null, delivery_instructions: null }, + })) + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + expect(text(el)).toContain('Early payment') + expect(text(el)).toContain('2.0000%') + expect(text(el.querySelector('[data-testid="ship-to"]'))).toContain('12 Quarry Rd') + }) + + it('renders a kit with its components indented and a text line as a note', async () => { + stub(invoice({ + lines: [ + invoiceLine({ id: 'k', line_type: 'kit', sku: 'KIT-1', description: 'Deck kit', position: 0 }), + invoiceLine({ id: 'c1', line_type: 'component', parent_line_id: 'k', sku: 'SCR-1', description: 'Screws', position: 1, line_total_cents: null }), + invoiceLine({ id: 't', line_type: 'text', description: 'Leave at the gate', position: 2, quantity: null, unit_price_ten_thousandths: null, line_total_cents: null }), + ], + })) + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + expect(el.querySelector('tr[data-line-type="component"] td')!.className).toContain('pl-10') + const note = el.querySelector('tr[data-line-type="text"]')! + expect(text(note)).toBe('Leave at the gate') + }) + + it('lists the credit memos against the invoice as credits', async () => { + stub(invoice(), { + '/api/v1/credit-memos': () => ({ items: [creditMemoSummary({ id: 'm1', number: 'CM-000003', status: 'open' }), creditMemoSummary({ id: 'm2', number: null, status: 'draft' })], next_cursor: null, limit: 100 }), + }) + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + const table = el.querySelector('table[aria-label="Credit memos for this invoice"]')! + expect(text(table)).toContain('CM-000003') + expect(text(table)).toContain('Draft') + expect(text(table)).toContain('-$70.55') + expect(table.querySelector('td.text-red-400')).not.toBeNull() + }) + + describe('voiding', () => { + it('requires a reason, sends the revision and shows the VOID banner afterwards', async () => { + const state = stub(invoice(), { + [`POST /api/v1/invoices/${INVOICE_ID}/transitions`]: () => invoice({ status: 'void', revision: 3, void_reason: 'billed twice', voided_at: '2026-08-12T10:00:00Z', open_cents: 0 }), + }) + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + buttonByText(el, 'Void').click() + await el.updateComplete + const dialog = el.querySelector('gable-sales-dialog')! + const confirmBtn = Array.from(dialog.querySelectorAll('button')).find(b => b.textContent?.includes('Void invoice')) as HTMLButtonElement + expect(confirmBtn.disabled).toBe(true) + const textarea = dialog.querySelector('textarea') as HTMLTextAreaElement + textarea.value = 'billed twice' + textarea.dispatchEvent(new Event('input')) + await flush() + await (dialog as unknown as { updateComplete: Promise }).updateComplete + confirmBtn.click() + await flush() + await flush() + await el.updateComplete + const call = state.calls.find(c => c.url.pathname.endsWith('/transitions'))! + expect(JSON.parse(String(call.init!.body))).toEqual({ to: 'void', revision: 2, reason: 'billed twice' }) + const banner = el.querySelector('[data-testid="void-banner"]')! + expect(text(banner)).toContain('VOID') + expect(text(banner)).toContain('billed twice') + // a void invoice has no actions + expect(text(el)).not.toContain('Print PDF') + expect(buttonByText(el, 'Void invoice')).toBeFalsy() + }) + + it('keeps the dialog open and shows the server blocker in plain words', async () => { + stub(invoice(), { + [`POST /api/v1/invoices/${INVOICE_ID}/transitions`]: () => + new Response(JSON.stringify({ error: { code: 'conflict', message: 'a credit memo names the invoice: void the credit memos first', details: [{ code: 'has_credit_memos', message: 'x' }] }, meta: {} }), { status: 409, headers: { 'Content-Type': 'application/json' } }), + }) + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + buttonByText(el, 'Void').click() + await el.updateComplete + const dialog = el.querySelector('gable-sales-dialog')! + const textarea = dialog.querySelector('textarea') as HTMLTextAreaElement + textarea.value = 'oops' + textarea.dispatchEvent(new Event('input')) + await flush() + await (dialog as unknown as { updateComplete: Promise }).updateComplete + ;(Array.from(dialog.querySelectorAll('button')).find(b => b.textContent?.includes('Void invoice')) as HTMLButtonElement).click() + await flush() + await flush() + await el.updateComplete + await (dialog as unknown as { updateComplete: Promise }).updateComplete + const err = text(el.querySelector('[data-testid="dialog-error"]')) + expect(err).toContain('void the credit memos first') + expect(err).toContain('Void its credit memos first') + expect(el.querySelector('[role="dialog"]')).not.toBeNull() + }) + }) + + it('shows the void banner with its reason and no actions on a void invoice', async () => { + stub(invoice({ status: 'void', void_reason: 'entered against the wrong customer', open_cents: 0 })) + el = await mountAsync('gable-invoice-detail', { routeId: INVOICE_ID }) + expect(text(el.querySelector('[data-testid="void-banner"]'))).toContain('entered against the wrong customer') + expect(text(el)).not.toContain('Email') + expect(el.querySelector('a[href^="/credit-memos/new"]')).toBeNull() + }) +}) diff --git a/web/apps/desk/src/pages/invoices/InvoiceList.test.ts b/web/apps/desk/src/pages/invoices/InvoiceList.test.ts new file mode 100644 index 00000000..76c48212 --- /dev/null +++ b/web/apps/desk/src/pages/invoices/InvoiceList.test.ts @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The invoice list on the wire contract (C2-3): the cursor envelope, the + * document number, lowercase statuses with the computed Overdue badge, cents + * money, the filters and the load-more cursor. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './InvoiceList' +import type { GableInvoiceList } from './InvoiceList' +import { mountAsync, text, flush } from '../../test/dom' +import { invoiceSummary, routedFetch } from '../../test/salesFixtures' + +describe('gable-invoice-list', () => { + let el: GableInvoiceList + let state: ReturnType + + function page(items: ReturnType[], next: string | null = null, total?: number) { + return { items, next_cursor: next, limit: 50, ...(total !== undefined ? { total } : {}) } + } + + beforeEach(() => { + state = routedFetch({ + '/api/v1/invoices': () => + page( + [ + invoiceSummary({ id: 'a', number: 'IN-000002', status: 'unpaid', is_overdue: true, due_date: '2026-07-01' }), + invoiceSummary({ id: 'b', number: 'IN-000001', status: 'paid', open_cents: 0, is_overdue: false }), + invoiceSummary({ id: 'c', number: 'IN-000003', status: 'written_off', open_cents: 0 }), + ], + 'cursor-2', + 12, + ), + '/api/v1/customers': () => ({ items: [{ id: 'c1', name: 'Ridgeview Framing' }], next_cursor: null, limit: 200 }), + }) + vi.stubGlobal('fetch', vi.fn(state.fn)) + }) + afterEach(() => vi.unstubAllGlobals()) + + it('lists the document number, cents money and the total from include=total', async () => { + el = await mountAsync('gable-invoice-list') + const body = text(el) + expect(body).toContain('IN-000002') + expect(body).toContain('$846.62') + expect(el.querySelector('[data-testid="invoice-total"]')?.textContent).toContain('12 invoices') + const call = state.calls.find(c => c.url.pathname === '/api/v1/invoices')! + expect(call.url.searchParams.get('include')).toBe('total') + }) + + it('shows lowercase statuses as words and an Overdue badge only where is_overdue is set', async () => { + el = await mountAsync('gable-invoice-list') + const rows = Array.from(el.querySelectorAll('tbody tr')) + expect(text(rows[0])).toContain('Unpaid') + expect(text(rows[0])).toContain('Overdue') + expect(text(rows[1])).toContain('Paid') + expect(text(rows[1])).not.toContain('Overdue') + expect(text(rows[2])).toContain('Written Off') + expect(el.querySelectorAll('[data-testid="overdue-badge"]').length).toBe(1) + }) + + it('offers Load more while a cursor remains and sends it on the next request', async () => { + el = await mountAsync('gable-invoice-list') + const more = Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes('Load more')) as HTMLButtonElement + expect(more).toBeTruthy() + more.click() + await flush() + await el.updateComplete + const last = state.calls.filter(c => c.url.pathname === '/api/v1/invoices').pop()! + expect(last.url.searchParams.get('cursor')).toBe('cursor-2') + expect(last.url.searchParams.has('include')).toBe(false) + }) + + it('sends the status and overdue filters under their contract names', async () => { + el = await mountAsync('gable-invoice-list') + const select = el.querySelector('select[aria-label="Status"]') as HTMLSelectElement + select.value = 'partial' + select.dispatchEvent(new Event('change')) + await flush() + const overdue = el.querySelector('input[aria-label="Overdue only"]') as HTMLInputElement + overdue.checked = true + overdue.dispatchEvent(new Event('change')) + await flush() + await el.updateComplete + const last = state.calls.filter(c => c.url.pathname === '/api/v1/invoices').pop()! + expect(last.url.searchParams.get('status')).toBe('partial') + expect(last.url.searchParams.get('overdue')).toBe('true') + }) + + it('shows an empty state that names the filter, and an error state with Retry', async () => { + vi.stubGlobal('fetch', vi.fn(routedFetch({ '/api/v1/invoices': () => page([], null, 0), '/api/v1/customers': () => page([]) }).fn)) + el = await mountAsync('gable-invoice-list') + expect(el.querySelector('[data-testid="invoice-empty"]')?.textContent).toContain('No invoices') + + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { code: 'internal', message: 'boom here' }, meta: {} }), { status: 500, headers: { 'Content-Type': 'application/json' } }))) + const failed = await mountAsync('gable-invoice-list') + expect(text(failed)).toContain('boom here') + expect(text(failed)).toContain('Retry') + }) +}) diff --git a/web/apps/desk/src/pages/orders/OrderDetail.ts b/web/apps/desk/src/pages/orders/OrderDetail.ts index 3bae810c..03d19b40 100644 --- a/web/apps/desk/src/pages/orders/OrderDetail.ts +++ b/web/apps/desk/src/pages/orders/OrderDetail.ts @@ -6,6 +6,7 @@ import { customElement, property, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { ToastService } from '../../lib/toast-service.ts'; import { OrderService } from '../../services/OrderService.ts'; +import { InvoiceService } from '../../services/InvoiceService.ts'; import { SalesTeamService } from '../../services/SalesTeamService.ts'; import { type Order, type OrderStatus, formatOrderStatus, getStatusColor } from '../../types/order.ts'; import type { SalesPerson } from '../../types/salesteam.ts'; @@ -22,6 +23,7 @@ export class GableOrderDetail extends LitElement { @state() private order: Order | null = null; @state() private salesperson: SalesPerson | null = null; + @state() private invoiceNumbers: Record = {}; @state() private loading = true; @state() private error = false; @state() private processing = false; @@ -45,6 +47,7 @@ export class GableOrderDetail extends LitElement { try { const data = await OrderService.getOrder(orderId); this.order = data; + this.loadInvoiceNumbers(data.id, data.invoice_ids); if (data.salesperson_id) { try { const sp = await SalesTeamService.getSalesPerson(data.salesperson_id); @@ -62,6 +65,19 @@ export class GableOrderDetail extends LitElement { } } + /** The numbers of the invoices this order billed, for the links; the id stands in until they arrive. */ + private async loadInvoiceNumbers(orderId: string, invoiceIds: string[]) { + if (invoiceIds.length === 0) return; + try { + const page = await InvoiceService.listInvoices({ orderId, limit: 100 }); + const numbers: Record = {}; + for (const inv of Array.isArray(page?.items) ? page.items : []) numbers[inv.id] = inv.number; + this.invoiceNumbers = numbers; + } catch { + // the number is a convenience: the link works by id + } + } + /** * The confirm is the transition (ADR 0005 5.2). An over-the-limit * customer lands the order on the credit hold: the answer is the held @@ -457,7 +473,7 @@ export class GableOrderDetail extends LitElement { ${order.invoice_ids.length > 0 ? html`
${order.invoice_ids.map(id => html` - ${id.slice(0, 8)} + ${this.invoiceNumbers[id] ?? id.slice(0, 8)} `)}
` : html` diff --git a/web/apps/desk/src/pages/portal/PortalInvoices.test.ts b/web/apps/desk/src/pages/portal/PortalInvoices.test.ts new file mode 100644 index 00000000..8ff1549f --- /dev/null +++ b/web/apps/desk/src/pages/portal/PortalInvoices.test.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The portal invoice page after C2-3: its own float-dollar DTO, now with the + * document number and the computed overdue flag. OVERDUE is no longer a status; + * WRITTEN_OFF is labelled; dollars are formatted directly, never divided. + */ +import { describe, it, expect, afterEach, vi } from 'vitest' +import './PortalInvoices' +import type { PortalInvoices } from './PortalInvoices' +import { portalStatusLabel } from './PortalInvoices' +import type { PortalInvoice } from '../../types/portal' +import { mountAsync, text, jsonResponse } from '../../test/dom' + +function invoice(overrides: Partial = {}): PortalInvoice { + return { + id: 'aaaaaaaa-1111-2222-3333-444444444444', + number: 'IN-000042', + is_overdue: false, + order_id: 'bbbbbbbb-1111-2222-3333-444444444444', + status: 'UNPAID', + total_amount: 846.62, + subtotal: 777.6, + tax_amount: 69.02, + payment_terms: 'NET30', + due_date: '2026-09-10T00:00:00Z', + paid_at: null, + created_at: '2026-08-11T14:00:00Z', + lines: [], + ...overrides, + } +} + +describe('gable-portal-invoices', () => { + let el: PortalInvoices + afterEach(() => vi.unstubAllGlobals()) + + async function mountWith(invoices: PortalInvoice[]) { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(invoices))) + el = await mountAsync('gable-portal-invoices') + return el + } + + it('shows the document number, not a made-up id prefix, and dollars as they arrive', async () => { + await mountWith([invoice()]) + const body = text(el) + expect(body).toContain('IN-000042') + expect(body).not.toContain('INV-AAAAAAAA') + expect(body).toContain('$846.62') + expect(body).not.toContain('$8.47') + }) + + it('derives the Overdue badge from is_overdue and keeps the status label', async () => { + await mountWith([invoice({ is_overdue: true }), invoice({ id: 'x', number: 'IN-000043' })]) + expect(el.querySelectorAll('[data-testid="portal-overdue"]').length).toBe(1) + expect(text(el)).toContain('Unpaid') + }) + + it('labels a written off invoice and never shows an OVERDUE status', async () => { + await mountWith([invoice({ status: 'WRITTEN_OFF', number: 'IN-000044' }), invoice({ status: 'PARTIAL', number: 'IN-000045' })]) + const body = text(el) + expect(body).toContain('Written off') + expect(body).toContain('Partial') + expect(body).not.toContain('OVERDUE') + expect(portalStatusLabel('WRITTEN_OFF')).toBe('Written off') + }) +}) diff --git a/web/apps/desk/src/pages/portal/PortalInvoices.ts b/web/apps/desk/src/pages/portal/PortalInvoices.ts index ab0ee779..1ab3041e 100644 --- a/web/apps/desk/src/pages/portal/PortalInvoices.ts +++ b/web/apps/desk/src/pages/portal/PortalInvoices.ts @@ -17,9 +17,9 @@ const statusConfig = (status: string): { color: string; bgColor: string } => { const map: Record = { PAID: { color: '#00FFA3', bgColor: 'rgba(0,255,163,0.1)' }, UNPAID: { color: '#F59E0B', bgColor: 'rgba(245,158,11,0.1)' }, - OVERDUE: { color: '#F43F5E', bgColor: 'rgba(244,63,94,0.1)' }, PARTIAL: { color: '#38BDF8', bgColor: 'rgba(56,189,248,0.1)' }, VOID: { color: '#71717A', bgColor: 'rgba(113,113,122,0.1)' }, + WRITTEN_OFF: { color: '#71717A', bgColor: 'rgba(113,113,122,0.1)' }, }; return map[status] || map.UNPAID; }; @@ -27,11 +27,15 @@ const statusConfig = (status: string): { color: string; bgColor: string } => { const INVOICE_STATUS_COLORS: Record = { PAID: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', UNPAID: 'bg-amber-500/10 text-amber-400 border-amber-500/20', - OVERDUE: 'bg-red-500/10 text-red-400 border-red-500/20', PARTIAL: 'bg-blue-500/10 text-blue-400 border-blue-500/20', VOID: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20', + WRITTEN_OFF: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20', }; +/** The label a status shows: WRITTEN_OFF reads "Written off". */ +export const portalStatusLabel = (status: string): string => + status.replace(/_/g, ' ').toLowerCase().replace(/^\w/, c => c.toUpperCase()); + @customElement('gable-portal-invoices') export class PortalInvoices extends LitElement { createRenderRoot() { return this; } @@ -56,9 +60,9 @@ export class PortalInvoices extends LitElement { private _handleDownloadPDF(invoice: PortalInvoice) { const content = [ - `INVOICE ${invoice.id.substring(0, 8).toUpperCase()}`, + `INVOICE ${invoice.number}`, `Date: ${new Date(invoice.created_at).toLocaleDateString()}`, - `Status: ${invoice.status}`, + `Status: ${portalStatusLabel(invoice.status)}${invoice.is_overdue ? ' (overdue)' : ''}`, `Payment Terms: ${invoice.payment_terms}`, invoice.due_date ? `Due Date: ${new Date(invoice.due_date).toLocaleDateString()}` : '', '', @@ -71,7 +75,7 @@ export class PortalInvoices extends LitElement { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `invoice-${invoice.id.substring(0, 8)}.txt`; + a.download = `invoice-${invoice.number}.txt`; a.click(); URL.revokeObjectURL(url); } @@ -130,7 +134,7 @@ export class PortalInvoices extends LitElement {
- INV-${inv.id.substring(0, 8).toUpperCase()} + ${inv.number}
${new Date(inv.created_at).toLocaleDateString()}${inv.due_date ? html` · Due ${new Date(inv.due_date).toLocaleDateString()}` : ''} @@ -141,8 +145,9 @@ export class PortalInvoices extends LitElement {
${formatCurrency(inv.total_amount)}
- ${inv.status} + ${portalStatusLabel(inv.status)} + ${inv.is_overdue ? html`Overdue` : ''}