From dabe8e0f7f9e890f44dbca5f78da3856aeb08c30 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 05:41:11 -0700 Subject: [PATCH 01/32] ap: migration 106 ap_wire_contract The C4-1b migration of ADR 0008 section 12: vendor_invoices gains created_at NOT NULL, revision, the number (AP-, a sequence, backfilled in (created_at, id) order with the DEFAULT minting through the writer's transaction), the branch (from the purchase order's branch when that column exists, else the default branch; a defaults trigger serves raw inserts), the currency, a status CHECK on today's values (lowercase spellings uppercased and unknown values become PENDING, each with a notice), po_id as a real foreign key (orphans set null, with a notice), UNIQUE (vendor_id, invoice_number) after duplicates are suffixed #2, #3 in (created_at, id) order, amount_open and gl_entry_id; the lines gain position, purchase_order_line_id, product_id, a unit_price widened to scale 4 and po_freight_charge_id (the carrier freight line link of 7.3, written from package C's freight rule on); the keyset indexes. The test builds the awkward legacy rows in a scratch database (three duplicates of one vendor number, an orphan po_id, a NULL created_at, odd statuses, paid, partial and voided rows), reads every backfill back, checks the constraints refuse what the old columns took, applies the migration a second time (a no-op), runs the down (which keeps the suffixed duplicates, normalized statuses and nulled orphans, and refuses while a unit price is finer than cents) and applies again. Signed-off-by: colton --- core/internal/ap/migration106_test.go | 350 ++++++++++++++++++ core/migrations/106_ap_wire_contract.sql | 226 +++++++++++ .../down/106_ap_wire_contract_down.sql | 64 ++++ 3 files changed, 640 insertions(+) create mode 100644 core/internal/ap/migration106_test.go create mode 100644 core/migrations/106_ap_wire_contract.sql create mode 100644 core/migrations/down/106_ap_wire_contract_down.sql diff --git a/core/internal/ap/migration106_test.go b/core/internal/ap/migration106_test.go new file mode 100644 index 000000000..91e2d7aac --- /dev/null +++ b/core/internal/ap/migration106_test.go @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap_test + +// Migration 106 on rows that exist (the module recipe's step 3, ADR 0008 +// section 12): the schema up to 102 is built in a scratch database, awkward +// legacy bills are written in the shape the base left them (duplicate vendor +// numbers, an orphan po_id, a NULL created_at, odd statuses, paid and partial +// rows), then 106 is applied and every backfill is read back: the duplicate +// suffixes in (created_at, id) order, the orphan set null, the NULL created_at +// filled, the statuses normalized, the numbers minted and unique, the branch +// and currency filled, amount_open computed, the constraints in force. The +// migration applies a second time (a no-op), the down file rolls the shape +// back (refusing while a unit price is finer than cents), and the migration +// applies again to the same result. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +func scratch106(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_c41bmig_" + 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(context.Background()) + }) + 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 files106(t *testing.T) (before []string, target, down 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, "106_"): + target = f + case base < "106_": + before = append(before, f) + } + } + if target == "" { + t.Fatal("migration 106 not found") + } + return before, target, "../../migrations/down/106_ap_wire_contract_down.sql" +} + +func apply106(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 one106(t *testing.T, conn *pgx.Conn, sql string, args ...any) string { + t.Helper() + var v string + if err := conn.QueryRow(context.Background(), sql, args...).Scan(&v); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return v +} + +func count106(t *testing.T, conn *pgx.Conn, sql string, args ...any) int { + t.Helper() + var n int + if err := conn.QueryRow(context.Background(), sql, args...).Scan(&n); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return n +} + +// refuses asserts the named constraint refuses the statement. +func refuses106(t *testing.T, conn *pgx.Conn, constraint, sql string, args ...any) { + t.Helper() + ctx := context.Background() + tx, err := conn.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(ctx) + _, err = tx.Exec(ctx, sql, args...) + var pg *pgconn.PgError + if err == nil || !errors.As(err, &pg) || pg.ConstraintName != constraint { + t.Errorf("expected constraint %s to refuse, got %v", constraint, err) + } +} + +func TestMigration106_OnAwkwardLegacyRows(t *testing.T) { + conn, notices := scratch106(t) + before, target, down := files106(t) + for _, f := range before { + apply106(t, conn, f) + } + + // The awkward legacy rows: duplicates of one vendor number (three, with + // distinct created_at so the suffix order is checkable), an orphan po_id, + // a NULL created_at (the base column was NOT NULL, so the NOT NULL comes + // off first to write one), odd statuses, and paid, partial and voided rows. + ctx := context.Background() + var vendor uuid.UUID + if err := conn.QueryRow(ctx, `INSERT INTO vendors (id, name) VALUES ($1, 'Mig 106 Vendor') RETURNING id`, uuid.New()).Scan(&vendor); err != nil { + t.Fatal(err) + } + insert := func(id uuid.UUID, number, status, createdAt string, poID *uuid.UUID, total, paid string) { + t.Helper() + var po any + if poID != nil { + po = *poID + } + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoices (id, vendor_id, invoice_number, invoice_date, due_date, po_id, + subtotal, tax_amount, total, amount_paid, status, created_at) + VALUES ($1, $2, $3, '2026-01-10', '2026-02-10', $4, $5, 0, $5, $6, $7, $8)`, + id, vendor, number, po, total, paid, status, createdAt); err != nil { + t.Fatalf("seed %s: %v", number, err) + } + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoice_lines (id, invoice_id, description, quantity, unit_price, line_total, created_at) + VALUES ($1, $2, 'legacy line', 1, $3, $3, $4)`, uuid.New(), id, total, createdAt); err != nil { + t.Fatalf("seed line %s: %v", number, err) + } + } + at := func(days int) string { return time.Now().AddDate(0, 0, days).Format(time.RFC3339) } + orphan := uuid.New() + dup1, dup2, dup3 := uuid.New(), uuid.New(), uuid.New() + insert(dup1, "DUP-1", "PENDING", at(-9), nil, "100.00", "0") + insert(dup2, "DUP-1", "pending", at(-8), nil, "100.00", "0") // lowercase: normalized + insert(dup3, "DUP-1", "WEIRD", at(-7), nil, "100.00", "0") // unknown: becomes PENDING + nailed := uuid.New() + insert(nailed, "PAID-1", "PAID", at(-6), nil, "50.00", "50.00") + partial := uuid.New() + insert(partial, "PART-1", "PARTIAL", at(-5), nil, "80.00", "30.00") + voided := uuid.New() + insert(voided, "VOID-1", "VOIDED", at(-4), &orphan, "70.00", "0") + // A NULL created_at: the base column was NOT NULL, so it comes off first + // (the migration's own fill-then-set step then has a row to fill). + if _, err := conn.Exec(ctx, `ALTER TABLE vendor_invoices ALTER COLUMN created_at DROP NOT NULL`); err != nil { + t.Fatal(err) + } + nulled := uuid.New() + insert(nulled, "NULL-CREATED", "PENDING", at(-3), nil, "10.00", "0") + if _, err := conn.Exec(ctx, `UPDATE vendor_invoices SET created_at = NULL WHERE id = $1`, nulled); err != nil { + t.Fatal(err) + } + + rowsBefore := count106(t, conn, `SELECT count(*) FROM vendor_invoices`) + + apply106(t, conn, target) + + // Counts before and after: no row lost or gained. + if rowsAfter := count106(t, conn, `SELECT count(*) FROM vendor_invoices`); rowsAfter != rowsBefore { + t.Fatalf("%d rows before, %d after", rowsBefore, rowsAfter) + } + + // The duplicates are suffixed in (created_at, id) order: the oldest keeps + // the bare number. + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dup1); got != "DUP-1" { + t.Errorf("the oldest duplicate = %q, want the bare DUP-1", got) + } + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dup2); got != "DUP-1 #2" { + t.Errorf("the second duplicate = %q, want DUP-1 #2", got) + } + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dup3); got != "DUP-1 #3" { + t.Errorf("the third duplicate = %q, want DUP-1 #3", got) + } + + // The orphan po_id is set null; the others keep theirs. + if got := one106(t, conn, `SELECT COALESCE(po_id::text, 'null') FROM vendor_invoices WHERE id = $1`, voided); got != "null" { + t.Errorf("the orphan po_id = %s, want null", got) + } + + // The NULL created_at is filled. + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE created_at IS NULL`); got != 0 { + t.Errorf("%d rows still hold a NULL created_at", got) + } + + // The odd statuses are normalized, with notices. + if got := one106(t, conn, `SELECT status FROM vendor_invoices WHERE id = $1`, dup2); got != "PENDING" { + t.Errorf("the lowercase status = %s, want PENDING", got) + } + if got := one106(t, conn, `SELECT status FROM vendor_invoices WHERE id = $1`, dup3); got != "PENDING" { + t.Errorf("the unknown status = %s, want PENDING", got) + } + joined := strings.Join(*notices, " | ") + for _, want := range []string{"duplicate vendor invoice number", "po_id is set null", "unknown status"} { + if !strings.Contains(joined, want) { + t.Errorf("the notices (%s) do not mention %q", joined, want) + } + } + + // The numbers: unique, AP-, and the sequence sits past the backfill. + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE number !~ '^AP-[0-9]+$'`); got != 0 { + t.Errorf("%d rows hold no AP- number", got) + } + if got := count106(t, conn, `SELECT count(DISTINCT number) - count(*) FROM vendor_invoices`); got != 0 { + t.Errorf("%d duplicate numbers", got) + } + // The backfill order is (created_at, id): the oldest bill holds the + // smallest number. + if got := one106(t, conn, `SELECT number FROM vendor_invoices WHERE id = $1`, dup1); got != "AP-000001" { + t.Errorf("the oldest bill's number = %s, want AP-000001", got) + } + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoices (vendor_id, invoice_number, invoice_date, due_date, subtotal, tax_amount, total) + VALUES ($1, 'AFTER-1', '2026-01-10', '2026-02-10', 1, 0, 1) RETURNING id`, vendor); err != nil { + t.Fatal(err) + } + if got := one106(t, conn, `SELECT number FROM vendor_invoices WHERE invoice_number = 'AFTER-1'`); got != fmt.Sprintf("AP-%06d", rowsBefore+1) { + t.Errorf("a bill created after the migration = %s, want the next number past the backfill", got) + } + + // The branch and the currency are filled. + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE branch_id IS NULL OR currency IS NULL`); got != 0 { + t.Errorf("%d rows hold no branch or currency", got) + } + def := one106(t, conn, `SELECT value FROM system_settings WHERE key = 'default_branch_id'`) + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE branch_id::text <> $1`, def); got != 0 { + t.Errorf("%d rows are not at the default branch (no purchase order branch exists yet)", got) + } + + // amount_open: total less paid, and zero on a voided bill. + for id, want := range map[uuid.UUID]string{dup1: "100.00", nailed: "0.00", partial: "50.00", voided: "0.00"} { + if got := one106(t, conn, `SELECT amount_open::text FROM vendor_invoices WHERE id = $1`, id); got != want { + t.Errorf("amount_open of %s = %s, want %s", id, got, want) + } + } + + // The lines: positioned in (created_at, id) order, and the price widened. + if got := one106(t, conn, `SELECT position::text FROM vendor_invoice_lines l JOIN vendor_invoices v ON v.id = l.invoice_id WHERE v.id = $1`, dup1); got != "0" { + t.Errorf("the single legacy line's position = %s, want 0", got) + } + + // The constraints refuse what the old column took. + refuses106(t, conn, "vendor_invoices_vendor_number_key", + `INSERT INTO vendor_invoices (vendor_id, invoice_number, invoice_date, due_date, subtotal, tax_amount, total) VALUES ($1, 'DUP-1', '2026-01-10', '2026-02-10', 1, 0, 1)`, vendor) + refuses106(t, conn, "vendor_invoices_status_check", + `UPDATE vendor_invoices SET status = 'WEIRD' WHERE id = $1`, dup1) + refuses106(t, conn, "vendor_invoices_po_id_fkey", + `UPDATE vendor_invoices SET po_id = $2 WHERE id = $1`, dup1, uuid.New()) + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoice_lines WHERE purchase_order_line_id IS NOT NULL OR product_id IS NOT NULL OR po_freight_charge_id IS NOT NULL`); got != 0 { + t.Errorf("%d legacy lines were linked to something", got) + } + + // A second up is a no-op: every count and value stands. + numbersBefore := one106(t, conn, `SELECT string_agg(number, ',' ORDER BY number) FROM vendor_invoices`) + apply106(t, conn, target) + if numbersAfter := one106(t, conn, `SELECT string_agg(number, ',' ORDER BY number) FROM vendor_invoices`); numbersAfter != numbersBefore { + t.Errorf("a second apply changed the numbers: %s -> %s", numbersBefore, numbersAfter) + } + + // The down rolls the shape back. + apply106(t, conn, down) + for _, column := range []string{"number", "revision", "branch_id", "currency", "amount_open"} { + if got := count106(t, conn, `SELECT count(*) FROM information_schema.columns WHERE table_name = 'vendor_invoices' AND column_name = $1`, column); got != 0 { + t.Errorf("the down left %s on vendor_invoices", column) + } + } + if got := count106(t, conn, `SELECT count(*) FROM information_schema.columns WHERE table_name = 'vendor_invoice_lines' AND column_name = 'position'`); got != 0 { + t.Error("the down left position on vendor_invoice_lines") + } + if got := one106(t, conn, `SELECT data_type FROM information_schema.columns WHERE table_name = 'vendor_invoice_lines' AND column_name = 'unit_price'`); got != "numeric" { + t.Error("the down left unit_price's type changed") + } + if got := one106(t, conn, `SELECT numeric_scale::text FROM information_schema.columns WHERE table_name = 'vendor_invoice_lines' AND column_name = 'unit_price'`); got != "2" { + t.Errorf("the down left unit_price at scale %s, want 2", got) + } + // Three of the up's writes are kept, each valid old shape data. + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dup2); got != "DUP-1 #2" { + t.Errorf("the down un-suffixed a duplicate: %s", got) + } + + // The down refuses while a unit price is finer than cents. + apply106(t, conn, target) + if _, err := conn.Exec(ctx, `UPDATE vendor_invoice_lines SET unit_price = 1.0005`); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(context.Background(), func() string { + b, _ := os.ReadFile(down) + return string(b) + }()); err == nil { + t.Error("the down applied though a unit price is finer than cents") + } + // Clean that row, and the down applies; the up then applies again to the + // same result. + if _, err := conn.Exec(ctx, `UPDATE vendor_invoice_lines SET unit_price = 1.00`); err != nil { + t.Fatal(err) + } + apply106(t, conn, down) + apply106(t, conn, target) + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE number IS NULL OR amount_open IS NULL`); got != 0 { + t.Errorf("%d rows incomplete after the down-up cycle", got) + } +} diff --git a/core/migrations/106_ap_wire_contract.sql b/core/migrations/106_ap_wire_contract.sql new file mode 100644 index 000000000..cc44044d1 --- /dev/null +++ b/core/migrations/106_ap_wire_contract.sql @@ -0,0 +1,226 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- 106: the AP fixes onto the wire contract (item C4-1b, ADR 0008 sections +-- 7.4 and 12, on ADR 0001). The steps are the record's C4-1b list, in order: +-- +-- 1 vendor_invoices: created_at NOT NULL, revision, the number (AP-), the +-- branch (from the purchase order's branch when that column exists, +-- else the default branch), the currency, a status CHECK on today's +-- values (unknown values become PENDING, with a notice), po_id as a +-- real foreign key (orphans are set null, with a notice), UNIQUE +-- (vendor_id, invoice_number) after duplicates are suffixed #2, #3 in +-- (created_at, id) order (with a notice), and amount_open. +-- 2 vendor_invoice_lines: position, purchase_order_line_id, product_id, +-- unit_price widened to NUMERIC(12,4), po_freight_charge_id (the +-- carrier freight line link of 7.3, written from package C's freight +-- rule on). Existing lines are not linked: today's lines have no +-- reliable key to their purchase line, and the old positional pairing +-- is exactly the defect. +-- 3 Keyset indexes on vendor invoices. +-- +-- Every statement is guarded so a second apply is a no-op. + +-- 1. vendor_invoices -------------------------------------------------------- + +UPDATE vendor_invoices SET created_at = NOW() WHERE created_at IS NULL; +ALTER TABLE vendor_invoices ALTER COLUMN created_at SET NOT NULL; +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 1; + +-- The branch: the purchase order's when C4-1a's column is already there, +-- else the default branch for every row. +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS branch_id UUID NULL REFERENCES locations (id); +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'current_schema'::text AND table_name = 'purchase_orders' AND column_name = 'branch_id') THEN + EXECUTE $sql$UPDATE vendor_invoices vi SET branch_id = po.branch_id + FROM purchase_orders po + WHERE po.id = vi.po_id AND vi.branch_id IS NULL AND po.branch_id IS NOT NULL$sql$; + END IF; +END $$; +DO $$ +DECLARE missing BIGINT; +BEGIN + SELECT count(*) INTO missing FROM vendor_invoices WHERE branch_id IS NULL; + UPDATE vendor_invoices + SET branch_id = (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id') + WHERE branch_id IS NULL; + IF missing > 0 THEN + RAISE NOTICE 'migration 106: % vendor invoice(s) had no branch and take the default branch', missing; + END IF; +END $$; +ALTER TABLE vendor_invoices ALTER COLUMN branch_id SET NOT NULL; + +-- A raw insert (the seed, the characterization fixtures) carries no branch, +-- currency or open amount: it opens at the default branch, in the default +-- currency, owing its whole total, as the service would set it (the invoices +-- migration's amount_open trigger is the shape). +CREATE OR REPLACE FUNCTION vendor_invoices_defaults() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.branch_id IS NULL THEN + NEW.branch_id := (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'); + END IF; + IF NEW.currency IS NULL THEN + NEW.currency := COALESCE((SELECT value FROM system_settings WHERE key = 'currency.default'), 'USD'); + END IF; + IF NEW.amount_open IS NULL THEN + NEW.amount_open := CASE WHEN NEW.status IN ('VOIDED', 'PAID') THEN 0 ELSE NEW.total END; + END IF; + RETURN NEW; +END $$; +DROP TRIGGER IF EXISTS trg_vendor_invoices_default_branch ON vendor_invoices; +DROP TRIGGER IF EXISTS trg_vendor_invoices_defaults ON vendor_invoices; +CREATE TRIGGER trg_vendor_invoices_defaults BEFORE INSERT ON vendor_invoices + FOR EACH ROW EXECUTE FUNCTION vendor_invoices_defaults(); + +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS currency CHAR(3) NULL; +UPDATE vendor_invoices SET currency = COALESCE((SELECT s.value FROM system_settings s WHERE s.key = 'currency.default'), 'USD') +WHERE currency IS NULL; +ALTER TABLE vendor_invoices ALTER COLUMN currency SET NOT NULL; +DO $$ BEGIN + ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_currency_format CHECK (currency ~ '^[A-Z]{3}$'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- The status CHECK on today's values: lowercase spellings are uppercased and +-- anything unknown becomes PENDING, each with a notice. +DO $$ +DECLARE lower BIGINT; unknown BIGINT; +BEGIN + SELECT count(*) INTO lower FROM vendor_invoices WHERE status <> UPPER(btrim(status)); + IF lower > 0 THEN + UPDATE vendor_invoices SET status = UPPER(btrim(status)) WHERE status <> UPPER(btrim(status)); + RAISE NOTICE 'migration 106: % vendor invoice status value(s) were not uppercase and are uppercased', lower; + END IF; + SELECT count(*) INTO unknown FROM vendor_invoices + WHERE status NOT IN ('PENDING', 'APPROVED', 'PARTIAL', 'PAID', 'VOIDED'); + IF unknown > 0 THEN + UPDATE vendor_invoices SET status = 'PENDING' + WHERE status NOT IN ('PENDING', 'APPROVED', 'PARTIAL', 'PAID', 'VOIDED'); + RAISE NOTICE 'migration 106: % vendor invoice(s) held an unknown status and become PENDING', unknown; + END IF; +END $$; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_status_check; +ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_status_check + CHECK (status IN ('PENDING', 'APPROVED', 'PARTIAL', 'PAID', 'VOIDED')); + +-- po_id becomes a real foreign key; an orphan is set null, with a notice. +DO $$ +DECLARE orphans BIGINT; +BEGIN + SELECT count(*) INTO orphans FROM vendor_invoices vi + WHERE vi.po_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM purchase_orders po WHERE po.id = vi.po_id); + IF orphans > 0 THEN + UPDATE vendor_invoices vi SET po_id = NULL + WHERE vi.po_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM purchase_orders po WHERE po.id = vi.po_id); + RAISE NOTICE 'migration 106: % vendor invoice(s) named no purchase order and their po_id is set null', orphans; + END IF; +END $$; +DO $$ BEGIN + ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_po_id_fkey; + ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_po_id_fkey + FOREIGN KEY (po_id) REFERENCES purchase_orders (id) ON DELETE SET NULL; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Gable's own number, AP-, from a sequence (gapped: a create that rolls back +-- abandons its number), the payment migration's shape. +CREATE SEQUENCE IF NOT EXISTS vendor_invoice_number_seq; +CREATE OR REPLACE FUNCTION vendor_invoice_next_number() RETURNS TEXT +LANGUAGE sql AS $$ + SELECT 'AP-' || lpad(s.n::text, GREATEST(6, length(s.n::text)), '0') + FROM (SELECT nextval('vendor_invoice_number_seq') AS n) s +$$; +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS number TEXT; +WITH ordered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) AS n FROM vendor_invoices WHERE number IS NULL +) +UPDATE vendor_invoices p +SET number = 'AP-' || lpad((o.n + COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM vendor_invoices WHERE number ~ '^AP-[0-9]+$'), 0))::text, + GREATEST(6, length((o.n + COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM vendor_invoices WHERE number ~ '^AP-[0-9]+$'), 0))::text)), '0') +FROM ordered o WHERE p.id = o.id; +SELECT setval('vendor_invoice_number_seq', + GREATEST(COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM vendor_invoices WHERE number ~ '^AP-[0-9]+$'), 0) + 1, + (SELECT CASE WHEN is_called THEN last_value + 1 ELSE last_value END FROM vendor_invoice_number_seq)), + false); +ALTER TABLE vendor_invoices ALTER COLUMN number SET DEFAULT vendor_invoice_next_number(); +ALTER TABLE vendor_invoices ALTER COLUMN number SET NOT NULL; +DO $$ BEGIN + ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_number_key UNIQUE (number); +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; +END $$; + +-- The vendor's own number is unique per vendor once duplicates are suffixed +-- #2, #3 in (created_at, id) order, the oldest keeping the bare number. +DO $$ +DECLARE dups BIGINT; +BEGIN + SELECT count(*) - count(DISTINCT (vendor_id, invoice_number)) INTO dups FROM vendor_invoices; + IF dups > 0 THEN + WITH ranked AS ( + SELECT id, row_number() OVER (PARTITION BY vendor_id, invoice_number ORDER BY created_at, id) AS rn + FROM vendor_invoices + ) + UPDATE vendor_invoices vi + SET invoice_number = left(vi.invoice_number, 64 - length(' #' || r.rn)) || ' #' || r.rn + FROM ranked r + WHERE r.id = vi.id AND r.rn > 1 + AND vi.invoice_number <> left(vi.invoice_number, 64 - length(' #' || r.rn)) || ' #' || r.rn; + RAISE NOTICE 'migration 106: % duplicate vendor invoice number(s) suffixed #2, #3 in (created_at, id) order', dups; + END IF; +END $$; +DO $$ BEGIN + ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_vendor_number_key UNIQUE (vendor_id, invoice_number); +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; +END $$; + +-- The entry the approval posts, which the void transition reverses. +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS gl_entry_id UUID NULL REFERENCES gl_journal_entries (id); + +-- amount_open: what the vendor is still owed, zero on a voided bill. +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS amount_open NUMERIC(12, 2) NULL; +UPDATE vendor_invoices SET amount_open = CASE WHEN status = 'VOIDED' THEN 0 ELSE GREATEST(0, total - amount_paid) END +WHERE amount_open IS NULL; +ALTER TABLE vendor_invoices ALTER COLUMN amount_open SET NOT NULL; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_amount_open_range; +ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_amount_open_range + CHECK (amount_open >= 0 AND amount_open <= total); + +-- 2. vendor_invoice_lines --------------------------------------------------- + +ALTER TABLE vendor_invoice_lines ADD COLUMN IF NOT EXISTS position INTEGER NULL; + +-- A raw line insert (the characterization fixtures) carries no position: it +-- takes the next position of its bill. +CREATE OR REPLACE FUNCTION vendor_invoice_lines_default_position() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.position IS NULL THEN + NEW.position := (SELECT COALESCE(MAX(position) + 1, 0) FROM vendor_invoice_lines WHERE invoice_id = NEW.invoice_id); + END IF; + RETURN NEW; +END $$; +DROP TRIGGER IF EXISTS trg_vendor_invoice_lines_default_position ON vendor_invoice_lines; +CREATE TRIGGER trg_vendor_invoice_lines_default_position BEFORE INSERT ON vendor_invoice_lines + FOR EACH ROW EXECUTE FUNCTION vendor_invoice_lines_default_position(); +WITH ordered AS ( + SELECT id, row_number() OVER (PARTITION BY invoice_id ORDER BY created_at, id) - 1 AS pos + FROM vendor_invoice_lines +) +UPDATE vendor_invoice_lines l SET position = o.pos FROM ordered o WHERE l.id = o.id AND l.position IS NULL; +ALTER TABLE vendor_invoice_lines ALTER COLUMN position SET NOT NULL; +ALTER TABLE vendor_invoice_lines ADD COLUMN IF NOT EXISTS purchase_order_line_id UUID NULL + REFERENCES purchase_order_lines (id) ON DELETE SET NULL; +ALTER TABLE vendor_invoice_lines ADD COLUMN IF NOT EXISTS product_id UUID NULL + REFERENCES products (id) ON DELETE SET NULL; +ALTER TABLE vendor_invoice_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 4); +ALTER TABLE vendor_invoice_lines ADD COLUMN IF NOT EXISTS po_freight_charge_id UUID NULL + REFERENCES po_freight_charges (id) ON DELETE SET NULL; + +-- 3. Keyset indexes --------------------------------------------------------- + +CREATE INDEX IF NOT EXISTS idx_vendor_invoices_created_id ON vendor_invoices (created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_vendor_invoices_vendor_created ON vendor_invoices (vendor_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_vendor_invoices_po ON vendor_invoices (po_id) WHERE po_id IS NOT NULL; diff --git a/core/migrations/down/106_ap_wire_contract_down.sql b/core/migrations/down/106_ap_wire_contract_down.sql new file mode 100644 index 000000000..3e1bfd807 --- /dev/null +++ b/core/migrations/down/106_ap_wire_contract_down.sql @@ -0,0 +1,64 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Down of 106_ap_wire_contract (item C4-1b). Each step reverses its own, and +-- refuses, naming the rows it cannot map back, wherever data written in the +-- new shape has no place in the old one (a down never discards a dealer's +-- data): +-- +-- 3 drops the keyset indexes; +-- 2 drops the line link columns and the position, and narrows unit_price +-- back to scale 2 only when every stored value is a whole cent, raising +-- an exception naming the count otherwise; +-- 1 drops amount_open, the number and its sequence, the currency, the +-- revision and the branch, the two uniqueness constraints and the +-- status CHECK. Three of the up's writes are kept, each valid old shape +-- data: the suffixed duplicate vendor numbers stay suffixed (a dealer +-- may have had a genuine "X #2" before, so un-suffixing cannot be told +-- apart from it), the normalized statuses stay normalized, and a po_id +-- the up orphaned stays null (the purchase order it named is gone). + +-- 3 ------------------------------------------------------------------------- + +DROP INDEX IF EXISTS idx_vendor_invoices_po; +DROP INDEX IF EXISTS idx_vendor_invoices_vendor_created; +DROP INDEX IF EXISTS idx_vendor_invoices_created_id; + +-- 2 ------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_vendor_invoice_lines_default_position ON vendor_invoice_lines; +DROP FUNCTION IF EXISTS vendor_invoice_lines_default_position(); +ALTER TABLE vendor_invoice_lines DROP COLUMN IF EXISTS po_freight_charge_id; +ALTER TABLE vendor_invoice_lines DROP COLUMN IF EXISTS product_id; +ALTER TABLE vendor_invoice_lines DROP COLUMN IF EXISTS purchase_order_line_id; +ALTER TABLE vendor_invoice_lines DROP COLUMN IF EXISTS position; +DO $$ +DECLARE fine BIGINT; +BEGIN + SELECT count(*) INTO fine FROM vendor_invoice_lines WHERE unit_price <> ROUND(unit_price, 2); + IF fine > 0 THEN + RAISE EXCEPTION 'vendor_invoice_lines holds % row(s) with a unit price finer than cents; the scale 2 column cannot carry them', fine; + END IF; + ALTER TABLE vendor_invoice_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 2); +END $$; + +-- 1 ------------------------------------------------------------------------- + +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_amount_open_range; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS amount_open; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS gl_entry_id; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_number_key; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_vendor_number_key; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_status_check; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_currency_format; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_po_id_fkey; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS currency; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS number; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS revision; +DROP TRIGGER IF EXISTS trg_vendor_invoices_default_branch ON vendor_invoices; +DROP TRIGGER IF EXISTS trg_vendor_invoices_defaults ON vendor_invoices; +DROP FUNCTION IF EXISTS vendor_invoices_defaults(); +DROP FUNCTION IF EXISTS vendor_invoices_default_branch(); +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS branch_id; +DROP FUNCTION IF EXISTS vendor_invoice_next_number(); +DROP SEQUENCE IF EXISTS vendor_invoice_number_seq; From 1ddab4a8d921277047f4aa10673a18f6296517f5 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 05:41:26 -0700 Subject: [PATCH 02/32] ap: the fixes of ADR 0008 7.4 on the wire contract The vendor invoice acts rebuilt on the module recipe: - approve loads the lines inside the transition's transaction and posts the entry through gl.PostEntry there (a debit leg per line on the line's account, the tax spread across the line accounts pro rata with the last line taking the remainder, the credit to 2010 for the whole total); a posting failure fails the act with its cause (a closed period the 409 period_closed blocker, a legacy line with no account the line_needs_account blocker) where the base posted an entry with only the payable leg and swallowed the error; - VOIDED is reachable by a transition that reverses the entry (a pending bill has nothing posted and simply dies; a bill with money applied is refused has_payments); - a payment applies only to approved or partial bills (the pending, paid and voided ones refused invoice_not_approved), locks them in id order with the money applying in the order the request named them, and posts its entry through gl.PostEntry inside the act (a failure fails the act); more than the named bills still owe is refused exceeds_open and writes nothing; - the wire: Gable's own number (AP-) as number, the vendor's as vendor_invoice_number, the cursor list with vendor_id, po_id and status filters, the error envelope with one 400 naming every field, revision with If-Match (428 without, 409 stale), lowercase statuses, cents and ten thousandths, the transitions route replacing /approve, POST /ap/invoices/{id}/transitions, and the branch wall on every read and write (the create's payload branch held by the guard); - vendor_invoice.created, .approved, .partial, .paid and .voided through the outbox as the last statements of their transactions, and an audit row per act; a failing event or audit write rolls the whole act back. The two broken sync functions are deleted from internal/gl (their only caller was AP) and gl gains the 2010 account code constant; the matcher reads the new invoice types and compares the wire's scale 4 line facts through its own unconverted arithmetic until its own conversion; serve wires the service with the outbox, audit log, transaction runner and branch guard behind scoped roles, as every converted module is. The tests carry the recipe's set (the create shape, exact money, the validation 400, the list and its cursor, the error envelope, the revision preconditions, the lifecycle edges with their events, idempotency, the branch wall) beside the three live failures in their converted form, the GL ties after every act (each entry balanced, the 2010 control account equal to what the open bills owe), the tax spread, the failing event and audit writes rolling each write back, the pool 4 contenders (two payments of one invoice, a payment racing a void, three racers on one revision) and the gated saturation proof. Signed-off-by: colton --- core/internal/ap/fixture_test.go | 414 +++++++++++++ core/internal/ap/gl_integration_test.go | 203 ------ core/internal/ap/handler.go | 335 +++++++--- core/internal/ap/input.go | 281 +++++++++ core/internal/ap/model.go | 190 +++--- core/internal/ap/repository.go | 567 +++++++++++------ core/internal/ap/service.go | 779 +++++++++++++++++++----- core/internal/ap/tx_test.go | 440 +++++++++++++ core/internal/ap/wire_test.go | 674 ++++++++++++++++++++ 9 files changed, 3148 insertions(+), 735 deletions(-) create mode 100644 core/internal/ap/fixture_test.go delete mode 100644 core/internal/ap/gl_integration_test.go create mode 100644 core/internal/ap/input.go create mode 100644 core/internal/ap/tx_test.go create mode 100644 core/internal/ap/wire_test.go diff --git a/core/internal/ap/fixture_test.go b/core/internal/ap/fixture_test.go new file mode 100644 index 000000000..6a5f3d172 --- /dev/null +++ b/core/internal/ap/fixture_test.go @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap_test + +// The AP module end to end (C4-1b, ADR 0008 7.4 on ADR 0001): a real +// Postgres, the real service wired as serve wires it (outbox, audit log, +// database as transaction runner, branch guard), the real handler on one mux +// behind the real idempotency middleware and a branch context stand-in. +// Requests are JSON and responses are read back as JSON, so each test states +// a wire fact. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/ap" + "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +const defaultBranch = `(SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')` + +type fixture struct { + t *testing.T + db *database.DB + srv *httptest.Server + svc *ap.Service + repo *ap.Repository + glSvc *gl.Service + vendor uuid.UUID + expense uuid.UUID // account 5020, the line account the fixture's bills use + branch uuid.UUID + prefix string +} + +// service builds a service variant with the given event recorder and audit +// sink, everything else as the fixture wires it. +func (f *fixture) service(events ap.EventRecorder, sink ap.AuditSink, tx ap.TxRunner) *ap.Service { + svc := ap.NewService(f.db, f.repo, f.glSvc, slog.Default()).WithOutbox(events).WithTxRunner(tx) + if sink != nil { + svc = svc.WithAuditLog(sink) + } + return svc +} + +// newFixture builds a vendor and serves the AP routes the way serve does. A +// request header X-Test-Sub names the acting user (the audit subject and the +// approve's approver) and X-Test-Branch puts a branch wall on the request's +// context, as the branch middleware does. +func newFixture(t *testing.T, db *database.DB) *fixture { + t.Helper() + t.Setenv("AUTH_MODE", "dev") + f := &fixture{t: t, db: db, vendor: uuid.New(), prefix: "C41B-" + uuid.NewString()[:8] + "-"} + ctx := context.Background() + if err := db.Pool.QueryRow(ctx, `SELECT `+defaultBranch).Scan(&f.branch); err != nil { + t.Fatalf("default branch: %v", err) + } + if err := db.Pool.QueryRow(ctx, `SELECT id FROM gl_accounts WHERE code = '5020'`).Scan(&f.expense); err != nil { + t.Fatalf("expense account: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO vendors (id, name, contact_email) VALUES ($1, $2, 'c41b@example.invalid')`, + f.vendor, "C41B Vendor "+f.vendor.String()[:8]); err != nil { + t.Fatal(err) + } + f.repo = ap.NewRepository(db) + f.glSvc = gl.NewService(gl.NewRepository(db), nil, slog.Default()) + f.svc = ap.NewService(db, f.repo, f.glSvc, slog.Default()). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db). + WithAuditLog(audit.NewLogger(db)).WithBranchGuard(middleware.NewBranchGuard(db)) + mux := http.NewServeMux() + ap.NewHandler(f.svc).RegisterRoutes(mux) + wallMux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := &middleware.UserClaims{Role: "finance", Roles: []string{"finance"}} + claims.Subject = r.Header.Get("X-Test-Sub") + if claims.Subject == "" { + claims.Subject = "11111111-2222-4333-8444-555555555555" + } + if role := r.Header.Get("X-Test-Role"); role != "" { + claims.Role, claims.Roles = role, []string{role} + } + ctx := context.WithValue(r.Context(), middleware.UserContextKey, claims) + // What the branch middleware settles for a real request: without a + // branch header an administrator reads every branch; X-Test-Branch + // holds the request to one branch. + bc := &branchctx.Context{UserSub: claims.Subject, IsAdmin: true} + if b := r.Header.Get("X-Test-Branch"); b != "" { + id := uuid.MustParse(b) + bc = &branchctx.Context{UserSub: claims.Subject, IsAdmin: r.Header.Get("X-Test-Admin") == "1", BranchID: &id} + } + mux.ServeHTTP(w, r.WithContext(branchctx.With(ctx, bc))) + }) + f.srv = httptest.NewServer(middleware.Idempotency(db)(wallMux)) + t.Cleanup(func() { + f.srv.Close() + f.cleanup() + }) + return f +} + +// cleanup removes everything the fixture's vendor produced, children first. +func (f *fixture) cleanup() { + ctx := context.Background() + exec := func(sql string, args ...any) { _, _ = f.db.Pool.Exec(ctx, sql, args...) } + inv := `SELECT id FROM vendor_invoices WHERE vendor_id = $1` + entries := `SELECT id FROM gl_journal_entries WHERE source_ref_id IN (` + inv + `) + OR source_ref_id IN (SELECT id FROM ap_payments WHERE vendor_id = $1)` + exec(`DELETE FROM gl_journal_lines WHERE journal_entry_id IN (SELECT id FROM gl_journal_entries WHERE reverses_entry_id IN `+entries+`)`, f.vendor) + exec(`DELETE FROM gl_journal_entries WHERE reverses_entry_id IN `+entries, f.vendor) + exec(`DELETE FROM gl_journal_lines WHERE journal_entry_id IN `+entries, f.vendor) + exec(`DELETE FROM gl_journal_entries WHERE id IN `+entries, f.vendor) + exec(`DELETE FROM events_outbox WHERE entity_type = 'vendor_invoice' AND entity_id IN `+inv, f.vendor) + exec(`DELETE FROM audit_log WHERE entity_type = 'vendor_invoice' AND entity_id IN `+inv, f.vendor) + exec(`DELETE FROM ap_payment_applications WHERE invoice_id IN `+inv, f.vendor) + exec(`DELETE FROM ap_payments WHERE vendor_id = $1`, f.vendor) + exec(`DELETE FROM vendor_invoice_lines WHERE invoice_id IN `+inv, f.vendor) + exec(`DELETE FROM vendor_invoices WHERE vendor_id = $1`, f.vendor) + exec(`DELETE FROM vendors WHERE id = $1`, f.vendor) +} + +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) + } +} + +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() + var rdr io.Reader + switch b := body.(type) { + case nil: + case string: + rdr = strings.NewReader(b) + default: + raw, err := json.Marshal(body) + if err != nil { + f.t.Fatal(err) + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, f.srv.URL+path, rdr) + if err != nil { + f.t.Fatal(err) + } + if rdr != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Request-ID", "req-ap-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 { + f.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-ap-test" { + t.Errorf("meta.request_id = %v, want the request's id", meta["request_id"]) + } + return code, blockers, fields +} + +// --------------------------------------------------------------------------- +// Making bills and payments through the routes. +// --------------------------------------------------------------------------- + +// lineBody is one bill line: quantity qty at price (dollars at scale 4 given +// as ten thousandths). +func (f *fixture) lineBody(qty string, price int64) map[string]any { + return map[string]any{"description": "2x4x8 SPF", "quantity": qty, + "unit_price_ten_thousandths": price, "gl_account_id": f.expense.String()} +} + +// createBody is a bill body of one line of qty at price (dollars at scale 4). +func (f *fixture) createBody(number string, qty string, price int64) map[string]any { + return map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": number, + "invoice_date": "2026-01-10", "due_date": "2026-02-10", + "lines": []map[string]any{f.lineBody(qty, price)}, + } +} + +// enter makes a pending bill of one line of qty at price (ten thousandths). +func (f *fixture) enter(number string, qty string, price int64) (string, int64) { + f.t.Helper() + r := f.do("POST", "/api/v1/ap/invoices", f.createBody(number, qty, price)) + if r.status != http.StatusCreated { + f.t.Fatalf("enter = %d: %s", r.status, r.raw) + } + return str(f.t, r.body, "id"), rev(f.t, r) +} + +// approve runs the approve transition. +func (f *fixture) approve(id string, revision int64) resp { + f.t.Helper() + return f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", + map[string]any{"to": "approved", "revision": revision}) +} + +// voided runs the void transition. +func (f *fixture) void(id string, revision int64, reason string) resp { + f.t.Helper() + return f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", + map[string]any{"to": "voided", "revision": revision, "reason": reason}) +} + +// pay pays cents against the invoices. +func (f *fixture) pay(cents float64, invoiceIDs ...string) resp { + f.t.Helper() + return f.do("POST", "/api/v1/ap/payments", map[string]any{ + "vendor_id": f.vendor.String(), "amount": cents / 100.0, "method": "CHECK", + "payment_date": "2026-01-15", "invoice_ids": invoiceIDs, + }) +} + +// --------------------------------------------------------------------------- +// Reading the ledger, the events and the control account. +// --------------------------------------------------------------------------- + +type leg struct{ debit, credit int64 } + +// entryLegs sums, per account code, the legs of the entries whose source +// reference is the bill. +func (f *fixture) entryLegs(invoiceID string) (entries int, legs map[string]leg) { + f.t.Helper() + return f.entryLegsWhere(f.t, f.db, `e.source_ref_id = $1`, invoiceID) +} + +func (f *fixture) entryLegsWhere(t *testing.T, db *database.DB, where string, args ...any) (int, map[string]leg) { + t.Helper() + ctx := context.Background() + var entries int + if err := db.Pool.QueryRow(ctx, `SELECT count(*) FROM gl_journal_entries e WHERE `+where, args...).Scan(&entries); err != nil { + t.Fatal(err) + } + rows, err := 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.id IN (SELECT id FROM gl_journal_entries e WHERE `+where+`) GROUP BY a.code`, args...) + if err != nil { + 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 { + t.Fatal(err) + } + legs[code] = l + } + return entries, legs +} + +// controlBalance is the AP control account's balance in cents, from the +// entries this fixture's acts wrote (approves credit it, payments and +// reversals debit it), scoped to the fixture's own bills and payments. +func (f *fixture) controlBalance() int64 { + f.t.Helper() + var balance int64 + err := f.db.Pool.QueryRow(context.Background(), ` + SELECT ROUND(COALESCE(SUM(l.credit - l.debit), 0) * 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 a.code = '2010' AND e.id IN ( + SELECT id FROM gl_journal_entries + WHERE source_ref_id IN (SELECT id FROM vendor_invoices WHERE vendor_id = $1) + OR source_ref_id IN (SELECT id FROM ap_payments WHERE vendor_id = $1) + OR reverses_entry_id IN (SELECT gl_entry_id FROM vendor_invoices WHERE vendor_id = $1))`, f.vendor).Scan(&balance) + if err != nil { + f.t.Fatal(err) + } + return balance +} + +// openSum is what the vendor's open bills owe, in cents. +func (f *fixture) openSum() int64 { + f.t.Helper() + var open int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT ROUND(COALESCE(SUM(amount_open), 0) * 100)::bigint FROM vendor_invoices WHERE vendor_id = $1 AND status IN ('APPROVED', 'PARTIAL')`, + f.vendor).Scan(&open); err != nil { + f.t.Fatal(err) + } + return open +} + +// balances answers whether every journal entry this fixture's acts wrote is +// balanced. +func (f *fixture) balanced() bool { + f.t.Helper() + var bad int64 + err := f.db.Pool.QueryRow(context.Background(), ` + SELECT count(*) FROM gl_journal_entries e + JOIN (SELECT journal_entry_id, SUM(debit) d, SUM(credit) c FROM gl_journal_lines GROUP BY journal_entry_id) s + ON s.journal_entry_id = e.id + WHERE e.id IN ( + SELECT id FROM gl_journal_entries + WHERE source_ref_id IN (SELECT id FROM vendor_invoices WHERE vendor_id = $1) + OR source_ref_id IN (SELECT id FROM ap_payments WHERE vendor_id = $1) + OR reverses_entry_id IN (SELECT gl_entry_id FROM vendor_invoices WHERE vendor_id = $1)) + AND s.d <> s.c`, f.vendor).Scan(&bad) + if err != nil { + f.t.Fatal(err) + } + return bad == 0 +} + +// eventTypes lists the vendor invoice's event types in order. +func eventTypes(t *testing.T, db *database.DB, entityID string) []string { + t.Helper() + rows, err := db.Pool.Query(context.Background(), + `SELECT type FROM events_outbox WHERE entity_type = 'vendor_invoice' AND entity_id = $1 ORDER BY position`, 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 +} + +func (f *fixture) count(sql string, args ...any) int64 { + f.t.Helper() + return countOf(f.t, f.db, sql, args...) +} diff --git a/core/internal/ap/gl_integration_test.go b/core/internal/ap/gl_integration_test.go deleted file mode 100644 index 579cfa6dd..000000000 --- a/core/internal/ap/gl_integration_test.go +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 -// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors - -package ap - -import ( - "context" - "log/slog" - "testing" - "time" - - "github.com/gablelbm/gable/internal/gl" - "github.com/google/uuid" -) - -// --- Mock Repositories --- - -type MockAPRepository struct { - invoices map[uuid.UUID]*VendorInvoice - payments map[uuid.UUID]*APPayment - invoiceLines map[uuid.UUID][]VendorInvoiceLine -} - -func (m *MockAPRepository) CreateVendorInvoice(ctx context.Context, inv *VendorInvoice) error { - m.invoices[inv.ID] = inv - return nil -} -func (m *MockAPRepository) GetVendorInvoice(ctx context.Context, id uuid.UUID) (*VendorInvoice, error) { - return m.invoices[id], nil -} -func (m *MockAPRepository) ListVendorInvoices(ctx context.Context, vID *uuid.UUID, status string) ([]VendorInvoice, error) { - var res []VendorInvoice - for _, inv := range m.invoices { - res = append(res, *inv) - } - return res, nil -} -func (m *MockAPRepository) UpdateVendorInvoice(ctx context.Context, inv *VendorInvoice) error { - m.invoices[inv.ID] = inv - return nil -} -func (m *MockAPRepository) AddInvoiceLine(ctx context.Context, line *VendorInvoiceLine) error { - m.invoiceLines[line.InvoiceID] = append(m.invoiceLines[line.InvoiceID], *line) - return nil -} -func (m *MockAPRepository) GetInvoiceLines(ctx context.Context, invoiceID uuid.UUID) ([]VendorInvoiceLine, error) { - return m.invoiceLines[invoiceID], nil -} -func (m *MockAPRepository) CreatePayment(ctx context.Context, pmt *APPayment) error { - m.payments[pmt.ID] = pmt - return nil -} -func (m *MockAPRepository) CreatePaymentApplication(ctx context.Context, app *APPaymentApplication) error { - return nil -} -func (m *MockAPRepository) ListPayments(ctx context.Context, vID *uuid.UUID) ([]APPayment, error) { - return nil, nil -} -func (m *MockAPRepository) GetAgingSummary(ctx context.Context) ([]APAgingSummary, error) { - return nil, nil -} - -type MockGLRepository struct { - entries []gl.JournalEntry -} - -func (m *MockGLRepository) CreateJournalEntry(ctx context.Context, entry *gl.JournalEntry) error { - m.entries = append(m.entries, *entry) - return nil -} -func (m *MockGLRepository) GetJournalEntry(ctx context.Context, id uuid.UUID) (*gl.JournalEntry, error) { - return nil, nil -} -func (m *MockGLRepository) ListJournalEntries(ctx context.Context) ([]gl.JournalEntry, error) { - return nil, nil -} -func (m *MockGLRepository) UpdateJournalEntryStatus(ctx context.Context, id uuid.UUID, status, user string) error { - return nil -} -func (m *MockGLRepository) ListAccounts(ctx context.Context) ([]gl.GLAccount, error) { - return []gl.GLAccount{ - {ID: uuid.New(), Name: "Accounts Payable", Code: "2010"}, - {ID: uuid.New(), Name: "Cash", Code: "1010"}, - }, nil -} -func (m *MockGLRepository) GetAccount(ctx context.Context, id uuid.UUID) (*gl.GLAccount, error) { - return nil, nil -} -func (m *MockGLRepository) CreateAccount(ctx context.Context, acct *gl.GLAccount) error { return nil } -func (m *MockGLRepository) UpdateAccount(ctx context.Context, acct *gl.GLAccount) error { return nil } -func (m *MockGLRepository) GetTrialBalance(ctx context.Context, asOf time.Time, currency string) ([]gl.TrialBalanceRow, error) { - return nil, nil -} - -// AP never reads financial statements; this satisfies gl.Repository only. -func (m *MockGLRepository) DefaultCurrency(ctx context.Context) (string, error) { return "USD", nil } - -func (m *MockGLRepository) GetAccountActivity(ctx context.Context, start *time.Time, end time.Time, accountTypes []string, currency string) ([]gl.AccountActivity, error) { - return nil, nil -} -func (m *MockGLRepository) ListFiscalPeriods(ctx context.Context) ([]gl.FiscalPeriod, error) { - return nil, nil -} -func (m *MockGLRepository) GetFiscalPeriodForDate(ctx context.Context, date time.Time) (*gl.FiscalPeriod, error) { - return nil, nil -} -func (m *MockGLRepository) CloseFiscalPeriod(ctx context.Context, id uuid.UUID, user string) error { - return nil -} - -func (m *MockGLRepository) ReopenFiscalPeriod(ctx context.Context, id uuid.UUID, user string) error { - return nil -} - -func (m *MockGLRepository) IsReversed(ctx context.Context, id uuid.UUID) (bool, error) { - return false, nil -} - -type MockDatabase struct{} - -func (m *MockDatabase) RunInTx(ctx context.Context, fn func(ctx context.Context) error) error { - return fn(ctx) -} - -func TestGLIntegration_ApproveInvoice(t *testing.T) { - apRepo := &MockAPRepository{ - invoices: make(map[uuid.UUID]*VendorInvoice), - invoiceLines: make(map[uuid.UUID][]VendorInvoiceLine), - } - glRepo := &MockGLRepository{} - glSvc := gl.NewService(glRepo, nil, nil) - apSvc := NewService(&MockDatabase{}, apRepo, nil, slog.Default()) - apSvc.glSvc = glSvc // Wire it up - - invID := uuid.New() - expenseAcctID := uuid.New() - inv := &VendorInvoice{ - ID: invID, - Total: 10000, // $100 - Status: InvoiceStatusPending, - Lines: []VendorInvoiceLine{ - {ID: uuid.New(), Description: "Lumber", LineTotal: 10000, GLAccountID: &expenseAcctID}, - }, - } - apRepo.invoices[invID] = inv - apRepo.invoiceLines[invID] = inv.Lines - - _, err := apSvc.ApproveInvoice(context.Background(), invID, uuid.New()) - if err != nil { - t.Fatalf("ApproveInvoice failed: %v", err) - } - - if len(glRepo.entries) != 1 { - t.Fatalf("expected 1 GL entry, got %d", len(glRepo.entries)) - } - - entry := glRepo.entries[0] - if entry.Source != gl.SourceVendorInv { - t.Errorf("expected source %s, got %s", gl.SourceVendorInv, entry.Source) - } - - // Check lines (1 AP credit, 1 Expense debit) - if len(entry.Lines) != 2 { - t.Fatalf("expected 2 GL lines, got %d", len(entry.Lines)) - } -} - -func TestGLIntegration_PayVendor(t *testing.T) { - apRepo := &MockAPRepository{ - invoices: make(map[uuid.UUID]*VendorInvoice), - payments: make(map[uuid.UUID]*APPayment), - } - glRepo := &MockGLRepository{} - glSvc := gl.NewService(glRepo, nil, nil) - apSvc := NewService(&MockDatabase{}, apRepo, nil, slog.Default()) - apSvc.glSvc = glSvc - - invID := uuid.New() - inv := &VendorInvoice{ID: invID, Total: 5000, AmountPaid: 0} - apRepo.invoices[invID] = inv - - req := CreateAPPaymentRequest{ - VendorID: uuid.New(), - Amount: 50.0, - Method: PaymentMethodCheck, - PaymentDate: "2026-03-03", - InvoiceIDs: []uuid.UUID{invID}, - } - - _, err := apSvc.PayVendor(context.Background(), req) - if err != nil { - t.Fatalf("PayVendor failed: %v", err) - } - - if len(glRepo.entries) != 1 { - t.Fatalf("expected 1 GL entry, got %d", len(glRepo.entries)) - } - - entry := glRepo.entries[0] - if entry.Source != gl.SourceVendorPmt { - t.Errorf("expected source %s, got %s", gl.SourceVendorPmt, entry.Source) - } -} diff --git a/core/internal/ap/handler.go b/core/internal/ap/handler.go index eb705bb7e..1e1be4ec4 100644 --- a/core/internal/ap/handler.go +++ b/core/internal/ap/handler.go @@ -6,13 +6,20 @@ package ap import ( "encoding/json" "net/http" + "strings" - "github.com/gablelbm/gable/pkg/httputil" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) -// Handler handles AP HTTP endpoints. +// The invoice list's ordering: created_at then id, newest first (ADR 0001 +// section 2). +const cursorScope = "ap.invoices.created_at_id_desc" + +// Handler handles the AP HTTP endpoints: the vendor invoice routes on the +// wire contract (ADR 0001, ADR 0008 7.4), and the payment and aging routes in +// their today shape until their own conversion (ADR 0008 section 11). type Handler struct { service *Service } @@ -22,8 +29,8 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// RegisterRoutes registers AP API routes. -// roleGuard protects all endpoints; pass middleware.RequireRole("admin","owner","finance") in production. +// RegisterRoutes registers the AP routes. roleGuard protects all endpoints; +// pass the branch wall's scoped("admin", "owner", "finance") in production. 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 { @@ -34,162 +41,296 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand return handler } - // Vendor Invoices - mux.HandleFunc("POST /api/v1/ap/invoices", guard(h.CreateVendorInvoice)) - mux.HandleFunc("GET /api/v1/ap/invoices", guard(h.ListVendorInvoices)) - mux.HandleFunc("GET /api/v1/ap/invoices/{id}", guard(h.GetVendorInvoice)) - mux.HandleFunc("POST /api/v1/ap/invoices/{id}/approve", guard(h.ApproveInvoice)) + // Vendor invoices + mux.HandleFunc("POST /api/v1/ap/invoices", guard(h.HandleCreate)) + mux.HandleFunc("GET /api/v1/ap/invoices", guard(h.HandleList)) + mux.HandleFunc("GET /api/v1/ap/invoices/{id}", guard(h.HandleGet)) + mux.HandleFunc("POST /api/v1/ap/invoices/{id}/transitions", guard(h.HandleTransition)) // AP Payments - mux.HandleFunc("POST /api/v1/ap/payments", guard(h.PayVendor)) - mux.HandleFunc("GET /api/v1/ap/payments", guard(h.ListPayments)) + mux.HandleFunc("POST /api/v1/ap/payments", guard(h.HandlePayVendor)) + mux.HandleFunc("GET /api/v1/ap/payments", guard(h.HandleListPayments)) // Aging Report - mux.HandleFunc("GET /api/v1/ap/aging", guard(h.GetAgingSummary)) + mux.HandleFunc("GET /api/v1/ap/aging", guard(h.HandleGetAgingSummary)) } -func (h *Handler) CreateVendorInvoice(w http.ResponseWriter, r *http.Request) { - var req CreateVendorInvoiceRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) - return - } - - inv, err := h.service.CreateVendorInvoice(r.Context(), req) - if err != nil { - httputil.RespondError(w, r, "failed to create vendor invoice", http.StatusInternalServerError, err) - return - } - +func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(inv) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) } -func (h *Handler) GetVendorInvoice(w http.ResponseWriter, r *http.Request) { +func pathID(r *http.Request, what string) (uuid.UUID, error) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "Invalid invoice ID", http.StatusBadRequest, err) - return + return uuid.Nil, httpx.BadRequest("invalid "+what+" id", httpx.FieldError{Field: "id", Message: "must be a UUID"}) } + return id, nil +} - inv, err := h.service.GetVendorInvoice(r.Context(), id) - if err != nil { - httputil.RespondError(w, r, "vendor invoice not found", http.StatusNotFound, err) - return +func noQuery(r *http.Request) error { + _, err := httpx.StrictQuery(r) + return err +} + +// caller reads who is acting from the auth chain: the audit subject. +func caller(r *http.Request) Caller { + if claims, ok := r.Context().Value(middleware.UserContextKey).(*middleware.UserClaims); ok && claims != nil { + return Caller{Actor: claims.Subject, Role: claims.Role} } + return Caller{} +} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(inv) +func cursorError() error { + return httpx.BadRequest("cursor keyset is malformed", httpx.FieldError{Field: "cursor", Message: "cursor keyset is malformed"}) } -func (h *Handler) ListVendorInvoices(w http.ResponseWriter, r *http.Request) { - var vendorID *uuid.UUID - if vid := r.URL.Query().Get("vendor_id"); vid != "" { - parsed, err := uuid.Parse(vid) - if err == nil { - vendorID = &parsed - } +func single(v *httpx.Validator, q map[string][]string, name string) (string, bool) { + vals := q[name] + if len(vals) == 0 { + return "", false } - status := r.URL.Query().Get("status") - - invoices, err := h.service.ListVendorInvoices(r.Context(), vendorID, status) - if err != nil { - httputil.RespondError(w, r, "failed to list vendor invoices", http.StatusInternalServerError, err) - return + if len(vals) > 1 { + v.Check(false, name, "parameter is repeated") + return "", false } + return vals[0], true +} - if invoices == nil { - invoices = []VendorInvoice{} +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 +} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(invoices) +// parseListFilter reads the invoice list's filters beside the platform's +// cursor, limit and include: vendor_id, status and po_id. Unknown parameters +// and uppercase status values are refused. +func parseListFilter(r *http.Request) (f ListFilter, wantTotal bool, err error) { + q, err := httpx.StrictQuery(r, "cursor", "limit", "include", "vendor_id", "status", "po_id") + if err != nil { + return f, false, err + } + page, err := httpx.ParseListQuery(r, cursorScope) + if err != nil { + return f, false, err + } + f.Limit = page.Limit + v := &httpx.Validator{} + f.VendorID = uuidParam(v, q, "vendor_id") + f.POID = uuidParam(v, q, "po_id") + if raw, ok := single(v, q, "status"); ok { + for _, name := range strings.Split(raw, ",") { + st, ok := ParseStatus(name) + if !ok { + v.Check(false, "status", "must be a comma separated list of: pending, approved, partial, paid, voided") + break + } + f.Statuses = append(f.Statuses, st) + } + } + if raw, ok := single(v, q, "include"); ok { + set, ierr := httpx.ParseInclude(raw) + if ierr != nil { + return f, false, ierr + } + wantTotal = set.Has(httpx.IncludeTotal) + } + if err := v.Err(); err != nil { + return f, false, err + } + if page.Key != nil { + if len(page.Key) != 2 { + return f, false, cursorError() + } + t, terr := httpx.ParseKeyTime(page.Key[0]) + id, uerr := httpx.ParseKeyUUID(page.Key[1]) + if terr != nil || uerr != nil { + return f, false, cursorError() + } + f.AfterTime, f.AfterID = &t, id + } + return f, wantTotal, nil } -func (h *Handler) ApproveInvoice(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) +// HandleList answers GET /api/v1/ap/invoices: the cursor envelope. +func (h *Handler) HandleList(w http.ResponseWriter, r *http.Request) { + f, wantTotal, err := parseListFilter(r) if err != nil { - httputil.RespondError(w, r, "Invalid invoice ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } - - // Extract approver ID from auth context - var approverID uuid.UUID - if claims := middleware.ClaimsFromContext(r.Context()); claims != nil { - parsed, err := uuid.Parse(claims.Subject) + items, hasMore, total, err := h.service.List(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(cursorScope, httpx.FormatKeyTime(last.CreatedAt.Time), last.ID.String()) if err != nil { - httputil.RespondError(w, r, "Invalid user ID in token", http.StatusUnauthorized, err) + httpx.WriteError(w, r, err) return } - approverID = parsed - } else { - httputil.RespondError(w, r, "Authentication required", http.StatusUnauthorized, nil) - return } + var opts []httpx.ListOption + if total != nil { + opts = append(opts, httpx.WithTotal(*total)) + } + httpx.WriteList(w, items, next, f.Limit, opts...) +} - inv, err := h.service.ApproveInvoice(r.Context(), id, approverID) +// HandleGet answers GET /api/v1/ap/invoices/{id}. +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, "vendor invoice") if err != nil { - httputil.RespondError(w, r, "failed to approve invoice", http.StatusUnprocessableEntity, err) + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(inv) + inv, err := h.service.Get(r.Context(), id) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteRevisionETag(w, inv.Revision) + writeJSON(w, http.StatusOK, inv) } -func (h *Handler) PayVendor(w http.ResponseWriter, r *http.Request) { - var req CreateAPPaymentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) +// HandleCreate answers POST /api/v1/ap/invoices: the bill starts pending. +func (h *Handler) HandleCreate(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) return } + var req CreateRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + in, err := req.Parse() + if err != nil { + httpx.WriteError(w, r, err) + return + } + inv, err := h.service.Create(r.Context(), in, caller(r)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteRevisionETag(w, inv.Revision) + w.Header().Set("Location", "/api/v1/ap/invoices/"+inv.ID.String()) + writeJSON(w, http.StatusCreated, inv) +} - pmt, err := h.service.PayVendor(r.Context(), req) +// HandleTransition answers POST /api/v1/ap/invoices/{id}/transitions: the +// approve and the void of ADR 0008 7.4, replacing the old /approve route. +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, "vendor invoice") + if err != nil { + httpx.WriteError(w, r, err) + return + } + var req TransitionRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + in, err := req.Parse() + if err != nil { + httpx.WriteError(w, r, err) + return + } + inv, err := h.service.Transition(r.Context(), id, in, Precondition{IfMatch: r.Header.Get("If-Match"), Revision: in.Revision}, caller(r)) if err != nil { - httputil.RespondError(w, r, "failed to process vendor payment", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } + httpx.WriteRevisionETag(w, inv.Revision) + writeJSON(w, http.StatusOK, inv) +} - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(pmt) +// HandlePayVendor answers POST /api/v1/ap/payments: the payment applies only +// to approved or partial bills (ADR 0008 7.4). +func (h *Handler) HandlePayVendor(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + var req PayRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + in, err := req.Parse() + if err != nil { + httpx.WriteError(w, r, err) + return + } + pmt, err := h.service.PayVendor(r.Context(), in, caller(r)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, pmt) } -func (h *Handler) ListPayments(w http.ResponseWriter, r *http.Request) { +// HandleListPayments answers GET /api/v1/ap/payments. +func (h *Handler) HandleListPayments(w http.ResponseWriter, r *http.Request) { + q, err := httpx.StrictQuery(r, "vendor_id") + if err != nil { + httpx.WriteError(w, r, err) + return + } + v := &httpx.Validator{} var vendorID *uuid.UUID - if vid := r.URL.Query().Get("vendor_id"); vid != "" { - parsed, err := uuid.Parse(vid) - if err == nil { - vendorID = &parsed + if raw, ok := single(v, q, "vendor_id"); ok { + if id, ok := v.UUID("vendor_id", &raw, true); ok { + vendorID = &id } } - + if err := v.Err(); err != nil { + httpx.WriteError(w, r, err) + return + } payments, err := h.service.ListPayments(r.Context(), vendorID) if err != nil { - httputil.RespondError(w, r, "failed to list AP payments", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - if payments == nil { payments = []APPayment{} } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(payments) + writeJSON(w, http.StatusOK, payments) } -func (h *Handler) GetAgingSummary(w http.ResponseWriter, r *http.Request) { +// HandleGetAgingSummary answers GET /api/v1/ap/aging. +func (h *Handler) HandleGetAgingSummary(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } summary, err := h.service.GetAgingSummary(r.Context()) if err != nil { - httputil.RespondError(w, r, "failed to get AP aging summary", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - if summary == nil { summary = []APAgingSummary{} } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(summary) + writeJSON(w, http.StatusOK, summary) } diff --git a/core/internal/ap/input.go b/core/internal/ap/input.go new file mode 100644 index 000000000..26500f859 --- /dev/null +++ b/core/internal/ap/input.go @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/money" + "github.com/google/uuid" +) + +func trim(s *string) string { + if s == nil { + return "" + } + return strings.TrimSpace(*s) +} + +func parseDate(v *httpx.Validator, field, raw string, required bool) (string, bool) { + if raw == "" { + if required { + v.Check(false, field, "is required as YYYY-MM-DD") + return "", false + } + return "", true + } + if _, err := time.Parse("2006-01-02", raw); err != nil { + v.Check(false, field, "must be YYYY-MM-DD") + return "", false + } + return raw, true +} + +// CreateLineRequest is one line of a bill. +type CreateLineRequest struct { + Description *string `json:"description"` + Quantity json.RawMessage `json:"quantity"` + UnitPriceTenThousand json.RawMessage `json:"unit_price_ten_thousandths"` + GLAccountID *string `json:"gl_account_id"` + PurchaseOrderLineID *string `json:"purchase_order_line_id"` + ProductID *string `json:"product_id"` +} + +// CreateRequest is the body of POST /api/v1/ap/invoices. +type CreateRequest struct { + VendorID *string `json:"vendor_id"` + BranchID *string `json:"branch_id"` + VendorInvoiceNumber *string `json:"vendor_invoice_number"` + InvoiceDate *string `json:"invoice_date"` + DueDate *string `json:"due_date"` + POID *string `json:"po_id"` + Currency *string `json:"currency"` + TaxCents json.RawMessage `json:"tax_cents"` + Notes *string `json:"notes"` + Lines []CreateLineRequest `json:"lines"` +} + +// LineInput is one validated line. The extension is priced by the service +// through httpx.Extend, never sent by the client. +type LineInput struct { + Description string + Quantity httpx.Quantity + UnitPrice httpx.Price + GLAccountID *uuid.UUID + PurchaseOrderLineID *uuid.UUID + ProductID *uuid.UUID + LineTotalCents httpx.Cents +} + +// Input is a validated bill ready for the service. +type Input struct { + VendorID uuid.UUID + BranchID *uuid.UUID + VendorInvoiceNumber string + InvoiceDate string + DueDate string + POID *uuid.UUID + Currency string + TaxCents httpx.Cents + Notes string + Lines []LineInput +} + +// Parse validates a create request in one pass: every problem is one field +// error with its full path (ADR 0001 section 4). The status is never in the +// request: a created bill starts pending. +func (req *CreateRequest) Parse() (*Input, error) { + v := &httpx.Validator{} + in := &Input{} + if id, ok := v.UUID("vendor_id", req.VendorID, true); ok { + in.VendorID = id + } + if req.BranchID != nil { + if id, ok := v.UUID("branch_id", req.BranchID, false); ok { + in.BranchID = &id + } + } + number := trim(req.VendorInvoiceNumber) + v.Check(number != "", "vendor_invoice_number", "is required") + v.Check(len(number) <= 64, "vendor_invoice_number", "must be at most 64 characters") + in.VendorInvoiceNumber = number + if d, ok := parseDate(v, "invoice_date", trim(req.InvoiceDate), true); ok { + in.InvoiceDate = d + } + if d, ok := parseDate(v, "due_date", trim(req.DueDate), true); ok { + in.DueDate = d + } + if req.POID != nil { + if id, ok := v.UUID("po_id", req.POID, false); ok { + in.POID = &id + } + } + currency := trim(req.Currency) + if currency == "" { + currency = "USD" // the service replaces this with the setting's default + } + v.Check(len(currency) == 3 && currency == strings.ToUpper(currency), "currency", "must be an ISO 4217 code of three capital letters") + in.Currency = currency + if n, ok := v.Int("tax_cents", req.TaxCents, false); ok { + v.Check(n >= 0, "tax_cents", "must not be negative") + in.TaxCents = httpx.Cents(n) + } + in.Notes = trim(req.Notes) + v.Check(len(in.Notes) <= 2000, "notes", "must be at most 2000 characters") + + v.Check(len(req.Lines) >= 1, "lines", "a bill carries at least one line") + var subtotal httpx.Cents + for i, l := range req.Lines { + path := fmt.Sprintf("lines[%d]", i) + line := LineInput{} + desc := trim(l.Description) + v.Check(desc != "", path+".description", "is required") + v.Check(len(desc) <= 256, path+".description", "must be at most 256 characters") + line.Description = desc + if q, ok := v.Quantity(path+".quantity", l.Quantity, true); ok { + v.Check(q > 0, path+".quantity", "must be more than zero") + line.Quantity = q + } + if p, ok := v.Int(path+".unit_price_ten_thousandths", l.UnitPriceTenThousand, true); ok { + v.Check(p >= 0, path+".unit_price_ten_thousandths", "must not be negative") + line.UnitPrice = httpx.Price(p) + } + if l.GLAccountID != nil { + if id, ok := v.UUID(path+".gl_account_id", l.GLAccountID, false); ok { + line.GLAccountID = &id + } + } + v.Check(line.GLAccountID != nil, path+".gl_account_id", "is required: the approve entry debits each line's account") + if l.PurchaseOrderLineID != nil { + if id, ok := v.UUID(path+".purchase_order_line_id", l.PurchaseOrderLineID, false); ok { + line.PurchaseOrderLineID = &id + } + } + if l.ProductID != nil { + if id, ok := v.UUID(path+".product_id", l.ProductID, false); ok { + line.ProductID = &id + } + } + if line.GLAccountID != nil && line.Quantity != 0 && line.UnitPrice >= 0 { + total, err := httpx.Extend(line.Quantity, 1, 1, line.UnitPrice) + if err != nil { + v.Check(false, path+".quantity", err.Error()) + } else { + line.LineTotalCents = total + subtotal += total + } + } + in.Lines = append(in.Lines, line) + } + if err := v.Err(); err != nil { + return nil, err + } + return in, nil +} + +// TransitionRequest is the body of POST /api/v1/ap/invoices/{id}/transitions. +type TransitionRequest struct { + To *string `json:"to"` + Revision json.RawMessage `json:"revision"` + Reason *string `json:"reason"` +} + +// TransitionInput is a validated transition: the target status, the client's +// revision and the reason a void asks for. +type TransitionInput struct { + To Status + Revision *int64 + Reason string +} + +// Parse validates a transition request in one pass. +func (req *TransitionRequest) Parse() (*TransitionInput, error) { + v := &httpx.Validator{} + in := &TransitionInput{} + if req.To == nil { + v.Check(false, "to", "is required") + } else if s, ok := ParseStatus(*req.To); !ok { + v.Check(false, "to", "must be one of: pending, approved, partial, paid, voided") + } else { + in.To = s + } + if n, ok := v.Int("revision", req.Revision, false); ok { + v.Check(n >= 1, "revision", "must be a revision number, 1 or more") + in.Revision = &n + } + in.Reason = trim(req.Reason) + v.Check(len(in.Reason) <= 500, "reason", "must be at most 500 characters") + if err := v.Err(); err != nil { + return nil, err + } + return in, nil +} + +// PayRequest is the body of POST /api/v1/ap/payments. The payment routes keep +// their request shape until their own conversion (ADR 0008 section 11); only +// the parsing is held to one pass here. +type PayRequest struct { + VendorID *string `json:"vendor_id"` + Amount *float64 `json:"amount"` + Method *string `json:"method"` + CheckNumber *string `json:"check_number"` + Reference *string `json:"reference"` + PaymentDate *string `json:"payment_date"` + InvoiceIDs []string `json:"invoice_ids"` +} + +// PayInput is a validated payment ready for the service. +type PayInput struct { + VendorID uuid.UUID + AmountCents int64 + Method PaymentMethod + CheckNumber string + Reference string + PaymentDate string + InvoiceIDs []uuid.UUID +} + +// Parse validates a pay request in one pass. +func (req *PayRequest) Parse() (*PayInput, error) { + v := &httpx.Validator{} + in := &PayInput{} + if id, ok := v.UUID("vendor_id", req.VendorID, true); ok { + in.VendorID = id + } + if req.Amount == nil { + v.Check(false, "amount", "is required") + } else { + v.Check(*req.Amount > 0, "amount", "must be more than zero") + in.AmountCents = money.DollarsToCents(*req.Amount) + } + if req.Method == nil { + v.Check(false, "method", "is required") + } else { + switch PaymentMethod(strings.ToUpper(trim(req.Method))) { + case PaymentMethodCheck, PaymentMethodACH, PaymentMethodWire: + in.Method = PaymentMethod(strings.ToUpper(trim(req.Method))) + default: + v.Check(false, "method", "must be one of: CHECK, ACH, WIRE") + } + } + in.CheckNumber = trim(req.CheckNumber) + in.Reference = trim(req.Reference) + if d, ok := parseDate(v, "payment_date", trim(req.PaymentDate), true); ok { + in.PaymentDate = d + } + for i, raw := range req.InvoiceIDs { + id, ok := v.UUID(fmt.Sprintf("invoice_ids[%d]", i), &raw, true) + if ok { + in.InvoiceIDs = append(in.InvoiceIDs, id) + } + } + if err := v.Err(); err != nil { + return nil, err + } + return in, nil +} diff --git a/core/internal/ap/model.go b/core/internal/ap/model.go index 859f9aa91..e8e894265 100644 --- a/core/internal/ap/model.go +++ b/core/internal/ap/model.go @@ -4,23 +4,75 @@ package ap import ( + "strings" "time" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/google/uuid" ) -// InvoiceStatus tracks the lifecycle of a vendor invoice. -type InvoiceStatus string +// Status is the vendor invoice lifecycle on the wire: lowercase snake_case +// (ADR 0001 section 6). The storage vocabulary stays uppercase; the mapping +// happens at this boundary. +type Status string const ( - InvoiceStatusPending InvoiceStatus = "PENDING" - InvoiceStatusApproved InvoiceStatus = "APPROVED" - InvoiceStatusPartial InvoiceStatus = "PARTIAL" - InvoiceStatusPaid InvoiceStatus = "PAID" - InvoiceStatusVoided InvoiceStatus = "VOIDED" + StatusPending Status = "pending" + StatusApproved Status = "approved" + StatusPartial Status = "partial" + StatusPaid Status = "paid" + StatusVoided Status = "voided" ) -// PaymentMethod for AP payments to vendors. +// ParseStatus accepts only the lowercase spelling; anything else is not a +// status this module knows. +func ParseStatus(raw string) (Status, bool) { + switch Status(raw) { + case StatusPending, StatusApproved, StatusPartial, StatusPaid, StatusVoided: + return Status(raw), true + } + return "", false +} + +// storageStatus maps a wire status to the column's uppercase vocabulary. +func storageStatus(s Status) string { return strings.ToUpper(string(s)) } + +// Status renders the status for a message. +func (s Status) Status() string { return string(s) } + +// wireStatus maps a stored status to the wire's lowercase one; an unknown +// stored value (a row written by hand) reads as pending. +func wireStatus(stored string) Status { + if s, ok := ParseStatus(strings.ToLower(stored)); ok { + return s + } + return StatusPending +} + +// transitions is the lifecycle: which edge each status allows. +var transitions = map[Status][]Status{ + StatusPending: {StatusApproved, StatusVoided}, + StatusApproved: {StatusVoided}, + // partial and paid carry payments; a partial bill cannot be voided while + // money has left (the transition's guard refuses it), and a paid one is + // closed. + StatusPartial: {}, + StatusPaid: {}, + StatusVoided: {}, +} + +func (s Status) allows(to Status) bool { + for _, t := range transitions[s] { + if t == to { + return true + } + } + return false +} + +// PaymentMethod for AP payments to vendors. The payment routes keep their +// uppercase wire vocabulary until their own conversion; this is the storage +// set. type PaymentMethod string const ( @@ -29,53 +81,72 @@ const ( PaymentMethodWire PaymentMethod = "WIRE" ) -// VendorInvoice represents a bill received from a vendor. -type VendorInvoice struct { - ID uuid.UUID `json:"id" db:"id"` - VendorID uuid.UUID `json:"vendor_id" db:"vendor_id"` - VendorName string `json:"vendor_name,omitempty" db:"vendor_name"` // Joined - InvoiceNumber string `json:"invoice_number" db:"invoice_number"` - InvoiceDate time.Time `json:"invoice_date" db:"invoice_date"` - DueDate time.Time `json:"due_date" db:"due_date"` - POID *uuid.UUID `json:"po_id,omitempty" db:"po_id"` - Subtotal int64 `json:"subtotal" db:"subtotal"` // Cents - TaxAmount int64 `json:"tax_amount" db:"tax_amount"` // Cents - Total int64 `json:"total" db:"total"` // Cents - AmountPaid int64 `json:"amount_paid" db:"amount_paid"` // Cents - Status InvoiceStatus `json:"status" db:"status"` - ApprovedBy *uuid.UUID `json:"approved_by,omitempty" db:"approved_by"` - ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"` - Notes string `json:"notes,omitempty" db:"notes"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - - // Populated on read - Lines []VendorInvoiceLine `json:"lines,omitempty"` +// Summary is one vendor invoice's header, the shape of both the single read +// and the list item (ADR 0001: the list item is the smaller struct the full +// document embeds, so the two cannot drift). +type Summary struct { + ID uuid.UUID `json:"id"` + Number string `json:"number"` + VendorID uuid.UUID `json:"vendor_id"` + VendorName string `json:"vendor_name"` + BranchID uuid.UUID `json:"branch_id"` + VendorInvoiceNumber string `json:"vendor_invoice_number"` + Currency string `json:"currency"` + InvoiceDate string `json:"invoice_date"` // YYYY-MM-DD + DueDate string `json:"due_date"` // YYYY-MM-DD + POID *uuid.UUID `json:"po_id"` + SubtotalCents httpx.Cents `json:"subtotal_cents"` + TaxCents httpx.Cents `json:"tax_cents"` + TotalCents httpx.Cents `json:"total_cents"` + AmountPaidCents httpx.Cents `json:"amount_paid_cents"` + AmountOpenCents httpx.Cents `json:"amount_open_cents"` + Status Status `json:"status"` + ApprovedBy *uuid.UUID `json:"approved_by"` + ApprovedAt *httpx.Timestamp `json:"approved_at"` + Notes string `json:"notes"` + Revision int64 `json:"revision"` + GLEntryID *uuid.UUID `json:"gl_entry_id"` + CreatedAt httpx.Timestamp `json:"created_at"` +} + +// Invoice is a vendor bill: the summary plus its lines. Gable's own number is +// number (AP-, gapped sequence); the vendor's own number is +// vendor_invoice_number (ADR 0008 7.4). +type Invoice struct { + Summary + Lines []InvoiceLine `json:"lines"` } -// VendorInvoiceLine represents a line item on a vendor invoice. -type VendorInvoiceLine struct { - ID uuid.UUID `json:"id" db:"id"` - InvoiceID uuid.UUID `json:"invoice_id" db:"invoice_id"` - Description string `json:"description" db:"description"` - Quantity float64 `json:"quantity" db:"quantity"` - UnitPrice int64 `json:"unit_price" db:"unit_price"` // Cents - LineTotal int64 `json:"line_total" db:"line_total"` // Cents - GLAccountID *uuid.UUID `json:"gl_account_id,omitempty" db:"gl_account_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` +// InvoiceLine is one line of a vendor bill. The extension is priced through +// httpx.Extend with the pair 1 and 1: AP lines carry no conversion pair +// until the purchase line link carries units (ADR 0008 sections 1 and 7.3). +type InvoiceLine struct { + ID uuid.UUID `json:"id"` + Position int `json:"position"` + Description string `json:"description"` + Quantity httpx.Quantity `json:"quantity"` + UnitPriceTenThousandths httpx.Price `json:"unit_price_ten_thousandths"` + LineTotalCents httpx.Cents `json:"line_total_cents"` + GLAccountID *uuid.UUID `json:"gl_account_id"` + PurchaseOrderLineID *uuid.UUID `json:"purchase_order_line_id"` + ProductID *uuid.UUID `json:"product_id"` + POFreightChargeID *uuid.UUID `json:"po_freight_charge_id"` + CreatedAt httpx.Timestamp `json:"created_at"` } -// APPayment represents a payment made to a vendor. +// APPayment is a payment made to a vendor. The payment routes keep their +// shape until their own conversion (ADR 0008 section 11); amount is cents. type APPayment struct { ID uuid.UUID `json:"id" db:"id"` VendorID uuid.UUID `json:"vendor_id" db:"vendor_id"` - VendorName string `json:"vendor_name,omitempty" db:"vendor_name"` // Joined + VendorName string `json:"vendor_name,omitempty" db:"vendor_name"` BatchID *uuid.UUID `json:"batch_id,omitempty" db:"batch_id"` Amount int64 `json:"amount" db:"amount"` // Cents Method PaymentMethod `json:"method" db:"method"` CheckNumber string `json:"check_number,omitempty" db:"check_number"` Reference string `json:"reference,omitempty" db:"reference"` - PaymentDate time.Time `json:"payment_date" db:"payment_date"` - Status string `json:"status" db:"status"` // PENDING, COMPLETE, VOIDED + PaymentDate string `json:"payment_date" db:"payment_date"` // YYYY-MM-DD + Status string `json:"status" db:"status"` CreatedAt time.Time `json:"created_at" db:"created_at"` } @@ -88,39 +159,6 @@ type APPaymentApplication struct { CreatedAt time.Time `json:"created_at" db:"created_at"` } -// --- Request Types --- - -// CreateVendorInvoiceRequest is sent when entering a new vendor bill. -type CreateVendorInvoiceRequest struct { - VendorID uuid.UUID `json:"vendor_id"` - InvoiceNumber string `json:"invoice_number"` - InvoiceDate string `json:"invoice_date"` // YYYY-MM-DD - DueDate string `json:"due_date"` // YYYY-MM-DD - POID *uuid.UUID `json:"po_id,omitempty"` - TaxAmount float64 `json:"tax_amount"` // Dollars - Notes string `json:"notes"` - Lines []CreateVendorInvoiceLineReq `json:"lines"` -} - -// CreateVendorInvoiceLineReq is a line item within a vendor invoice. -type CreateVendorInvoiceLineReq struct { - Description string `json:"description"` - Quantity float64 `json:"quantity"` - UnitPrice float64 `json:"unit_price"` // Dollars - GLAccountID *uuid.UUID `json:"gl_account_id,omitempty"` -} - -// CreateAPPaymentRequest is sent when paying vendors. -type CreateAPPaymentRequest struct { - VendorID uuid.UUID `json:"vendor_id"` - Amount float64 `json:"amount"` // Dollars - Method PaymentMethod `json:"method"` - CheckNumber string `json:"check_number,omitempty"` - Reference string `json:"reference,omitempty"` - PaymentDate string `json:"payment_date"` // YYYY-MM-DD - InvoiceIDs []uuid.UUID `json:"invoice_ids"` // Invoices this payment applies to -} - // APAgingSummary represents aging buckets for a vendor. type APAgingSummary struct { VendorID uuid.UUID `json:"vendor_id"` diff --git a/core/internal/ap/repository.go b/core/internal/ap/repository.go index c8385ac59..164328cd5 100644 --- a/core/internal/ap/repository.go +++ b/core/internal/ap/repository.go @@ -5,299 +5,482 @@ package ap import ( "context" + "errors" "fmt" "time" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/database" - "github.com/gablelbm/gable/pkg/money" + "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) -// Repository handles AP data persistence. -type Repository interface { - CreateVendorInvoice(ctx context.Context, inv *VendorInvoice) error - GetVendorInvoice(ctx context.Context, id uuid.UUID) (*VendorInvoice, error) - ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]VendorInvoice, error) - UpdateVendorInvoice(ctx context.Context, inv *VendorInvoice) error +var errInvoiceNotFound = httpx.NotFound("vendor invoice not found") - AddInvoiceLine(ctx context.Context, line *VendorInvoiceLine) error - GetInvoiceLines(ctx context.Context, invoiceID uuid.UUID) ([]VendorInvoiceLine, error) +// Repository reads and writes the AP tables. Every statement goes through the +// executor the context resolves, so inside a transaction it is the +// transaction; the branch wall rides every read and every lock. +type Repository struct { + db *database.DB +} - CreatePayment(ctx context.Context, pmt *APPayment) error - CreatePaymentApplication(ctx context.Context, app *APPaymentApplication) error - ListPayments(ctx context.Context, vendorID *uuid.UUID) ([]APPayment, error) +func NewRepository(db *database.DB) *Repository { return &Repository{db: db} } - GetAgingSummary(ctx context.Context) ([]APAgingSummary, error) +// wall is the branch predicate on an aliased table's branch_id (nil context +// values mean no wall: an in-process caller or an administrator). +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) } -// PostgresRepository implements Repository. -type PostgresRepository struct { - db *database.DB -} +const summaryColumns = ` + vi.id, vi.number, vi.vendor_id, COALESCE(v.name, ''), vi.branch_id, vi.invoice_number, vi.currency, + to_char(vi.invoice_date, 'YYYY-MM-DD'), to_char(vi.due_date, 'YYYY-MM-DD'), vi.po_id, + ROUND(vi.subtotal * 100)::bigint, ROUND(vi.tax_amount * 100)::bigint, ROUND(vi.total * 100)::bigint, + ROUND(vi.amount_paid * 100)::bigint, ROUND(vi.amount_open * 100)::bigint, vi.status, + vi.approved_by, vi.approved_at, COALESCE(vi.notes, ''), vi.revision, vi.gl_entry_id, vi.created_at` -// NewRepository creates a new AP repository. -func NewRepository(db *database.DB) *PostgresRepository { - return &PostgresRepository{db: db} -} +const summaryFrom = ` + FROM vendor_invoices vi + LEFT JOIN vendors v ON v.id = vi.vendor_id` + +type rowScan func(dest ...any) error -func (r *PostgresRepository) CreateVendorInvoice(ctx context.Context, inv *VendorInvoice) error { - if inv.ID == uuid.Nil { - inv.ID = uuid.New() - } - inv.CreatedAt = time.Now() - - query := ` - INSERT INTO vendor_invoices (id, vendor_id, invoice_number, invoice_date, due_date, po_id, - subtotal, tax_amount, total, amount_paid, status, notes, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - inv.ID, inv.VendorID, inv.InvoiceNumber, inv.InvoiceDate, inv.DueDate, inv.POID, - float64(inv.Subtotal)/100.0, float64(inv.TaxAmount)/100.0, float64(inv.Total)/100.0, - float64(inv.AmountPaid)/100.0, inv.Status, inv.Notes, inv.CreatedAt, +func scanSummary(scan rowScan, s *Summary) error { + var ( + subtotal, tax, total, paid, open int64 + status string + approvedAt *time.Time + created time.Time ) - if err != nil { - return fmt.Errorf("failed to create vendor invoice: %w", err) + if err := scan(&s.ID, &s.Number, &s.VendorID, &s.VendorName, &s.BranchID, &s.VendorInvoiceNumber, &s.Currency, + &s.InvoiceDate, &s.DueDate, &s.POID, &subtotal, &tax, &total, &paid, &open, &status, + &s.ApprovedBy, &approvedAt, &s.Notes, &s.Revision, &s.GLEntryID, &created); err != nil { + return err } + s.SubtotalCents, s.TaxCents, s.TotalCents, s.AmountPaidCents, s.AmountOpenCents = + httpx.Cents(subtotal), httpx.Cents(tax), httpx.Cents(total), httpx.Cents(paid), httpx.Cents(open) + s.Status, s.ApprovedAt, s.CreatedAt = wireStatus(status), httpx.PtrTimestamp(approvedAt), httpx.TimestampOf(created) return nil } -func (r *PostgresRepository) GetVendorInvoice(ctx context.Context, id uuid.UUID) (*VendorInvoice, error) { - query := ` - SELECT vi.id, vi.vendor_id, COALESCE(v.name, '') as vendor_name, - vi.invoice_number, vi.invoice_date, vi.due_date, vi.po_id, - vi.subtotal, vi.tax_amount, vi.total, vi.amount_paid, vi.status, - vi.approved_by, vi.approved_at, COALESCE(vi.notes, '') as notes, vi.created_at - FROM vendor_invoices vi - LEFT JOIN vendors v ON v.id = vi.vendor_id - WHERE vi.id = $1 - ` - var inv VendorInvoice - var subtotal, taxAmount, total, amountPaid float64 - err := r.db.GetExecutor(ctx).QueryRow(ctx, query, id).Scan( - &inv.ID, &inv.VendorID, &inv.VendorName, - &inv.InvoiceNumber, &inv.InvoiceDate, &inv.DueDate, &inv.POID, - &subtotal, &taxAmount, &total, &amountPaid, &inv.Status, - &inv.ApprovedBy, &inv.ApprovedAt, &inv.Notes, &inv.CreatedAt, +const lineColumns = ` + l.id, l.position, l.description, l.quantity::text, ROUND(l.unit_price * 10000)::bigint, ROUND(l.line_total * 100)::bigint, + l.gl_account_id, l.purchase_order_line_id, l.product_id, l.po_freight_charge_id, l.created_at` + +func scanLine(scan rowScan, l *InvoiceLine) error { + var ( + qty string + price int64 + total int64 + created time.Time ) + if err := scan(&l.ID, &l.Position, &l.Description, &qty, &price, &total, + &l.GLAccountID, &l.PurchaseOrderLineID, &l.ProductID, &l.POFreightChargeID, &created); err != nil { + return err + } + q, err := httpx.ParseQuantity(qty) if err != nil { + return fmt.Errorf("vendor invoice line quantity %q: %w", qty, err) + } + l.Quantity, l.UnitPriceTenThousandths, l.LineTotalCents = q, httpx.Price(price), httpx.Cents(total) + l.CreatedAt = httpx.TimestampOf(created) + return nil +} + +// Get reads one vendor invoice, header and lines, held to the branch wall. +func (r *Repository) Get(ctx context.Context, id uuid.UUID) (*Invoice, error) { + var inv Invoice + row := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT `+summaryColumns+summaryFrom+` + WHERE vi.id = $1 AND `+wall("vi", 2, 3), id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + if err := scanSummary(row.Scan, &inv.Summary); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errInvoiceNotFound + } return nil, fmt.Errorf("failed to get vendor invoice: %w", err) } - inv.Subtotal = money.DollarsToCents(subtotal) - inv.TaxAmount = money.DollarsToCents(taxAmount) - inv.Total = money.DollarsToCents(total) - inv.AmountPaid = money.DollarsToCents(amountPaid) + lines, err := r.Lines(ctx, id) + if err != nil { + return nil, err + } + inv.Lines = lines return &inv, nil } -func (r *PostgresRepository) ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]VendorInvoice, error) { - query := ` - SELECT vi.id, vi.vendor_id, COALESCE(v.name, '') as vendor_name, - vi.invoice_number, vi.invoice_date, vi.due_date, - vi.subtotal, vi.tax_amount, vi.total, vi.amount_paid, vi.status, vi.created_at - FROM vendor_invoices vi - LEFT JOIN vendors v ON v.id = vi.vendor_id - WHERE ($1::uuid IS NULL OR vi.vendor_id = $1) - AND ($2 = '' OR vi.status = $2) - ORDER BY vi.due_date ASC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, vendorID, status) +// Lines reads a bill's lines in position order. +func (r *Repository) Lines(ctx context.Context, invoiceID uuid.UUID) ([]InvoiceLine, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, `SELECT `+lineColumns+` + FROM vendor_invoice_lines l WHERE l.invoice_id = $1 ORDER BY l.position, l.created_at, l.id`, invoiceID) if err != nil { - return nil, fmt.Errorf("failed to list vendor invoices: %w", err) + return nil, fmt.Errorf("failed to read vendor invoice lines: %w", err) } defer rows.Close() + out := []InvoiceLine{} + for rows.Next() { + var l InvoiceLine + if err := scanLine(rows.Scan, &l); err != nil { + return nil, fmt.Errorf("failed to scan vendor invoice line: %w", err) + } + out = append(out, l) + } + return out, rows.Err() +} + +// LineWithCode is a line beside its account's stable code, what the approve +// entry's debit legs are built from. +type LineWithCode struct { + InvoiceLine + AccountCode string +} - var invoices []VendorInvoice +// LinesWithCodes reads a bill's lines joined to their account codes, in +// position order. A line with no account reads an empty code (the service +// refuses the approve naming it). +func (r *Repository) LinesWithCodes(ctx context.Context, invoiceID uuid.UUID) ([]LineWithCode, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, `SELECT `+lineColumns+`, COALESCE(a.code, '') + FROM vendor_invoice_lines l LEFT JOIN gl_accounts a ON a.id = l.gl_account_id + WHERE l.invoice_id = $1 ORDER BY l.position, l.created_at, l.id`, invoiceID) + if err != nil { + return nil, fmt.Errorf("failed to read vendor invoice lines: %w", err) + } + defer rows.Close() + out := []LineWithCode{} for rows.Next() { - var inv VendorInvoice - var subtotal, taxAmount, total, amountPaid float64 - if err := rows.Scan( - &inv.ID, &inv.VendorID, &inv.VendorName, - &inv.InvoiceNumber, &inv.InvoiceDate, &inv.DueDate, - &subtotal, &taxAmount, &total, &amountPaid, &inv.Status, &inv.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("failed to scan vendor invoice: %w", err) + var ( + l LineWithCode + qty string + price int64 + total int64 + created time.Time + ) + if err := rows.Scan(&l.ID, &l.Position, &l.Description, &qty, &price, &total, + &l.GLAccountID, &l.PurchaseOrderLineID, &l.ProductID, &l.POFreightChargeID, &created, &l.AccountCode); err != nil { + return nil, fmt.Errorf("failed to scan vendor invoice line: %w", err) + } + q, err := httpx.ParseQuantity(qty) + if err != nil { + return nil, fmt.Errorf("vendor invoice line quantity %q: %w", qty, err) } - inv.Subtotal = money.DollarsToCents(subtotal) - inv.TaxAmount = money.DollarsToCents(taxAmount) - inv.Total = money.DollarsToCents(total) - inv.AmountPaid = money.DollarsToCents(amountPaid) - invoices = append(invoices, inv) + l.Quantity, l.UnitPriceTenThousandths, l.LineTotalCents = q, httpx.Price(price), httpx.Cents(total) + l.CreatedAt = httpx.TimestampOf(created) + out = append(out, l) } - return invoices, nil + return out, rows.Err() } -func (r *PostgresRepository) UpdateVendorInvoice(ctx context.Context, inv *VendorInvoice) error { - query := ` - UPDATE vendor_invoices - SET amount_paid = $2, status = $3, approved_by = $4, approved_at = $5 - WHERE id = $1 - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - inv.ID, float64(inv.AmountPaid)/100.0, inv.Status, inv.ApprovedBy, inv.ApprovedAt, - ) +// ListFilter is the list's filters and keyset position. +type ListFilter struct { + VendorID *uuid.UUID + Statuses []Status + POID *uuid.UUID + AfterTime *time.Time + AfterID uuid.UUID + Limit int +} + +var listWhere = "\n\tWHERE " + wall("vi", 1, 2) + ` + AND ($3::uuid IS NULL OR vi.vendor_id = $3) + AND (cardinality($4::text[]) = 0 OR vi.status = ANY($4)) + AND ($5::uuid IS NULL OR vi.po_id = $5)` + +func (r *Repository) listArgs(ctx context.Context, f ListFilter) []any { + statuses := make([]string, len(f.Statuses)) + for i, s := range f.Statuses { + statuses[i] = storageStatus(s) + } + return []any{middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx), f.VendorID, statuses, f.POID} +} + +// List answers one page, newest first by (created_at, id). +func (r *Repository) List(ctx context.Context, f ListFilter) ([]Summary, 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 ($6::timestamptz IS NULL OR (vi.created_at, vi.id) < ($6, $7::uuid)) + ORDER BY vi.created_at DESC, vi.id DESC + LIMIT $8`, args...) if err != nil { - return fmt.Errorf("failed to update vendor invoice: %w", err) + return nil, fmt.Errorf("failed to list vendor invoices: %w", err) } - return nil + defer rows.Close() + out := []Summary{} + for rows.Next() { + var s Summary + if err := scanSummary(rows.Scan, &s); err != nil { + return nil, fmt.Errorf("failed to scan vendor invoice: %w", err) + } + out = append(out, s) + } + return out, rows.Err() } -func (r *PostgresRepository) AddInvoiceLine(ctx context.Context, line *VendorInvoiceLine) error { - if line.ID == uuid.Nil { - line.ID = uuid.New() +// Count is the size of the filtered set, for ?include=total. +func (r *Repository) Count(ctx context.Context, f ListFilter) (int64, error) { + var n int64 + if err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM vendor_invoices vi`+listWhere, + r.listArgs(ctx, f)...).Scan(&n); err != nil { + return 0, fmt.Errorf("failed to count vendor invoices: %w", err) } - line.CreatedAt = time.Now() + return n, nil +} - query := ` - INSERT INTO vendor_invoice_lines (id, invoice_id, description, quantity, unit_price, line_total, gl_account_id, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - line.ID, line.InvoiceID, line.Description, line.Quantity, - float64(line.UnitPrice)/100.0, float64(line.LineTotal)/100.0, - line.GLAccountID, line.CreatedAt, - ) +// LockedInvoice is a bill read under its row lock: what a transition or a +// payment checks before it writes. +type LockedInvoice struct { + Summary +} + +// Lock takes the vendor invoice row FOR UPDATE, held to the branch wall. +func (r *Repository) Lock(ctx context.Context, id uuid.UUID) (*LockedInvoice, error) { + var locked LockedInvoice + row := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT `+summaryColumns+summaryFrom+` + WHERE vi.id = $1 AND `+wall("vi", 2, 3)+` FOR UPDATE OF vi`, id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + if err := scanSummary(row.Scan, &locked.Summary); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errInvoiceNotFound + } + return nil, fmt.Errorf("failed to lock vendor invoice: %w", err) + } + return &locked, nil +} + +// LockForPayment takes one invoice of the named vendor FOR UPDATE, in the +// caller's id order (ADR 0008 section 9 step 5a). No row means no such +// invoice for this vendor. +func (r *Repository) LockForPayment(ctx context.Context, id, vendorID uuid.UUID) (*Summary, error) { + var s Summary + row := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT `+summaryColumns+summaryFrom+` + WHERE vi.id = $1 AND vi.vendor_id = $2 AND `+wall("vi", 3, 4)+` FOR UPDATE OF vi`, + id, vendorID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + if err := scanSummary(row.Scan, &s); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to lock vendor invoice for payment: %w", err) + } + return &s, nil +} + +// Create inserts a bill and its lines. The number comes from the column's +// DEFAULT, which mints through this transaction (a rollback abandons it). +func (r *Repository) Create(ctx context.Context, in *Input, branch uuid.UUID, currency string) (*Invoice, error) { + var inv Invoice + var created time.Time + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + INSERT INTO vendor_invoices (vendor_id, invoice_number, invoice_date, due_date, po_id, branch_id, currency, + subtotal, tax_amount, total, amount_open, status, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::bigint::numeric / 100, $9::bigint::numeric / 100, + $10::bigint::numeric / 100, $10::bigint::numeric / 100, 'PENDING', NULLIF($11, '')) + RETURNING id, number, created_at`, + in.VendorID, in.VendorInvoiceNumber, in.InvoiceDate, in.DueDate, in.POID, branch, currency, + int64(subtotalCents(in)), int64(in.TaxCents), int64(totalCents(in)), in.Notes).Scan(&inv.ID, &inv.Number, &created) if err != nil { - return fmt.Errorf("failed to add invoice line: %w", err) + return nil, mapWriteError(err, "vendor_invoice_number", "vendor_id") + } + inv.CreatedAt = httpx.TimestampOf(created) + for i, l := range in.Lines { + if _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + INSERT INTO vendor_invoice_lines (invoice_id, position, description, quantity, unit_price, line_total, + gl_account_id, purchase_order_line_id, product_id) + VALUES ($1, $2, $3, $4::numeric, $5::bigint::numeric / 10000, $6::bigint::numeric / 100, $7, $8, $9)`, + inv.ID, i, l.Description, l.Quantity.DecimalString(), int64(l.UnitPrice), int64(l.LineTotalCents), + l.GLAccountID, l.PurchaseOrderLineID, l.ProductID); err != nil { + return nil, mapWriteError(err, "", fmt.Sprintf("lines[%d]", i)) + } + } + full, err := r.Get(ctx, inv.ID) + if err != nil { + return nil, err + } + return full, nil +} + +// MarkApproved sets the bill approved under its lock, recording who approved +// it and the entry the approval posted. +func (r *Repository) MarkApproved(ctx context.Context, id uuid.UUID, approver *uuid.UUID, entryID *uuid.UUID) error { + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE vendor_invoices SET status = 'APPROVED', approved_by = $2, approved_at = NOW(), + gl_entry_id = $3, revision = revision + 1 + WHERE id = $1 AND status = 'PENDING'`, id, approver, entryID) + if err != nil { + return fmt.Errorf("failed to approve vendor invoice: %w", err) } return nil } -func (r *PostgresRepository) GetInvoiceLines(ctx context.Context, invoiceID uuid.UUID) ([]VendorInvoiceLine, error) { - query := ` - SELECT id, invoice_id, description, quantity, unit_price, line_total, gl_account_id, created_at - FROM vendor_invoice_lines - WHERE invoice_id = $1 - ORDER BY created_at ASC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, invoiceID) +// MarkVoided sets the bill voided under its lock, owing nothing more. +func (r *Repository) MarkVoided(ctx context.Context, id uuid.UUID) error { + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE vendor_invoices SET status = 'VOIDED', amount_open = 0, revision = revision + 1 + WHERE id = $1`, id) if err != nil { - return nil, fmt.Errorf("failed to get invoice lines: %w", err) + return fmt.Errorf("failed to void vendor invoice: %w", err) } - defer rows.Close() + return nil +} - var lines []VendorInvoiceLine - for rows.Next() { - var line VendorInvoiceLine - var unitPrice, lineTotal float64 - if err := rows.Scan( - &line.ID, &line.InvoiceID, &line.Description, &line.Quantity, - &unitPrice, &lineTotal, &line.GLAccountID, &line.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("failed to scan invoice line: %w", err) - } - line.UnitPrice = money.DollarsToCents(unitPrice) - line.LineTotal = money.DollarsToCents(lineTotal) - lines = append(lines, line) +// ApplyToInvoice moves a payment's cents onto a locked bill: the paid and open +// amounts, the status the open amount settles, and the revision. +func (r *Repository) ApplyToInvoice(ctx context.Context, id uuid.UUID, apply int64) (paid bool, err error) { + err = r.db.GetExecutor(ctx).QueryRow(ctx, ` + UPDATE vendor_invoices + SET amount_paid = amount_paid + $2::bigint::numeric / 100, + amount_open = amount_open - $2::bigint::numeric / 100, + status = CASE WHEN amount_open - $2::bigint::numeric / 100 = 0 THEN 'PAID' ELSE 'PARTIAL' END, + revision = revision + 1 + WHERE id = $1 RETURNING (status = 'PAID')`, id, apply).Scan(&paid) + if err != nil { + return false, fmt.Errorf("failed to apply payment to vendor invoice: %w", err) } - return lines, nil + return paid, nil } -func (r *PostgresRepository) CreatePayment(ctx context.Context, pmt *APPayment) error { +// CreatePayment writes an AP payment row. +func (r *Repository) CreatePayment(ctx context.Context, pmt *APPayment) error { if pmt.ID == uuid.Nil { pmt.ID = uuid.New() } pmt.CreatedAt = time.Now() - - query := ` - INSERT INTO ap_payments (id, vendor_id, batch_id, amount, method, check_number, reference, payment_date, status, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - pmt.ID, pmt.VendorID, pmt.BatchID, float64(pmt.Amount)/100.0, - pmt.Method, pmt.CheckNumber, pmt.Reference, pmt.PaymentDate, pmt.Status, pmt.CreatedAt, - ) + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + INSERT INTO ap_payments (id, vendor_id, amount, method, check_number, reference, payment_date, status, created_at) + VALUES ($1, $2, $3::bigint::numeric / 100, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8, $9)`, + pmt.ID, pmt.VendorID, pmt.Amount, pmt.Method, pmt.CheckNumber, pmt.Reference, pmt.PaymentDate, pmt.Status, pmt.CreatedAt) if err != nil { - return fmt.Errorf("failed to create AP payment: %w", err) + return mapWriteError(err, "", "vendor_id") } return nil } -func (r *PostgresRepository) CreatePaymentApplication(ctx context.Context, app *APPaymentApplication) error { +// CreatePaymentApplication links an applied amount to a bill. +func (r *Repository) CreatePaymentApplication(ctx context.Context, app *APPaymentApplication) error { if app.ID == uuid.Nil { app.ID = uuid.New() } - app.CreatedAt = time.Now() - - query := ` + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` INSERT INTO ap_payment_applications (id, payment_id, invoice_id, amount, created_at) - VALUES ($1, $2, $3, $4, $5) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - app.ID, app.PaymentID, app.InvoiceID, float64(app.Amount)/100.0, app.CreatedAt, - ) + VALUES ($1, $2, $3, $4::bigint::numeric / 100, $5)`, + app.ID, app.PaymentID, app.InvoiceID, app.Amount, time.Now()) if err != nil { - return fmt.Errorf("failed to create payment application: %w", err) + return mapWriteError(err, "", "invoice_id") } return nil } -func (r *PostgresRepository) ListPayments(ctx context.Context, vendorID *uuid.UUID) ([]APPayment, error) { - query := ` +// ListPayments lists a vendor's payments, newest first. +func (r *Repository) ListPayments(ctx context.Context, vendorID *uuid.UUID) ([]APPayment, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` SELECT p.id, p.vendor_id, COALESCE(v.name, '') as vendor_name, - p.amount, p.method, COALESCE(p.check_number, '') as check_number, - COALESCE(p.reference, '') as reference, p.payment_date, p.status, p.created_at + ROUND(p.amount * 100)::bigint, p.method, COALESCE(p.check_number, ''), + COALESCE(p.reference, ''), to_char(p.payment_date, 'YYYY-MM-DD'), p.status, p.created_at FROM ap_payments p LEFT JOIN vendors v ON v.id = p.vendor_id WHERE ($1::uuid IS NULL OR p.vendor_id = $1) - ORDER BY p.payment_date DESC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, vendorID) + ORDER BY p.created_at DESC, p.id DESC`, vendorID) if err != nil { return nil, fmt.Errorf("failed to list AP payments: %w", err) } defer rows.Close() - var payments []APPayment for rows.Next() { var pmt APPayment - var amount float64 - if err := rows.Scan( - &pmt.ID, &pmt.VendorID, &pmt.VendorName, - &amount, &pmt.Method, &pmt.CheckNumber, - &pmt.Reference, &pmt.PaymentDate, &pmt.Status, &pmt.CreatedAt, - ); err != nil { + var amount int64 + if err := rows.Scan(&pmt.ID, &pmt.VendorID, &pmt.VendorName, &amount, &pmt.Method, &pmt.CheckNumber, + &pmt.Reference, &pmt.PaymentDate, &pmt.Status, &pmt.CreatedAt); err != nil { return nil, fmt.Errorf("failed to scan AP payment: %w", err) } - pmt.Amount = money.DollarsToCents(amount) + pmt.Amount = amount payments = append(payments, pmt) } - return payments, nil + return payments, rows.Err() } -func (r *PostgresRepository) GetAgingSummary(ctx context.Context) ([]APAgingSummary, error) { - query := ` +// GetAgingSummary returns the AP aging report by vendor, in cents. +func (r *Repository) GetAgingSummary(ctx context.Context) ([]APAgingSummary, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` SELECT vi.vendor_id, COALESCE(v.name, 'Unknown') as vendor_name, - COALESCE(SUM(CASE WHEN vi.due_date >= CURRENT_DATE THEN vi.total - vi.amount_paid ELSE 0 END), 0) as current_amt, - COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE AND vi.due_date >= CURRENT_DATE - 30 THEN vi.total - vi.amount_paid ELSE 0 END), 0) as past_30, - COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE - 30 AND vi.due_date >= CURRENT_DATE - 60 THEN vi.total - vi.amount_paid ELSE 0 END), 0) as past_60, - COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE - 60 THEN vi.total - vi.amount_paid ELSE 0 END), 0) as past_90, - COALESCE(SUM(vi.total - vi.amount_paid), 0) as total + ROUND(COALESCE(SUM(CASE WHEN vi.due_date >= CURRENT_DATE THEN vi.amount_open ELSE 0 END), 0) * 100)::bigint, + ROUND(COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE AND vi.due_date >= CURRENT_DATE - 30 THEN vi.amount_open ELSE 0 END), 0) * 100)::bigint, + ROUND(COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE - 30 AND vi.due_date >= CURRENT_DATE - 60 THEN vi.amount_open ELSE 0 END), 0) * 100)::bigint, + ROUND(COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE - 60 THEN vi.amount_open ELSE 0 END), 0) * 100)::bigint, + ROUND(COALESCE(SUM(vi.amount_open), 0) * 100)::bigint FROM vendor_invoices vi LEFT JOIN vendors v ON v.id = vi.vendor_id WHERE vi.status NOT IN ('PAID', 'VOIDED') GROUP BY vi.vendor_id, v.name - ORDER BY total DESC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query) + ORDER BY 6 DESC`) if err != nil { return nil, fmt.Errorf("failed to get AP aging: %w", err) } defer rows.Close() - var summaries []APAgingSummary for rows.Next() { var s APAgingSummary - var current, past30, past60, past90, total float64 - if err := rows.Scan(&s.VendorID, &s.VendorName, ¤t, &past30, &past60, &past90, &total); err != nil { + if err := rows.Scan(&s.VendorID, &s.VendorName, &s.Current, &s.Past30, &s.Past60, &s.Past90, &s.Total); err != nil { return nil, fmt.Errorf("failed to scan aging: %w", err) } - s.Current = money.DollarsToCents(current) - s.Past30 = money.DollarsToCents(past30) - s.Past60 = money.DollarsToCents(past60) - s.Past90 = money.DollarsToCents(past90) - s.Total = money.DollarsToCents(total) summaries = append(summaries, s) } - return summaries, nil + return summaries, rows.Err() +} + +// VendorFacts are what a bill takes from its vendor: that it exists. +func (r *Repository) VendorExists(ctx context.Context, id uuid.UUID) (bool, error) { + var n int + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM vendors WHERE id = $1`, id).Scan(&n) + return n > 0, err +} + +// POExists reports whether a purchase order exists, for the po_id reference. +func (r *Repository) POExists(ctx context.Context, id uuid.UUID) (bool, error) { + var n int + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM purchase_orders WHERE id = $1`, id).Scan(&n) + return n > 0, err +} + +// POLineExists reports whether a purchase order line exists, for a line's +// purchase_order_line_id reference. +func (r *Repository) POLineExists(ctx context.Context, id uuid.UUID) (bool, error) { + var n int + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM purchase_order_lines WHERE id = $1`, id).Scan(&n) + return n > 0, err +} + +// ProductExists reports whether a product exists, for a line's product_id +// reference. +func (r *Repository) ProductExists(ctx context.Context, id uuid.UUID) (bool, error) { + var n int + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM products WHERE id = $1`, id).Scan(&n) + return n > 0, err +} + +// mapWriteError turns a database write refusal into the wire's error: a +// unique violation is a 409 duplicate naming the field, a foreign key +// violation is a 400 naming the field, and everything else stands as it is. +// dupField is the field a uniqueness refusal names; fks are the fields a +// foreign key refusal names, in the order the insert writes them. +func mapWriteError(err error, dupField string, fks ...string) error { + var pg interface{ SQLState() string } + if !errors.As(err, &pg) { + return err + } + field := "vendor_id" + if len(fks) > 0 { + field = fks[0] + } + switch pg.SQLState() { + case "23505": + dup := httpx.FieldError{Field: dupField, Message: "already used"} + if dupField == "" { + return httpx.Duplicate("a record this request would create already exists") + } + return httpx.Duplicate("a record this request would create already exists", dup) + case "23503": + return httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: field, Message: "no such record"}) + } + return err } diff --git a/core/internal/ap/service.go b/core/internal/ap/service.go index f1feb7fa1..947fb914d 100644 --- a/core/internal/ap/service.go +++ b/core/internal/ap/service.go @@ -5,261 +5,706 @@ package ap import ( "context" + "encoding/json" + "errors" "fmt" "log/slog" + "net/http" + "sort" "time" "github.com/gablelbm/gable/internal/gl" - "github.com/gablelbm/gable/pkg/money" + "github.com/gablelbm/gable/internal/platform/httpx" + "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" ) -// Database interface for transaction support. -type Database interface { +// 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 +} + +// AuditSink writes an audit row (*audit.Logger satisfies it). +type AuditSink interface { + Log(ctx context.Context, e audit.Entry) error +} + +// BranchGuard applies the payload branch rule (ADR 0008 section 11). +type BranchGuard interface { + CheckPayloadBranch(ctx context.Context, branch uuid.UUID) error +} + +// 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 } -// Service handles AP business logic. +// Event types the module writes (ADR 0008 section 10). +const ( + EventCreated = "vendor_invoice.created" + EventApproved = "vendor_invoice.approved" + EventPartial = "vendor_invoice.partial" + EventPaid = "vendor_invoice.paid" + EventVoided = "vendor_invoice.voided" +) + +// Service is the AP module: the vendor invoice acts of ADR 0008 section 7.4 +// on the wire contract of ADR 0001, and the payment act they fix. Every +// mutation is one transaction: the write, the journal entry it causes, the +// audit row, then the events last. type Service struct { - db Database - repo Repository - glSvc *gl.Service - logger *slog.Logger + db *database.DB + tx TxRunner + repo *Repository + gl *gl.Service + auditLog AuditSink + events EventRecorder + branches BranchGuard + logger *slog.Logger + now func() time.Time } -// NewService creates a new AP service. -func NewService(db Database, repo Repository, glSvc *gl.Service, logger *slog.Logger) *Service { - return &Service{ - db: db, - repo: repo, - glSvc: glSvc, - logger: logger, +func NewService(db *database.DB, repo *Repository, glSvc *gl.Service, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() } + return &Service{db: db, repo: repo, gl: glSvc, logger: logger, now: time.Now} } -// CreateVendorInvoice enters a new vendor bill with line items. -func (s *Service) CreateVendorInvoice(ctx context.Context, req CreateVendorInvoiceRequest) (*VendorInvoice, error) { - invoiceDate, err := time.Parse("2006-01-02", req.InvoiceDate) - if err != nil { - return nil, fmt.Errorf("invalid invoice_date: %w", err) +// 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 } + +// WithAuditLog sets the audit logger. +func (s *Service) WithAuditLog(l AuditSink) *Service { s.auditLog = l; return s } + +// WithBranchGuard sets the payload branch rule for the write routes. +func (s *Service) WithBranchGuard(g BranchGuard) *Service { s.branches = g; return s } + +func (s *Service) inTx(ctx context.Context, fn func(ctx context.Context) error) error { + if s.tx != nil { + return s.tx.RunInTx(ctx, fn) } - dueDate, err := time.Parse("2006-01-02", req.DueDate) - if err != nil { - return nil, fmt.Errorf("invalid due_date: %w", err) - } - - // Calculate subtotal from lines - var subtotalCents int64 - for _, line := range req.Lines { - lineTotalCents := money.DollarsToCents(line.UnitPrice * line.Quantity) - subtotalCents += lineTotalCents - } - taxCents := money.DollarsToCents(req.TaxAmount) - totalCents := subtotalCents + taxCents - - var inv *VendorInvoice - err = s.db.RunInTx(ctx, func(ctx context.Context) error { - inv = &VendorInvoice{ - VendorID: req.VendorID, - InvoiceNumber: req.InvoiceNumber, - InvoiceDate: invoiceDate, - DueDate: dueDate, - POID: req.POID, - Subtotal: subtotalCents, - TaxAmount: taxCents, - Total: totalCents, - AmountPaid: 0, - Status: InvoiceStatusPending, - Notes: req.Notes, - } - - if err := s.repo.CreateVendorInvoice(ctx, inv); err != nil { + return s.db.RunInTx(ctx, fn) +} + +func (s *Service) record(ctx context.Context, ev outbox.Event) error { + if s.events == nil { + return nil + } + return s.events.Write(ctx, ev) +} + +func (s *Service) recordAll(ctx context.Context, evs []outbox.Event) error { + for _, ev := range evs { + if err := s.record(ctx, ev); err != nil { return err } + } + return nil +} - // Add line items - for _, lineReq := range req.Lines { - unitPriceCents := money.DollarsToCents(lineReq.UnitPrice) - lineTotalCents := money.DollarsToCents(lineReq.UnitPrice * lineReq.Quantity) +func (s *Service) audit(ctx context.Context, e audit.Entry) error { + if s.auditLog == nil { + return nil + } + if err := s.auditLog.Log(ctx, e); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + return nil +} - line := &VendorInvoiceLine{ - InvoiceID: inv.ID, - Description: lineReq.Description, - Quantity: lineReq.Quantity, - UnitPrice: unitPriceCents, - LineTotal: lineTotalCents, - GLAccountID: lineReq.GLAccountID, - } - if err := s.repo.AddInvoiceLine(ctx, line); err != nil { - return err - } - } +// Precondition is the client's revision for an act on the bill named in the +// path (ADR 0001 section 11). An in-process caller sets Any: the same rules +// and the same event, without a client revision to check. +type Precondition struct { + IfMatch string + Revision *int64 + Any bool +} - s.logger.Info("Vendor invoice created", - "id", inv.ID, - "vendor_id", inv.VendorID, - "total_cents", totalCents, - ) +func (p Precondition) missing() bool { return !p.Any && p.IfMatch == "" && p.Revision == nil } + +func (p Precondition) check(current int64) error { + if p.Any { return nil - }) + } + return httpx.CheckRevision(current, p.IfMatch, p.Revision) +} + +// Caller is who is acting: the audit subject. +type Caller struct { + Actor string + Role string +} + +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)}} +} + +// checkBranch is the record branch rule for a branch a payload names. +func (s *Service) checkBranch(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: "the branch 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 +} + +func subtotalCents(in *Input) httpx.Cents { + var sum httpx.Cents + for _, l := range in.Lines { + sum += l.LineTotalCents + } + return sum +} + +func totalCents(in *Input) httpx.Cents { return subtotalCents(in) + in.TaxCents } + +func mustJSON(v map[string]any) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte(`{}`) + } + return b +} +// event builds one vendor_invoice.* event: a small summary, never the +// document (ADR 0008 section 10). +func (s *Service) event(typ string, from Status, inv *Summary) outbox.Event { + branch := inv.BranchID + return outbox.Event{ + Type: typ, EntityType: "vendor_invoice", EntityID: inv.ID, BranchID: &branch, + Data: mustJSON(map[string]any{ + "number": inv.Number, "vendor_invoice_number": inv.VendorInvoiceNumber, "vendor_id": inv.VendorID, + "branch_id": inv.BranchID, "status": inv.Status, "from_status": from, "revision": inv.Revision, + "total_cents": int64(inv.TotalCents), "amount_open_cents": int64(inv.AmountOpenCents), "currency": inv.Currency, + }), + } +} + +// reloaded reads the bill's summary again inside the transaction, for the +// event and the response. +func (s *Service) reloaded(ctx context.Context, id uuid.UUID) (*Summary, error) { + inv, err := s.repo.Get(ctx, id) if err != nil { return nil, err } - return inv, nil + return &inv.Summary, nil } -// ApproveInvoice approves a pending vendor invoice and posts to GL. -func (s *Service) ApproveInvoice(ctx context.Context, invoiceID uuid.UUID, approverID uuid.UUID) (*VendorInvoice, error) { - inv, err := s.repo.GetVendorInvoice(ctx, invoiceID) +// resolve holds what a create takes from the database: the vendor, the +// references, the currency default and the branch. +func (s *Service) resolve(ctx context.Context, in *Input) (branch uuid.UUID, currency string, err error) { + ok, err := s.repo.VendorExists(ctx, in.VendorID) + if err != nil { + return uuid.Nil, "", err + } + if !ok { + return uuid.Nil, "", httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: "vendor_id", Message: "no such vendor"}) + } + if in.POID != nil { + ok, err := s.repo.POExists(ctx, *in.POID) + if err != nil { + return uuid.Nil, "", err + } + if !ok { + return uuid.Nil, "", httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: "po_id", Message: "no such purchase order"}) + } + } + for i, l := range in.Lines { + if l.PurchaseOrderLineID != nil { + ok, err := s.repo.POLineExists(ctx, *l.PurchaseOrderLineID) + if err != nil { + return uuid.Nil, "", err + } + if !ok { + return uuid.Nil, "", httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: fmt.Sprintf("lines[%d].purchase_order_line_id", i), Message: "no such purchase order line"}) + } + } + if l.ProductID != nil { + ok, err := s.repo.ProductExists(ctx, *l.ProductID) + if err != nil { + return uuid.Nil, "", err + } + if !ok { + return uuid.Nil, "", httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: fmt.Sprintf("lines[%d].product_id", i), Message: "no such product"}) + } + } + } + currency = in.Currency + if currency == "" { + var setting string + if err := s.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT COALESCE((SELECT value FROM system_settings WHERE key = 'currency.default'), 'USD')`).Scan(&setting); err != nil { + return uuid.Nil, "", err + } + currency = setting + } + branch = uuid.Nil + if in.BranchID != nil { + branch = *in.BranchID + } else if ctxBranch := middleware.BranchIDForQuery(ctx); ctxBranch != nil { + branch = *ctxBranch + } else { + var def uuid.UUID + if err := s.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'`).Scan(&def); err != nil { + return uuid.Nil, "", err + } + branch = def + } + if err := s.checkBranch(ctx, branch, "branch_id"); err != nil { + return uuid.Nil, "", err + } + return branch, currency, nil +} + +// Create enters a vendor bill: it starts pending, owing its whole total, with +// Gable's own number and the vendor's as vendor_invoice_number. +func (s *Service) Create(ctx context.Context, in *Input, who Caller) (*Invoice, error) { + branch, currency, err := s.resolve(ctx, in) + if err != nil { + return nil, err + } + var out *Invoice + err = s.inTx(ctx, func(ctx context.Context) error { + inv, err := s.repo.Create(ctx, in, branch, currency) + if err != nil { + return err + } + out = inv + if err := s.audit(ctx, audit.Entry{Action: "vendor_invoice.created", EntityType: "vendor_invoice", + EntityID: inv.ID, UserID: who.Actor, Changes: map[string]interface{}{ + "number": inv.Number, "vendor_invoice_number": inv.VendorInvoiceNumber, "vendor_id": inv.VendorID, + "total_cents": int64(inv.TotalCents), "line_count": len(in.Lines), "currency": inv.Currency}}); err != nil { + return err + } + return s.record(ctx, s.event(EventCreated, "", &inv.Summary)) + }) if err != nil { return nil, err } + s.logger.Info("vendor invoice entered", "id", out.ID, "number", out.Number, "total_cents", int64(out.TotalCents)) + return out, nil +} - if inv.Status != InvoiceStatusPending { - return nil, fmt.Errorf("invoice is not pending (status: %s)", inv.Status) +// Get reads one bill with its lines, held to the caller's branch wall. +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Invoice, error) { + return s.repo.Get(ctx, id) +} + +// List answers one page and whether another follows. +func (s *Service) List(ctx context.Context, f ListFilter, wantTotal bool) ([]Summary, bool, *int64, error) { + asked := f.Limit + f.Limit = asked + 1 + items, err := s.repo.List(ctx, f) + if err != nil { + return nil, false, nil, err + } + more := len(items) > asked + if more { + items = items[:asked] + } + var total *int64 + if wantTotal { + n, err := s.repo.Count(ctx, f) + if err != nil { + return nil, false, nil, err + } + total = &n } + return items, more, total, nil +} - err = s.db.RunInTx(ctx, func(txCtx context.Context) error { - now := time.Now() - inv.Status = InvoiceStatusApproved - inv.ApprovedBy = &approverID - inv.ApprovedAt = &now +// Transition runs POST /ap/invoices/{id}/transitions: to approved (the +// approve of ADR 0008 7.4) or to voided (the transition that reverses the +// entry). +func (s *Service) Transition(ctx context.Context, id uuid.UUID, in *TransitionInput, pre Precondition, who Caller) (*Invoice, error) { + if pre.missing() { + return nil, httpx.PreconditionRequired("this write needs If-Match or a body revision") + } + switch in.To { + case StatusApproved: + return s.approve(ctx, id, pre, who, nil) + case StatusVoided: + return s.void(ctx, id, pre, in.Reason, who) + case StatusPending, StatusPartial, StatusPaid: + return nil, httpx.InvalidStateTransition("a bill never moves to " + in.To.Status() + " by a transition") + } + return nil, httpx.InvalidStateTransition("a bill never moves there by a transition") +} - if err := s.repo.UpdateVendorInvoice(txCtx, inv); err != nil { +// approve is the act ADR 0008 7.4 fixes: it loads the lines, posts the entry +// through gl.PostEntry inside the approve transaction, and a posting failure +// fails the act. approver is the recorded approver (the caller's UUID when it +// has one); a nil approver with an in-process caller records none. +func (s *Service) approve(ctx context.Context, id uuid.UUID, pre Precondition, who Caller, approver *uuid.UUID) (*Invoice, error) { + var out *Invoice + err := s.inTx(ctx, func(ctx context.Context) error { + locked, err := s.repo.Lock(ctx, id) + if err != nil { + return err + } + if err := pre.check(locked.Revision); err != nil { return err } + if locked.Status != StatusPending { + return httpx.InvalidStateTransition("only a pending bill can be approved", + httpx.Blocker("invoice_not_pending", "the bill is "+locked.Status.Status())) + } + lines, err := s.repo.LinesWithCodes(ctx, id) + if err != nil { + return err + } + var subtotal httpx.Cents + for _, l := range lines { + if l.AccountCode == "" { + return conflictBlocker("line_needs_account", + fmt.Sprintf("line %d has no account: the approve entry debits each line's account", l.Position)) + } + subtotal += l.LineTotalCents + } + entryID, err := s.postInvoiceEntry(ctx, &locked.Summary, lines, subtotal, who.Actor) + if err != nil { + return err + } + var approvedBy *uuid.UUID + if approver != nil { + approvedBy = approver + } else if id, err := uuid.Parse(who.Actor); err == nil { + approvedBy = &id + } + if err := s.repo.MarkApproved(ctx, id, approvedBy, entryID); err != nil { + return err + } + if err := s.audit(ctx, audit.Entry{Action: "vendor_invoice.approved", EntityType: "vendor_invoice", + EntityID: id, UserID: who.Actor, Changes: map[string]interface{}{ + "number": locked.Number, "total_cents": int64(locked.TotalCents), "gl_entry_id": entryID}}); err != nil { + return err + } + after, err := s.reloaded(ctx, id) + if err != nil { + return err + } + if err := s.record(ctx, s.event(EventApproved, StatusPending, after)); err != nil { + return err + } + full, err := s.repo.Get(ctx, id) + if err != nil { + return err + } + out = full + return nil + }) + if err != nil { + return nil, err + } + s.logger.Info("vendor invoice approved", "id", id, "total_cents", int64(out.TotalCents)) + return out, nil +} - // Post to GL: DR Expense/Inventory, CR Accounts Payable - var glLines []gl.VendorInvoiceLineDetail - for _, line := range inv.Lines { - glLines = append(glLines, gl.VendorInvoiceLineDetail{ - Description: line.Description, - AmountCents: line.LineTotal, - GLAccountID: line.GLAccountID, - }) +// postInvoiceEntry builds and posts the approve entry: a debit leg per line +// on the line's account, the tax spread across the line accounts pro rata to +// their extensions (the last line taking the remainder, the freight rule of +// ADR 0008 3.5), and the credit to Accounts Payable for the whole total. The +// entry rides the approve transaction; a posting failure fails the act. +func (s *Service) postInvoiceEntry(ctx context.Context, inv *Summary, lines []LineWithCode, subtotal httpx.Cents, actor string) (*uuid.UUID, error) { + tax := int64(inv.TaxCents) + remaining := tax + legs := make([]gl.Leg, 0, len(lines)+1) + for i, l := range lines { + debit := int64(l.LineTotalCents) + switch { + case i == len(lines)-1: + debit += remaining + case int64(subtotal) > 0: + share := tax * int64(l.LineTotalCents) / int64(subtotal) + debit += share + remaining -= share } - if err := s.glSvc.SyncVendorInvoice(txCtx, inv.ID, inv.Total, glLines); err != nil { - return fmt.Errorf("failed to sync vendor invoice to GL: %w", err) + legs = append(legs, gl.Leg{AccountCode: l.AccountCode, Description: l.Description, Debit: debit}) + } + legs = append(legs, gl.Leg{AccountCode: gl.AccountCodeAP, Description: "Accounts Payable", Credit: int64(inv.TotalCents)}) + entry, err := s.gl.PostEntry(ctx, gl.PostingInput{ + EntryDate: mustDate(inv.InvoiceDate), Memo: "Vendor invoice " + inv.Number, + Source: gl.SourceVendorInv, SourceRefID: &inv.ID, Currency: inv.Currency, PostedBy: actor, Legs: legs, + }) + if err != nil { + if errors.Is(err, gl.ErrPeriodClosed) { + return nil, conflictBlocker("period_closed", "the entry would be dated into a closed fiscal period") } + if errors.Is(err, gl.ErrUnbalanced) { + return nil, conflictBlocker("entry_unbalanced", "the bill's lines do not balance against its total") + } + return nil, fmt.Errorf("failed to post the vendor invoice entry: %w", err) + } + if entry == nil { + return nil, nil + } + return &entry.ID, nil +} +// void is the transition that reverses the entry: a pending bill has nothing +// posted and simply dies; an approved one's entry is reversed whole. A bill +// with money applied is refused: the payments have to be undone first. +func (s *Service) void(ctx context.Context, id uuid.UUID, pre Precondition, reason string, who Caller) (*Invoice, error) { + if reason == "" { + return nil, httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "reason", Message: "is required to void a bill"}) + } + var out *Invoice + err := s.inTx(ctx, func(ctx context.Context) error { + locked, err := s.repo.Lock(ctx, id) + if err != nil { + return err + } + if err := pre.check(locked.Revision); err != nil { + return err + } + from := locked.Status + if locked.AmountPaidCents > 0 { + return httpx.InvalidStateTransition("the bill carries payments: they must be undone before it is voided", + httpx.Blocker("has_payments", fmt.Sprintf("%d cents have been applied to this bill", int64(locked.AmountPaidCents)))) + } + if !from.allows(StatusVoided) { + return httpx.InvalidStateTransition("a " + from.Status() + " bill cannot be voided") + } + var reversal *uuid.UUID + if locked.GLEntryID != nil { + entry, err := s.gl.PostReversal(ctx, gl.ReversalInput{ + EntryID: *locked.GLEntryID, EntryDate: s.now(), Currency: locked.Currency, + Reason: reason, PostedBy: who.Actor, + }) + if err != nil { + if errors.Is(err, gl.ErrPeriodClosed) { + return conflictBlocker("period_closed", "the reversal would be dated into a closed fiscal period") + } + return fmt.Errorf("failed to reverse the vendor invoice entry: %w", err) + } + if entry != nil { + reversal = &entry.ID + } + } + if err := s.repo.MarkVoided(ctx, id); err != nil { + return err + } + if err := s.audit(ctx, audit.Entry{Action: "vendor_invoice.voided", EntityType: "vendor_invoice", + EntityID: id, UserID: who.Actor, Changes: map[string]interface{}{ + "number": locked.Number, "reason": reason, "reversal_entry_id": reversal}}); err != nil { + return err + } + after, err := s.reloaded(ctx, id) + if err != nil { + return err + } + if err := s.record(ctx, s.event(EventVoided, from, after)); err != nil { + return err + } + full, err := s.repo.Get(ctx, id) + if err != nil { + return err + } + out = full return nil }) if err != nil { return nil, err } + return out, nil +} - s.logger.Info("Vendor invoice approved and synced to GL", - "id", invoiceID, - "approved_by", approverID, - "total_cents", inv.Total, - ) +// ApproveInvoice is the in-process approve (the matcher's auto approve): the +// same transition without a client revision, attributed to the given +// approver. +func (s *Service) ApproveInvoice(ctx context.Context, invoiceID uuid.UUID, approverID uuid.UUID) (*Invoice, error) { + return s.approve(ctx, invoiceID, Precondition{Any: true}, Caller{Actor: approverID.String()}, &approverID) +} - return inv, nil +// GetVendorInvoice is the in-process read of one bill with its lines. +func (s *Service) GetVendorInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) { + return s.repo.Get(ctx, id) } -// PayVendor creates a payment and applies it to invoices. -func (s *Service) PayVendor(ctx context.Context, req CreateAPPaymentRequest) (*APPayment, error) { - paymentDate, err := time.Parse("2006-01-02", req.PaymentDate) +// ListVendorInvoices is the in-process listing matching reads: every bill of +// the vendor (or every vendor), optionally by storage status. +func (s *Service) ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]Invoice, error) { + ids, err := s.repo.List(ctx, ListFilter{VendorID: vendorID, Limit: 100000}) if err != nil { - return nil, fmt.Errorf("invalid payment_date: %w", err) + return nil, err + } + out := make([]Invoice, 0, len(ids)) + for _, h := range ids { + if status != "" && storageStatus(h.Status) != status && h.Status.Status() != status { + continue + } + full, err := s.repo.Get(ctx, h.ID) + if err != nil { + return nil, err + } + out = append(out, *full) } + return out, nil +} - amountCents := money.DollarsToCents(req.Amount) +// PayVendor records a payment to a vendor and applies it to the bills it +// names, in id order, under their locks: only an approved or partial bill +// takes money (ADR 0008 7.4). The entry posts through gl.PostEntry inside the +// act's transaction; a posting failure fails the act. +func (s *Service) PayVendor(ctx context.Context, in *PayInput, who Caller) (*APPayment, error) { + // The invoices are locked in id order (ADR 0008 section 9 step 5a), so + // every contender takes them in one order; the money then applies in the + // order the request named them, the order the desk pays them in. + lockedIDs := make([]uuid.UUID, len(in.InvoiceIDs)) + copy(lockedIDs, in.InvoiceIDs) + sort.Slice(lockedIDs, func(i, j int) bool { return lockedIDs[i].String() < lockedIDs[j].String() }) var pmt *APPayment - err = s.db.RunInTx(ctx, func(ctx context.Context) error { - pmt = &APPayment{ - VendorID: req.VendorID, - Amount: amountCents, - Method: req.Method, - CheckNumber: req.CheckNumber, - Reference: req.Reference, - PaymentDate: paymentDate, + err := s.inTx(ctx, func(ctx context.Context) error { + p := &APPayment{ + VendorID: in.VendorID, + Amount: in.AmountCents, + Method: in.Method, + CheckNumber: in.CheckNumber, + Reference: in.Reference, + PaymentDate: in.PaymentDate, Status: "COMPLETE", } - - if err := s.repo.CreatePayment(ctx, pmt); err != nil { + if err := s.repo.CreatePayment(ctx, p); err != nil { return err } + pmt = p - // Apply payment to invoices - remaining := amountCents - for _, invID := range req.InvoiceIDs { - if remaining <= 0 { - break - } - - inv, err := s.repo.GetVendorInvoice(ctx, invID) + locked := make(map[uuid.UUID]*Summary, len(lockedIDs)) + for _, id := range lockedIDs { + inv, err := s.repo.LockForPayment(ctx, id, in.VendorID) if err != nil { - return fmt.Errorf("invoice %s not found: %w", invID, err) + return err + } + if inv == nil { + return httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: fmt.Sprintf("invoice_ids[%d]", indexOfUUID(in.InvoiceIDs, id)), + Message: "no such invoice for this vendor"}) } + if inv.Status != StatusApproved && inv.Status != StatusPartial { + return httpx.InvalidStateTransition("only an approved or partial bill takes a payment", + httpx.Blocker("invoice_not_approved", inv.Number+" is "+inv.Status.Status())) + } + locked[id] = inv + } - outstanding := inv.Total - inv.AmountPaid + var events []outbox.Event + remaining := in.AmountCents + for _, id := range in.InvoiceIDs { + inv := locked[id] + open := int64(inv.AmountOpenCents) + if open <= 0 { + continue + } apply := remaining - if apply > outstanding { - apply = outstanding + if apply > open { + apply = open } - - app := &APPaymentApplication{ - PaymentID: pmt.ID, - InvoiceID: invID, - Amount: apply, + if apply <= 0 { + break } + app := &APPaymentApplication{PaymentID: p.ID, InvoiceID: id, Amount: apply} if err := s.repo.CreatePaymentApplication(ctx, app); err != nil { return err } - - // Update invoice paid amount - inv.AmountPaid += apply - if inv.AmountPaid >= inv.Total { - inv.Status = InvoiceStatusPaid - } else { - inv.Status = InvoiceStatusPartial - } - if err := s.repo.UpdateVendorInvoice(ctx, inv); err != nil { + paid, err := s.repo.ApplyToInvoice(ctx, id, apply) + if err != nil { return err } - remaining -= apply + after, err := s.reloaded(ctx, id) + if err != nil { + return err + } + from := inv.Status + typ := EventPartial + if paid { + typ = EventPaid + } + events = append(events, s.event(typ, from, after)) + if remaining <= 0 { + break + } } - - // Post to GL: DR Accounts Payable, CR Cash - if err := s.glSvc.SyncVendorPayment(ctx, pmt.ID, pmt.Amount); err != nil { - return fmt.Errorf("failed to sync vendor payment to GL: %w", err) + if remaining > 0 { + return httpx.InvalidStateTransition("the payment exceeds what the named bills still owe", + httpx.Blocker("exceeds_open", fmt.Sprintf("%d cents were asked and %d cents are open", in.AmountCents, in.AmountCents-remaining))) } - - s.logger.Info("Vendor payment created and synced to GL", - "id", pmt.ID, - "vendor_id", pmt.VendorID, - "amount_cents", amountCents, - "invoices_paid", len(req.InvoiceIDs), - ) - return nil + if len(locked) > 0 { + ref := p.ID + entry, err := s.gl.PostEntry(ctx, gl.PostingInput{ + EntryDate: mustDate(in.PaymentDate), Memo: "Vendor payment " + p.ID.String(), + Source: gl.SourceVendorPmt, SourceRefID: &ref, PostedBy: who.Actor, + Legs: []gl.Leg{ + {AccountCode: gl.AccountCodeAP, Description: "Accounts Payable", Debit: in.AmountCents - remaining}, + {AccountCode: gl.AccountCodeCash, Description: "Cash", Credit: in.AmountCents - remaining}, + }, + }) + if err != nil { + if errors.Is(err, gl.ErrPeriodClosed) { + return conflictBlocker("period_closed", "the entry would be dated into a closed fiscal period") + } + return fmt.Errorf("failed to post the vendor payment entry: %w", err) + } + _ = entry + } + if err := s.audit(ctx, audit.Entry{Action: "vendor_payment.recorded", EntityType: "ap_payment", + EntityID: p.ID, UserID: who.Actor, Changes: map[string]interface{}{ + "vendor_id": in.VendorID, "amount_cents": in.AmountCents, "method": string(in.Method), + "invoices": len(locked)}}); err != nil { + return err + } + return s.recordAll(ctx, events) }) - if err != nil { return nil, err } + s.logger.Info("vendor payment recorded", "id", pmt.ID, "vendor_id", pmt.VendorID, "amount_cents", pmt.Amount) return pmt, nil } -// GetVendorInvoice returns a vendor invoice with lines. -func (s *Service) GetVendorInvoice(ctx context.Context, id uuid.UUID) (*VendorInvoice, error) { - inv, err := s.repo.GetVendorInvoice(ctx, id) - if err != nil { - return nil, err +func indexOfUUID(haystack []uuid.UUID, needle uuid.UUID) int { + for i, v := range haystack { + if v == needle { + return i + } } - inv.Lines, _ = s.repo.GetInvoiceLines(ctx, id) - return inv, nil + return 0 } -// ListVendorInvoices returns vendor invoices filtered by vendor and status. -func (s *Service) ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]VendorInvoice, error) { - return s.repo.ListVendorInvoices(ctx, vendorID, status) +// mustDate parses a YYYY-MM-DD wire date into the posting's business date; +// an unparsable value answers today rather than failing the posting. +func mustDate(s string) time.Time { + if t, err := time.Parse("2006-01-02", s); err == nil { + return t + } + return time.Now() } -// ListPayments returns AP payments for a vendor. +// ListPayments lists a vendor's payments. func (s *Service) ListPayments(ctx context.Context, vendorID *uuid.UUID) ([]APPayment, error) { return s.repo.ListPayments(ctx, vendorID) } diff --git a/core/internal/ap/tx_test.go b/core/internal/ap/tx_test.go new file mode 100644 index 000000000..cf54ce872 --- /dev/null +++ b/core/internal/ap/tx_test.go @@ -0,0 +1,440 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap_test + +// The transaction proofs of the AP module (the module recipe's set and the +// lane rule on transactions): a mutation, its journal entry, its audit row +// and its events are one fact, and every transaction runs on its own +// connection, never reaching for a second one from the pool. The concurrency +// tests run the whole service at pool size 4, the two payments of one +// invoice and the payment racing a void among them (ADR 0008 7.4). + +import ( + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gablelbm/gable/internal/ap" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +type failingEvents struct{} + +func (failingEvents) Write(context.Context, outbox.Event) error { + return errors.New("outbox insert failed") +} + +type failingAudit struct{} + +func (failingAudit) Log(context.Context, audit.Entry) error { + return errors.New("audit insert failed") +} + +// createInput is the parsed bill the tx tests enter. +func (f *fixture) createInput(number string) *ap.Input { + req := &ap.CreateRequest{ + VendorID: ptr(f.vendor.String()), + VendorInvoiceNumber: ptr(number), + InvoiceDate: ptr("2026-01-10"), + DueDate: ptr("2026-02-10"), + Lines: []ap.CreateLineRequest{ + {Description: ptr("2x4x8 SPF"), Quantity: raw(`"1"`), UnitPriceTenThousand: raw(`1000`), GLAccountID: ptr(f.expense.String())}, + }, + } + in, err := req.Parse() + if err != nil { + f.t.Fatal(err) + } + return in +} + +func ptr[T any](v T) *T { return &v } + +func raw(s string) []byte { return []byte(s) } + +func payInput(f *fixture, cents int64, ids ...uuid.UUID) *ap.PayInput { + amount := float64(cents) / 100.0 + req := &ap.PayRequest{ + VendorID: ptr(f.vendor.String()), Amount: &amount, Method: ptr("CHECK"), + PaymentDate: ptr("2026-01-15"), InvoiceIDs: nil, + } + for _, id := range ids { + req.InvoiceIDs = append(req.InvoiceIDs, id.String()) + } + in, err := req.Parse() + if err != nil { + panic(err) + } + return in +} + +func revision(n int64) ap.Precondition { return ap.Precondition{Revision: &n} } + +// RULE (ADR 0003 section 2): a mutation whose event cannot be recorded does +// not happen, for each kind of write. +func TestTx_FailedEventWriteRollsBackEachWrite(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + fail := f.service(failingEvents{}, audit.NewLogger(f.db), f.db) + good := f.svc + // An in-process caller marks itself a system caller: no branch context to + // hold a payload branch against (ADR 0007 section 2.3). + ctx := branchctx.WithSystem(context.Background()) + + if _, err := fail.Create(ctx, f.createInput("TX-EV-1"), ap.Caller{}); err == nil { + t.Error("create succeeded though its event could not be written") + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d bills survived a rolled back create", n) + } + + created, err := good.Create(ctx, f.createInput("TX-EV-2"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := fail.Transition(ctx, created.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err == nil { + t.Error("approve succeeded though its event could not be written") + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'PENDING' AND revision = 1`, created.ID); n != 1 { + t.Error("an approve survived a rolled back event") + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, created.ID); n != 0 { + t.Errorf("%d entries survived a rolled back approve", n) + } + + approved, err := good.Transition(ctx, created.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := fail.Transition(ctx, approved.ID, &ap.TransitionInput{To: ap.StatusVoided, Reason: "wrong"}, revision(2), ap.Caller{}); err == nil { + t.Error("void succeeded though its event could not be written") + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'APPROVED' AND revision = 2`, approved.ID); n != 1 { + t.Error("a void survived a rolled back event") + } + if n := f.count(`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`, approved.ID); n != 0 { + t.Errorf("%d reversals survived a rolled back void", n) + } + + if _, err := fail.PayVendor(ctx, payInput(f, 1000, approved.ID), ap.Caller{}); err == nil { + t.Error("payment succeeded though its event could not be written") + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d payments survived a rolled back payment", n) + } + if n := f.count(`SELECT count(*) FROM ap_payment_applications a JOIN vendor_invoices i ON i.id = a.invoice_id WHERE i.vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d applications survived a rolled back payment", n) + } + if got := f.count(`SELECT ROUND(amount_open * 100)::bigint FROM vendor_invoices WHERE id = $1`, approved.ID); got != 10 { + t.Errorf("amount_open = %d after a rolled back payment, want 10", got) + } +} + +// The same rule for the audit row, for each kind of write. +func TestTx_FailedAuditWriteRollsBackEachWrite(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + fail := f.service(outbox.NewWriter(f.db, ""), failingAudit{}, f.db) + good := f.svc + ctx := branchctx.WithSystem(context.Background()) + + if _, err := fail.Create(ctx, f.createInput("TX-AU-1"), ap.Caller{}); err == nil { + t.Error("create succeeded though its audit row could not be written") + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d bills survived a rolled back create", n) + } + + created, err := good.Create(ctx, f.createInput("TX-AU-2"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := fail.Transition(ctx, created.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err == nil { + t.Error("approve succeeded though its audit row could not be written") + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, created.ID); n != 0 { + t.Errorf("%d entries survived a rolled back approve", n) + } + + approved, err := good.Transition(ctx, created.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := fail.PayVendor(ctx, payInput(f, 1000, approved.ID), ap.Caller{}); err == nil { + t.Error("payment succeeded though its audit row could not be written") + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d payments survived a rolled back payment", n) + } +} + +// gatedTx opens as many transactions as the pool has connections and holds +// each INSIDE its transaction at a gate before fn runs. The pool is then +// empty, so any statement that goes to the pool instead of the transaction +// blocks forever. +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. This is the test that +// fails when code inside a transaction uses the pool. +func TestTx_Pool4SaturationNeedsNoSecondConnection(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDBMaxConns(t, 4) + f := newFixture(t, db) + events := outbox.NewWriter(f.db, "") + good := f.service(events, audit.NewLogger(f.db), db) + ctx, cancel := context.WithTimeout(branchctx.WithSystem(context.Background()), 40*time.Second) + defer cancel() + + const contenders = 4 + var seeds []uuid.UUID + for i := 0; i < contenders; i++ { + inv, err := good.Create(ctx, f.createInput("TX-GATE-"+string(rune('A'+i))), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := good.Transition(ctx, inv.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err != nil { + t.Fatal(err) + } + seeds = append(seeds, inv.ID) + } + + phase := func(name string, run func(svc *ap.Service, i int) error) { + t.Helper() + svc := f.service(events, audit.NewLogger(f.db), newGatedTx(db, contenders)) + var wg sync.WaitGroup + errs := make(chan error, contenders) + for i := 0; i < contenders; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := run(svc, i); err != nil { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Errorf("%s: %v", name, err) + } + } + + phase("create", func(svc *ap.Service, i int) error { + _, err := svc.Create(ctx, f.createInput("TX-GATED-"+string(rune('a'+i))), ap.Caller{}) + return err + }) + phase("approve", func(svc *ap.Service, i int) error { + inv, err := svc.Create(ctx, f.createInput("TX-GATED-AP-"+string(rune('a'+i))), ap.Caller{}) + if err != nil { + return err + } + // The create ran inside its own gated transaction; the approve is the + // write under test. + _, err = svc.Transition(ctx, inv.ID, &ap.TransitionInput{To: ap.StatusApproved}, ap.Precondition{Any: true}, ap.Caller{}) + return err + }) + phase("void", func(svc *ap.Service, i int) error { + inv, err := svc.Create(ctx, f.createInput("TX-GATED-VO-"+string(rune('a'+i))), ap.Caller{}) + if err != nil { + return err + } + _, err = svc.Transition(ctx, inv.ID, &ap.TransitionInput{To: ap.StatusVoided, Reason: "gated"}, ap.Precondition{Any: true}, ap.Caller{}) + return err + }) + phase("pay", func(svc *ap.Service, i int) error { + _, err := svc.PayVendor(ctx, payInput(f, 5, seeds[i]), ap.Caller{}) // each seed owes 10 cents + return err + }) +} + +// Three contenders at pool size 4 (the lane rule on transactions): concurrent +// creates get distinct numbers and one event each, three racers on one +// revision have exactly one winner, two payments of one invoice apply +// exactly the money once, and a payment racing a void leaves exactly one +// winner. The GL ties after every race. +func TestTx_Concurrency_Pool4ThreeContenders(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDBMaxConns(t, 4) + f := newFixture(t, db) + svc := f.svc + ctx, cancel := context.WithTimeout(branchctx.WithSystem(context.Background()), 60*time.Second) + defer cancel() + + // Concurrent creates: distinct numbers, one event each. + const contenders, each = 3, 4 + var wg sync.WaitGroup + errs := make(chan error, contenders*each) + for i := 0; i < contenders; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < each; j++ { + if _, err := svc.Create(ctx, f.createInput("TX-CC-"+string(rune('A'+i))+string(rune('0'+j))), ap.Caller{}); err != nil { + errs <- err + return + } + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("concurrent create: %v", err) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != contenders*each { + t.Fatalf("%d bills created, want %d", n, contenders*each) + } + if n := f.count(`SELECT count(*) FROM events_outbox WHERE entity_type = 'vendor_invoice' AND entity_id IN (SELECT id FROM vendor_invoices WHERE vendor_id = $1)`, f.vendor); n != contenders*each { + t.Errorf("%d events for %d bills", n, contenders*each) + } + if n := f.count(`SELECT count(DISTINCT number) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != contenders*each { + t.Errorf("%d distinct numbers for %d bills", n, contenders*each) + } + + // Three racers on one revision: exactly one winner. + made, err := svc.Create(ctx, f.createInput("TX-RACE"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + var won, stale, failed atomic.Int32 + var raceWG sync.WaitGroup + for i := 0; i < contenders; i++ { + raceWG.Add(1) + go func() { + defer raceWG.Done() + _, err := svc.Transition(ctx, made.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}) + switch { + case err == nil: + won.Add(1) + default: + if e, ok := err.(*httpx.Error); ok && e.Status == http.StatusConflict { + stale.Add(1) + } else { + t.Errorf("racer: %v", err) + failed.Add(1) + } + } + }() + } + raceWG.Wait() + if won.Load() != 1 || stale.Load() != contenders-1 || failed.Load() != 0 { + t.Errorf("%d winners, %d stale, %d failed; want 1, %d, 0", won.Load(), stale.Load(), failed.Load(), contenders-1) + } + + // Two payments of one invoice: the locks serialize them, the open amount + // is applied exactly once, and the control account still ties. + payable, err := svc.Create(ctx, f.createInput("TX-PAY2"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Transition(ctx, payable.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err != nil { + t.Fatal(err) + } + var payErrs = make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // each pays 5 of the bill's 10 cents: both succeed under the + // locks, and the 10 cents are applied exactly once between them. + if _, err := svc.PayVendor(ctx, payInput(f, 5, payable.ID), ap.Caller{}); err != nil { + payErrs <- err + } + }() + } + wg.Wait() + close(payErrs) + for err := range payErrs { + t.Fatalf("concurrent payment: %v", err) + } + if applied := f.count(`SELECT ROUND(COALESCE(SUM(amount), 0) * 100)::bigint FROM ap_payment_applications WHERE invoice_id = $1`, payable.ID); applied != 10 { + t.Errorf("%d cents applied to a 10 cent bill, want 10 exactly once", applied) + } + if open := f.count(`SELECT ROUND(amount_open * 100)::bigint FROM vendor_invoices WHERE id = $1`, payable.ID); open != 0 { + t.Errorf("amount_open = %d after the payments, want 0", open) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Errorf("after the payments: control %d, open %d", got, want) + } + if !f.balanced() { + t.Error("a payment entry does not balance") + } + + // A payment racing a void on one bill: the row lock serializes them, and + // exactly one wins. + raced, err := svc.Create(ctx, f.createInput("TX-PAYVOID"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Transition(ctx, raced.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err != nil { + t.Fatal(err) + } + var succeeded atomic.Int32 + var raceErrs = make(chan error, 2) + wg.Add(1) + go func() { + defer wg.Done() + if _, err := svc.PayVendor(ctx, payInput(f, 1000, raced.ID), ap.Caller{}); err != nil { + if e, ok := err.(*httpx.Error); !ok || e.Status != http.StatusConflict { + raceErrs <- err + } + } else { + succeeded.Add(1) + } + }() + wg.Add(1) + go func() { + defer wg.Done() + if _, err := svc.Transition(ctx, raced.ID, &ap.TransitionInput{To: ap.StatusVoided, Reason: "racing"}, revision(2), ap.Caller{}); err != nil { + if e, ok := err.(*httpx.Error); !ok || e.Status != http.StatusConflict { + raceErrs <- err + } + } else { + succeeded.Add(1) + } + }() + wg.Wait() + close(raceErrs) + for err := range raceErrs { + t.Fatalf("payment racing void: %v", err) + } + if succeeded.Load() != 1 { + t.Errorf("%d of the payment and the void won, want exactly 1", succeeded.Load()) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Errorf("after the race: control %d, open %d", got, want) + } +} diff --git a/core/internal/ap/wire_test.go b/core/internal/ap/wire_test.go new file mode 100644 index 000000000..97ced9f61 --- /dev/null +++ b/core/internal/ap/wire_test.go @@ -0,0 +1,674 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap_test + +// The vendor invoice wire tests (the module recipe's set) and the three live +// failures of ADR 0008 7.4 in their converted form: the approve entry carries +// a debit leg per line, a posting failure fails the act with its cause, and a +// payment applies only to an approved or partial bill. The GL ties after +// every act: each entry balances, and the AP control account (2010) equals +// what the open bills owe. + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +var numberPattern = regexp.MustCompile(`^AP-\d{6,}$`) + +// RULE (ADR 0008 7.4, ADR 0001 sections 7 and 8): a bill read carries +// Gable's own number, the vendor's as vendor_invoice_number, a lowercase +// status, money in cents, the scaled unit price, the revision with its ETag, +// and every line in the shared shape; the optional fields are present as +// null, and no legacy field survives. +func TestWire_InvoiceShape(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + r := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": "V-2026-001", + "invoice_date": "2026-01-10", "due_date": "2026-02-10", "tax_cents": 488, + "notes": "fixture bill", "lines": []map[string]any{ + {"description": "2x4x8 SPF", "quantity": "2", "unit_price_ten_thousandths": 550000, "gl_account_id": f.expense.String()}, + {"description": "delivery", "quantity": "1", "unit_price_ten_thousandths": 10000, "gl_account_id": f.expense.String()}, + }, + }) + if r.status != http.StatusCreated { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + b := r.body + if !numberPattern.MatchString(str(t, b, "number")) { + t.Errorf("number = %q, want AP-nnnnnn", b["number"]) + } + for key, want := range map[string]string{"vendor_invoice_number": "V-2026-001", "status": "pending", "currency": "USD", + "invoice_date": "2026-01-10", "due_date": "2026-02-10", "notes": "fixture bill", "vendor_name": "C41B Vendor " + f.vendor.String()[:8]} { + 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": 11100, "tax_cents": 488, "total_cents": 11588, + "amount_paid_cents": 0, "amount_open_cents": 11588, "revision": 1} { + if got := num(t, b, key); got != want { + t.Errorf("%s = %d, want %d", key, got, want) + } + } + if etag := r.header.Get("ETag"); etag != `"1"` { + t.Errorf("ETag = %q, want \"1\"", etag) + } + if loc := r.header.Get("Location"); loc != "/api/v1/ap/invoices/"+str(t, b, "id") { + t.Errorf("Location = %q", loc) + } + if b["branch_id"] != f.branch.String() { + t.Errorf("branch_id = %v, want the default branch (no branch in the body, no context branch)", b["branch_id"]) + } + for _, key := range []string{"po_id", "approved_by", "approved_at", "gl_entry_id"} { + 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{"invoice_number", "subtotal", "tax_amount", "total", "amount_paid", "unit_price", "line_total"} { + if _, ok := b[key]; ok { + t.Errorf("legacy field %q is still on the wire", key) + } + } + lines := b["lines"].([]any) + if len(lines) != 2 { + t.Fatalf("%d lines, want 2: %s", len(lines), r.raw) + } + l := lines[0].(map[string]any) + for key, want := range map[string]string{"quantity": "2", "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": 550000, "line_total_cents": 11000, "position": 0} { + if got := num(t, l, key); got != want { + t.Errorf("line %s = %d, want %d", key, got, want) + } + } + if l["gl_account_id"] != f.expense.String() { + t.Errorf("line gl_account_id = %v", l["gl_account_id"]) + } + for _, key := range []string{"purchase_order_line_id", "product_id", "po_freight_charge_id"} { + if v, ok := l[key]; !ok || v != nil { + t.Errorf("line %s = %v (present %v), want present as null", key, v, ok) + } + } + if ev := eventTypes(t, f.db, str(t, b, "id")); fmt.Sprint(ev) != "[vendor_invoice.created]" { + t.Errorf("events = %v, want [vendor_invoice.created]", ev) + } + if n := f.count(`SELECT count(*) FROM audit_log WHERE entity_type = 'vendor_invoice' AND entity_id = $1 AND action = 'vendor_invoice.created'`, b["id"]); n != 1 { + t.Errorf("%d create audit rows, want 1", n) + } + // The number is Gable's own and unique: a second bill may carry the same + // vendor number? No: the vendor's number is unique per vendor. + if bad := f.do("POST", "/api/v1/ap/invoices", f.createBody("V-2026-001", "1", 1000)); bad.status != http.StatusConflict { + t.Errorf("duplicate vendor number = %d, want 409", bad.status) + } +} + +// RULE (ADR 0001 section 4): one 400 with every offending field in details, +// the full path included; a body the route cannot consume is bad_request; an +// unknown field is refused; a create has no status. +func TestWire_CreateValidation(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + r := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), + "lines": []map[string]any{ + {"description": "", "quantity": "0", "unit_price_ten_thousandths": -5}, + {"description": "ok", "quantity": "1", "unit_price_ten_thousandths": 100}, + }, + }) + if r.status != http.StatusBadRequest { + t.Fatalf("validation = %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{"vendor_invoice_number", "invoice_date", "due_date", + "lines[0].description", "lines[0].quantity", "lines[0].unit_price_ten_thousandths", "lines[0].gl_account_id", + "lines[1].gl_account_id"} + if fmt.Sprint(fields) != fmt.Sprint(want) { + t.Errorf("fields = %v, want %v", fields, want) + } + bad := f.do("POST", "/api/v1/ap/invoices", "not-an-object") + if bad.status != http.StatusBadRequest { + t.Errorf("bad body = %d, want 400", bad.status) + } + if code, _, _ := errorOf(t, bad); code != "bad_request" { + t.Errorf("bad body code = %q, want bad_request", code) + } + if unknown := f.do("POST", "/api/v1/ap/invoices", map[string]any{"vendor_id": f.vendor.String(), "nope": 1}); unknown.status != http.StatusBadRequest { + t.Errorf("unknown field = %d, want 400", unknown.status) + } + nv := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": uuid.NewString(), "vendor_invoice_number": "V-X", "invoice_date": "2026-01-10", "due_date": "2026-02-10", + "lines": []map[string]any{{"description": "x", "quantity": "1", "unit_price_ten_thousandths": 1, "gl_account_id": f.expense.String()}}}) + if nv.status != http.StatusBadRequest { + t.Errorf("unknown vendor = %d, want 400 naming vendor_id", nv.status) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d bills survived a refused create", n) + } +} + +// RULE (ADR 0001 sections 1, 2 and 5): the list is the cursor envelope, +// newest first, every row once, with filters that filter and unknown or +// uppercase values refused; an empty page is [] in the bytes. +func TestWire_List(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + var ids []string + for i := 0; i < 3; i++ { + id, _ := f.enter(fmt.Sprintf("V-L%d", i), "1", 1000) + ids = append(ids, id) + } + var seen []string + cursor := "" + for page := 0; page < 5; page++ { + path := "/api/v1/ap/invoices?limit=2&vendor_id=" + f.vendor.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 num(t, r.body, "limit") != 2 { + t.Errorf("limit echo = %v, want 2", r.body["limit"]) + } + 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/ap/invoices?vendor_id="+f.vendor.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/ap/invoices?vendor_id="+f.vendor.String()+"&status=pending", nil); r.status != 200 || len(r.body["items"].([]any)) != 3 { + t.Errorf("status=pending = %d, want the 3 pending", r.status) + } + if r := f.do("GET", "/api/v1/ap/invoices?vendor_id="+f.vendor.String()+"&status=approved,partial", nil); r.status != 200 || len(r.body["items"].([]any)) != 0 { + t.Errorf("status=approved,partial = %d %s, want none", r.status, r.raw) + } + if r := f.do("GET", "/api/v1/ap/invoices?vendor_id="+f.vendor.String()+"&po_id="+uuid.NewString(), nil); r.status != 200 || len(r.body["items"].([]any)) != 0 { + t.Errorf("a po_id filter that matches nothing: %d %s", r.status, r.raw) + } + empty := f.do("GET", "/api/v1/ap/invoices?vendor_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=PENDING", "status=unknown", "vendor_id=zzz", "limit=0", "limit=201", "cursor=junk", "nope=1"} { + if r := f.do("GET", "/api/v1/ap/invoices?"+bad, nil); r.status != 400 { + t.Errorf("?%s = %d, want 400", bad, r.status) + } + } +} + +// RULE (ADR 0001 section 3): not found and a malformed id are the wire's +// envelope. +func TestWire_GetRefusals(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + r := f.do("GET", "/api/v1/ap/invoices/"+uuid.NewString(), nil) + if r.status != 404 { + t.Fatalf("unknown bill = %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/ap/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 (ADR 0001 section 11): a write needs the revision, through If-Match or +// the body; a mismatch is 409 stale_revision; the new revision and its ETag +// come back on success. +func TestWire_Revision(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, _ := f.enter("V-R", "1", 1000) + body := func() map[string]any { + return map[string]any{"to": "approved"} + } + if r := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", body()); r.status != http.StatusPreconditionRequired { + t.Errorf("no precondition = %d, want 428", r.status) + } + r := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", map[string]any{"to": "approved", "revision": 5}) + if r.status != http.StatusConflict { + t.Errorf("stale revision = %d, want 409", r.status) + } + if code, _, _ := errorOf(t, r); code != "stale_revision" { + t.Errorf("stale code = %q", code) + } + if r := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", map[string]any{"to": "approved", "revision": 1}, "If-Match", `W/"1"`); r.status != http.StatusOK { + t.Errorf("weak If-Match = %d, want 200", r.status) + } + if r := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", map[string]any{"to": "voided", "revision": 2, "reason": "wrong bill"}, "If-Match", `"3"`); r.status != http.StatusBadRequest { + t.Errorf("header and body disagree = %d, want 400", r.status) + } +} + +// RULE (ADR 0008 7.4 and section 10): the lifecycle. Approve posts the entry; +// void reverses it; the events land in order; the edges the lifecycle refuses +// answer 409 invalid_state_transition. +func TestWire_Lifecycle(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-LC", "2", 500000) + + r := f.approve(id, r1) + if r.status != http.StatusOK || str(t, r.body, "status") != "approved" { + t.Fatalf("approve = %d %v: %s", r.status, r.body["status"], r.raw) + } + if rev(t, r) != 2 || r.header.Get("ETag") != `"2"` { + t.Errorf("approve revision = %d %q, want 2 \"2\"", rev(t, r), r.header.Get("ETag")) + } + if str(t, r.body, "approved_by") == "" || r.body["approved_at"] == nil { + t.Errorf("approved_by %v approved_at %v, want set", r.body["approved_by"], r.body["approved_at"]) + } + // A second approve is refused. + again := f.approve(id, 2) + if again.status != http.StatusConflict { + t.Errorf("second approve = %d, want 409", again.status) + } + if code, blockers, _ := errorOf(t, again); code != "invalid_state_transition" || len(blockers) == 0 { + t.Errorf("second approve = %q %v", code, blockers) + } + // A bill with money applied cannot be voided. + if p := f.pay(10000, id); p.status != http.StatusCreated { + t.Fatalf("pay = %d: %s", p.status, p.raw) + } + v := f.void(id, 3, "wrong bill") + if v.status != http.StatusConflict { + t.Fatalf("void with payments = %d, want 409", v.status) + } + if code, blockers, _ := errorOf(t, v); code != "invalid_state_transition" || blockers[0] != "has_payments" { + t.Errorf("void with payments = %q %v, want has_payments", code, blockers) + } + // A pending bill voids with no entry to reverse. + id2, r2 := f.enter("V-LC2", "1", 1000) + vp := f.void(id2, r2, "entered twice") + if vp.status != http.StatusOK || str(t, vp.body, "status") != "voided" { + t.Fatalf("void pending = %d: %s", vp.status, vp.raw) + } + if num(t, vp.body, "amount_open_cents") != 0 { + t.Errorf("a voided bill owes %d, want 0", num(t, vp.body, "amount_open_cents")) + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, id2); n != 0 { + t.Errorf("a pending bill voided with %d entries, want none", n) + } + // An approved bill voids by reversing its entry. + id3, r3 := f.enter("V-LC3", "1", 1000) + if a := f.approve(id3, r3); a.status != http.StatusOK { + t.Fatalf("approve = %s", a.raw) + } + va := f.void(id3, 2, "wrong vendor") + if va.status != http.StatusOK || str(t, va.body, "status") != "voided" { + t.Fatalf("void approved = %d: %s", va.status, va.raw) + } + if n := f.count(`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`, id3); n != 1 { + t.Errorf("the void wrote %d reversals, want 1", n) + } + if ev := eventTypes(t, f.db, id3); fmt.Sprint(ev) != "[vendor_invoice.created vendor_invoice.approved vendor_invoice.voided]" { + t.Errorf("events = %v", ev) + } + // The targets a bill never reaches by a transition. + for _, to := range []string{"pending", "partial", "paid"} { + if tr := f.do("POST", "/api/v1/ap/invoices/"+id2+"/transitions", map[string]any{"to": to, "revision": 2, "reason": "x"}); tr.status != http.StatusConflict { + t.Errorf("transition to %s = %d, want 409", to, tr.status) + } + } + if nr := f.do("POST", "/api/v1/ap/invoices/"+id2+"/transitions", map[string]any{"to": "voided", "revision": 2}); nr.status != http.StatusBadRequest { + t.Errorf("void without a reason = %d, want 400", nr.status) + } +} + +// LIVE FAILURE (ADR 0008 7.4, section 14), converted form: approve loads the +// bill's lines, so the entry carries a debit leg per line beside the Accounts +// Payable credit, and it balances. +func TestWire_ApproveEntryCarriesLineDebits(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-GL1", "2", 500000) // 2 at 50.00 = 100.00 + if a := f.approve(id, r1); a.status != http.StatusOK { + t.Fatalf("approve = %s", a.raw) + } + entries, legs := f.entryLegs(id) + if entries != 1 { + t.Fatalf("%d entries, want 1", entries) + } + if len(legs) != 2 { + t.Fatalf("the entry's accounts = %v, want 5020 (the line) and 2010 (the payable)", legs) + } + if legs["5020"].debit != 10000 || legs["5020"].credit != 0 { + t.Errorf("5020 = %+v, want debit 10000", legs["5020"]) + } + if legs["2010"].debit != 0 || legs["2010"].credit != 10000 { + t.Errorf("2010 = %+v, want credit 10000", legs["2010"]) + } + if !f.balanced() { + t.Error("the approve entry does not balance") + } +} + +// A bill with tax approves balanced: the tax spreads across the line accounts +// pro rata to their extensions, the last line taking the remainder. +func TestWire_ApproveSpreadsTax(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + r := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": "V-TAX", + "invoice_date": "2026-01-10", "due_date": "2026-02-10", "tax_cents": 101, + "lines": []map[string]any{ + {"description": "two thirds", "quantity": "2", "unit_price_ten_thousandths": 500000, "gl_account_id": f.expense.String()}, + {"description": "one third", "quantity": "1", "unit_price_ten_thousandths": 500000, "gl_account_id": f.expense.String()}, + }, + }) + if r.status != http.StatusCreated { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + id := str(t, r.body, "id") + if num(t, r.body, "subtotal_cents") != 15000 || num(t, r.body, "total_cents") != 15101 { + t.Fatalf("subtotal %v total %v", r.body["subtotal_cents"], r.body["total_cents"]) + } + if a := f.approve(id, 1); a.status != http.StatusOK { + t.Fatalf("approve = %s", a.raw) + } + _, legs := f.entryLegs(id) + if legs["5020"].debit != 15101 { + t.Errorf("5020 debit = %d, want 15101 (15000 lines plus the 101 tax)", legs["5020"].debit) + } + if legs["2010"].credit != 15101 { + t.Errorf("2010 credit = %d, want 15101", legs["2010"].credit) + } + if !f.balanced() { + t.Error("the taxed approve entry does not balance") + } +} + +// A line with no account cannot be approved (a legacy bill): the approve +// refuses naming the line, and nothing posts. +func TestWire_ApproveRefusesLineWithoutAccount(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, _ := f.enter("V-NOACC", "1", 1000) + mustExec(t, f.db, `UPDATE vendor_invoice_lines SET gl_account_id = NULL WHERE invoice_id = $1`, id) + r := f.approve(id, 1) + if r.status != http.StatusConflict { + t.Fatalf("approve = %d, want 409", r.status) + } + if code, blockers, _ := errorOf(t, r); code != "conflict" || blockers[0] != "line_needs_account" { + t.Errorf("approve = %q %v, want line_needs_account", code, blockers) + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, id); n != 0 { + t.Errorf("%d entries posted for a refused approve", n) + } + if s := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'PENDING'`, id); s != 1 { + t.Error("the bill did not stay pending") + } +} + +// LIVE FAILURE (ADR 0008 7.4, section 14), converted form: a posting failure +// fails the act with its cause. The period holding the bill's date is closed, +// so the journal insert refuses and the approve answers 409 period_closed, +// leaving the bill pending and the ledger untouched. +func TestWire_ApprovePostingFailureFailsTheAct(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-CLOSED", "1", 1000) + mustExec(t, f.db, `UPDATE gl_fiscal_periods SET status = 'CLOSED' + WHERE start_date <= '2026-01-10' AND end_date >= '2026-01-10'`) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE gl_fiscal_periods SET status = 'OPEN' + WHERE start_date <= '2026-01-10' AND end_date >= '2026-01-10'`) + }) + closed := f.approve(id, r1) + if closed.status != http.StatusConflict { + t.Fatalf("approve into a closed period = %d, want 409: %s", closed.status, closed.raw) + } + if code, blockers, _ := errorOf(t, closed); code != "conflict" || blockers[0] != "period_closed" { + t.Errorf("approve = %q %v, want period_closed", code, blockers) + } + if s := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'PENDING' AND revision = 1`, id); s != 1 { + t.Error("the bill did not stay pending at revision 1") + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, id); n != 0 { + t.Errorf("%d entries posted into a closed period", n) + } +} + +// LIVE FAILURE (ADR 0008 7.4, section 14), converted form: a payment applies +// only to an approved or partial bill. A pending bill is refused with the +// invoice_not_approved blocker and nothing is written. +func TestWire_PaymentRefusesUnapprovedInvoice(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, _ := f.enter("V-PENDING", "1", 1000) + pr := f.pay(1000, id) + if pr.status != http.StatusConflict { + t.Fatalf("pay pending = %d, want 409: %s", pr.status, pr.raw) + } + if code, blockers, _ := errorOf(t, pr); code != "invalid_state_transition" || blockers[0] != "invoice_not_approved" { + t.Errorf("pay pending = %q %v, want invoice_not_approved", code, blockers) + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d payments survived the refusal", n) + } + if n := f.count(`SELECT count(*) FROM ap_payment_applications a JOIN vendor_invoices i ON i.id = a.invoice_id WHERE i.vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d applications were written against the pending bill", n) + } +} + +// RULE (ADR 0008 7.4): a payment applies to approved and partial bills, in id +// order, and the GL ties after every act: each entry balances and the AP +// control account equals what the open bills owe. +func TestWire_PaymentAppliesAndTies(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + a, ra := f.enter("V-PAY-A", "1", 500000) // 50.00 + b, rb := f.enter("V-PAY-B", "1", 300000) // 30.00 + if aa := f.approve(a, ra); aa.status != http.StatusOK { + t.Fatal(aa.raw) + } + if ab := f.approve(b, rb); ab.status != http.StatusOK { + t.Fatal(ab.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the approves: control %d, open %d", got, want) + } + // A partial payment of the first bill. + if pp := f.pay(2000, a); pp.status != http.StatusCreated { + t.Fatalf("partial pay = %d: %s", pp.status, pp.raw) + } + if g := f.do("GET", "/api/v1/ap/invoices/"+a, nil); g.status != 200 || str(t, g.body, "status") != "partial" || + num(t, g.body, "amount_paid_cents") != 2000 || num(t, g.body, "amount_open_cents") != 3000 { + t.Fatalf("after the partial: %s", g.raw) + } + if ev := eventTypes(t, f.db, a); fmt.Sprint(ev) != "[vendor_invoice.created vendor_invoice.approved vendor_invoice.partial]" { + t.Errorf("events = %v", ev) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the partial payment: control %d, open %d", got, want) + } + // A payment naming both bills, more than the first still owes: the rest + // lands on the second (3000 closes A, 2000 of B's 3000). + if pb := f.pay(5000, a, b); pb.status != http.StatusCreated { + t.Fatalf("pay both = %d: %s", pb.status, pb.raw) + } + if g := f.do("GET", "/api/v1/ap/invoices/"+a, nil); str(t, g.body, "status") != "paid" { + t.Errorf("bill A = %s, want paid", g.raw) + } + if g := f.do("GET", "/api/v1/ap/invoices/"+b, nil); str(t, g.body, "status") != "partial" || num(t, g.body, "amount_open_cents") != 1000 { + t.Errorf("bill B = %s, want partial owing 1000", g.raw) + } + if ev := eventTypes(t, f.db, a); fmt.Sprint(ev) != "[vendor_invoice.created vendor_invoice.approved vendor_invoice.partial vendor_invoice.paid]" { + t.Errorf("events = %v", ev) + } + if !f.balanced() { + t.Error("a payment entry does not balance") + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after both payments: control %d, open %d", got, want) + } + // More than the bills owe is refused, and nothing is written. + before := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor) + over := f.pay(100000, b) // b is partial, owing 1000 + if over.status != http.StatusConflict { + t.Fatalf("overpay = %d, want 409: %s", over.status, over.raw) + } + if _, blockers, _ := errorOf(t, over); blockers[0] != "exceeds_open" { + t.Errorf("overpay = %v, want exceeds_open", blockers) + } + if after := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); after != before { + t.Errorf("%d payments were written for a refused overpay", after-before) + } + // A bill of another vendor is not this payment's to pay. + if ui := f.pay(700, uuid.NewString()); ui.status != http.StatusBadRequest { + t.Errorf("unknown invoice = %d, want 400 naming invoice_ids[0]", ui.status) + } + // An old-wire body is a validation failure, not a 500. + if eb := f.do("POST", "/api/v1/ap/payments", map[string]any{"vendor_id": f.vendor.String()}); eb.status != http.StatusBadRequest { + t.Errorf("an empty pay body = %d, want 400", eb.status) + } + // The payment list stays a bare array until its own conversion. + if pl := f.do("GET", "/api/v1/ap/payments?vendor_id="+f.vendor.String(), nil); pl.status != 200 || !strings.HasPrefix(string(pl.raw), "[") || strings.Count(string(pl.raw), `"id"`) != 2 { + t.Errorf("list payments = %d %s, want a bare array of the 2 payments", pl.status, pl.raw) + } +} + +// RULE (ADR 0008 section 11): the branch wall on every read and write. A +// caller held to one branch finds another branch's bill a 404 on the by-id +// routes and never in the list; a create naming a branch it may not target is +// a 403 naming branch_id. +func TestWire_BranchWall(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + other := uuid.New() + mustExec(t, f.db, `INSERT INTO locations (id, type, code) VALUES ($1, 'BRANCH', $2)`, other, "C41B-"+other.String()[:8]) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE vendor_invoices SET branch_id = `+defaultBranch+` WHERE branch_id = $1`, other) + mustExec(t, f.db, `DELETE FROM locations WHERE id = $1`, other) + }) + id, _ := f.enter("V-WALL", "1", 1000) + mustExec(t, f.db, `UPDATE vendor_invoices SET branch_id = $2 WHERE id = $1`, id, other) + + wall := []string{"X-Test-Branch", f.branch.String()} + if g := f.do("GET", "/api/v1/ap/invoices/"+id, nil, wall...); g.status != http.StatusNotFound { + t.Errorf("a held caller reads another branch's bill: %d, want 404", g.status) + } + if ta := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", + map[string]any{"to": "approved", "revision": 1}, wall...); ta.status != http.StatusNotFound { + t.Errorf("a held caller approves another branch's bill: %d, want 404", ta.status) + } + if tv := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", + map[string]any{"to": "voided", "revision": 1, "reason": "x"}, wall...); tv.status != http.StatusNotFound { + t.Errorf("a held caller voids another branch's bill: %d, want 404", tv.status) + } + list := f.do("GET", "/api/v1/ap/invoices?limit=50&vendor_id="+f.vendor.String(), nil, wall...) + if list.status != 200 { + t.Fatalf("list = %d", list.status) + } + for _, it := range list.body["items"].([]any) { + if it.(map[string]any)["id"].(string) == id { + t.Error("the walled list shows the other branch's bill") + } + } + foreign := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": "V-FOREIGN", "branch_id": other.String(), + "invoice_date": "2026-01-10", "due_date": "2026-02-10", + "lines": []map[string]any{f.lineBody("1", 1000)}}, wall...) + if foreign.status != http.StatusForbidden { + t.Errorf("create at a foreign branch = %d, want 403", foreign.status) + } + if code, _, fields := errorOf(t, foreign); code != "forbidden" || len(fields) == 0 || fields[0] != "branch_id" { + t.Errorf("create at a foreign branch = %q %v", code, fields) + } + // The same caller creates at its own branch. + if r := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": "V-OWN", "branch_id": f.branch.String(), + "invoice_date": "2026-01-10", "due_date": "2026-02-10", + "lines": []map[string]any{f.lineBody("1", 1000)}}, wall...); r.status != http.StatusCreated { + t.Errorf("create at the caller's branch = %d, want 201: %s", r.status, r.raw) + } +} + +// RULE (ADR 0001 section 9): idempotency through the existing middleware: the +// same create twice with one key returns the first response and makes one row +// and one event; the same key with another body is 422. +func TestWire_Idempotency(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + key := "c41b-" + uuid.NewString() + body := f.createBody("V-IDEM", "1", 1000) + first := f.do("POST", "/api/v1/ap/invoices", body, "Idempotency-Key", key) + if first.status != http.StatusCreated { + t.Fatalf("first = %d: %s", first.status, first.raw) + } + again := f.do("POST", "/api/v1/ap/invoices", body, "Idempotency-Key", key) + if again.status != http.StatusCreated || again.header.Get("Idempotency-Replayed") != "true" { + t.Fatalf("replay = %d replayed=%q, want 201 replayed", again.status, again.header.Get("Idempotency-Replayed")) + } + if again.body["id"] != first.body["id"] { + t.Errorf("replay answered %v, want the first bill %v", again.body["id"], first.body["id"]) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 1 { + t.Errorf("%d bills for one keyed create", n) + } + if n := f.count(`SELECT count(*) FROM events_outbox WHERE entity_type = 'vendor_invoice' AND entity_id = $1`, first.body["id"]); n != 1 { + t.Errorf("%d events for one keyed create", n) + } + other := f.createBody("V-IDEM-2", "1", 1000) + if r := f.do("POST", "/api/v1/ap/invoices", other, "Idempotency-Key", key); r.status != http.StatusUnprocessableEntity { + t.Errorf("key reuse with another body = %d, want 422", r.status) + } +} + +// The aging read: the buckets come from amount_open, and a voided bill owes +// nothing anywhere. +func TestWire_Aging(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-AGE", "1", 1000) // 10 cents open + if a := f.approve(id, r1); a.status != http.StatusOK { + t.Fatal(a.raw) + } + r := f.do("GET", "/api/v1/ap/aging", nil) + if r.status != 200 { + t.Fatalf("aging = %d: %s", r.status, r.raw) + } + var rows []map[string]any + if err := json.Unmarshal(r.raw, &rows); err != nil { + t.Fatalf("aging body: %v", err) + } + found := false + for _, row := range rows { + if row["vendor_id"] == f.vendor.String() { + found = true + if row["total"].(float64) != 10 { + t.Errorf("aging total = %v, want 10 cents", row["total"]) + } + } + } + if !found { + t.Errorf("the aging report does not list the fixture's vendor: %s", r.raw) + } +} From 582a1dfd3315dd58c7ba61ed081a3a7c909a725e Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 05:41:42 -0700 Subject: [PATCH 03/32] ap: the contract fragment, census and goldens on the converted routes The fragment describes the vendor invoice routes exactly (the cursor envelope, the cents and ten thousandths, the transitions route with its precondition and blockers, the payment and aging routes in their today shapes with the payment act's new refusals); the model binding ties the new ApVendorInvoice, ApVendorInvoiceSummary and ApVendorInvoiceLine to the Go structs; the census carries the transitions route in place of /approve and the pending list empties; CONTRACT-CHANGES gains the C4-1b rows. The ap and ap_payments goldens re-record on the converted routes: the create in cents, ten thousandths and a decimal string quantity, the 428 transition without a precondition, the approve through /transitions and its 409 on a second attempt, the events feed step, the partial and settling payments, the paid bill's further payment refused invoice_not_approved, the void with payments refused has_payments, the validation 400, the status filters and the pending void. The harness gains the expenseAccount seed anchor the bills' lines name. Signed-off-by: colton --- core/api/ROUTES.txt | 14 +- core/api/fragments/ap.yaml | 585 ++++++++++++------ core/api/openapi.yaml | 485 ++++++++++----- core/internal/apicontract/model_shape_test.go | 5 +- .../internal/characterization/harness_test.go | 1 + .../characterization/scenarios_r1b_a_test.go | 31 +- .../characterization/scenarios_sales_test.go | 20 +- .../characterization/testdata/goldens/ap.json | 508 ++++++++++++--- .../testdata/goldens/ap_payments.json | 496 ++++++++++++--- docs/refactor/CONTRACT-CHANGES.md | 9 + 10 files changed, 1618 insertions(+), 536 deletions(-) diff --git a/core/api/ROUTES.txt b/core/api/ROUTES.txt index 8d1a12dfa..ddb311b19 100644 --- a/core/api/ROUTES.txt +++ b/core/api/ROUTES.txt @@ -93,13 +93,13 @@ GET /api/v1/admin/staff/{id} internal/staff h.GetStaff PUT /api/v1/admin/staff/{id} internal/staff h.UpdateStaff POST /api/v1/admin/staff/{id}/modules internal/staff h.GrantModule DELETE /api/v1/admin/staff/{id}/modules/{module_id} internal/staff h.RevokeModule -GET /api/v1/ap/aging internal/ap h.GetAgingSummary -GET /api/v1/ap/invoices internal/ap h.ListVendorInvoices -POST /api/v1/ap/invoices internal/ap h.CreateVendorInvoice -GET /api/v1/ap/invoices/{id} internal/ap h.GetVendorInvoice -POST /api/v1/ap/invoices/{id}/approve internal/ap h.ApproveInvoice -GET /api/v1/ap/payments internal/ap h.ListPayments -POST /api/v1/ap/payments internal/ap h.PayVendor +GET /api/v1/ap/aging internal/ap h.HandleGetAgingSummary +GET /api/v1/ap/invoices internal/ap h.HandleList +POST /api/v1/ap/invoices internal/ap h.HandleCreate +GET /api/v1/ap/invoices/{id} internal/ap h.HandleGet +POST /api/v1/ap/invoices/{id}/transitions internal/ap h.HandleTransition +GET /api/v1/ap/payments internal/ap h.HandleListPayments +POST /api/v1/ap/payments internal/ap h.HandlePayVendor GET /api/v1/apps pkg/apps h.handleList POST /api/v1/apps/{key}/disable pkg/apps h.handleDisable POST /api/v1/apps/{key}/enable pkg/apps h.handleEnable diff --git a/core/api/fragments/ap.yaml b/core/api/fragments/ap.yaml index 5ac44d693..545359aff 100644 --- a/core/api/fragments/ap.yaml +++ b/core/api/fragments/ap.yaml @@ -1,19 +1,17 @@ # SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 # SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors # -# The accounts payable module, transcribed from internal/ap/handler.go, -# model.go, service.go and repository.go as they behave today. Quirks the -# transcription preserves: response money is int64 cents but the create -# requests take float dollars (tax_amount, unit_price, amount) and convert on -# the way in; the lists are bare JSON arrays (the handlers coerce nil to []), -# with no limit or offset (a ?limit= is silently ignored) and unparseable -# vendor_id filters silently dropped; service validation failures (bad dates, -# unknown invoice) surface as 500, while approve maps every failure to 422 and -# an unauthenticated approve is a 401 raised by the handler itself; create -# echoes omit vendor_name and lines, which only the reads carry. Every error -# body is the standard envelope. Every route sits behind -# RequireRole("admin","owner","finance"), registered in -# internal/app/serve/serve.go. +# The accounts payable module. The vendor invoice routes are on the wire +# contract (item C4-1b, ADR 0008 sections 7.4 and 11, on ADR 0001): Gable's +# own number (AP-, gapped sequence) as number, the vendor's as +# vendor_invoice_number, money in _cents, the scaled unit price, the cursor +# envelope, the error envelope, the revision precondition and a transitions +# route that replaced /approve. The payment and aging routes keep their +# today shapes until their own conversion (ADR 0008 section 11), with the +# payment act's new refusals. Every route sits behind +# scoped("admin","owner","finance") with the branch middleware, registered in +# internal/app/serve/serve.go; the bills carry a branch, so every read and +# write is held to the caller's wall. paths: /api/v1/ap/invoices: get: @@ -21,86 +19,115 @@ paths: tags: [ap] summary: List vendor invoices description: >- - Role guard admin, owner or finance. Optional filters vendor_id (an - unparseable value is ignored, not refused) and status (free text, - compared as given). No paging parameters exist; limit and offset are - silently ignored. Never null. Lines are not included. + Role guard admin, owner or finance, behind the branch middleware: a + bound caller sees its branch's bills only. The cursor envelope, + newest first by (created_at, id). vendor_id and po_id filter on their + document and status on the lowercase lifecycle vocabulary (a comma + separated list); a parameter the route does not declare, a status + outside the vocabulary or an uppercase one, a malformed cursor or an + out of range limit is a 400. total appears only under include=total. parameters: - name: vendor_id in: query required: false + description: Filter on the vendor. + schema: + type: string + format: uuid + - name: po_id + in: query + required: false + description: Filter on the purchase order. schema: type: string format: uuid - name: status in: query required: false - description: Invoice status filter, for example PENDING, APPROVED, PARTIAL, PAID or VOIDED. + description: Comma separated lowercase statuses (pending, approved, partial, paid, voided). schema: type: string + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Include' + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The vendor invoices, ordered by due_date ascending (earliest due first). + '200': + description: The page of vendor invoice summaries. content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/ApVendorInvoice" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApInvoicePage' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' post: operationId: apInvoiceCreate tags: [ap] summary: Enter a vendor invoice description: >- - Role guard admin, owner or finance. The request carries float - dollars; subtotal, tax and line amounts are converted to int64 cents. - The invoice starts PENDING with nothing paid. Only an undecodable - body is a 400; a malformed invoice_date or due_date or any database - refusal (unknown vendor, duplicate number) is a 500. The echo omits - vendor_name and lines. + Role guard admin, owner or finance. The bill starts pending, owing + its whole total, with Gable's own number. Money is _cents, the line + unit price is _ten_thousandths and the quantity a decimal string; the + server prices each line and never accepts a status. Every line names + its gl_account_id (the approve entry debits each line's account). The + branch defaults from the body branch, the caller's context branch or + the default branch, and is held to the caller's wall (403 naming + branch_id). Validation failures are one 400 naming every field with + its full path; a duplicate vendor number for the vendor is a 409; an + unknown vendor, purchase order or account is a 400 naming the field. parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/XBranchId' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApCreateVendorInvoiceRequest" + $ref: '#/components/schemas/ApCreateVendorInvoiceRequest' responses: - "201": - description: The created invoice. + '201': + description: The created bill, with its lines. + headers: + ETag: + description: The document's revision. + schema: + type: string + Location: + description: The bill's own route. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "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" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApVendorInvoice' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/invoices/{id}: get: operationId: apInvoiceGet tags: [ap] summary: Get one vendor invoice with lines description: >- - Role guard admin, owner or finance. Any service failure, not only a - missing row, is a 404. lines is omitted when the invoice has none. + Role guard admin, owner or finance, held to the caller's branch wall: + another branch's bill is a 404. Carries the revision's ETag. parameters: - name: id in: path @@ -108,34 +135,47 @@ paths: schema: type: string format: uuid + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The invoice with vendor_name and lines. + '200': + description: The bill with its lines. + headers: + ETag: + description: The document's revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - /api/v1/ap/invoices/{id}/approve: + $ref: '#/components/schemas/ApVendorInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/WireNotFound' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/ap/invoices/{id}/transitions: post: - operationId: apInvoiceApprove + operationId: apInvoiceTransition tags: [ap] - summary: Approve a pending vendor invoice + summary: Approve or void a vendor invoice description: >- - Role guard admin, owner or finance. No body. The approver is the - subject of the caller's JWT claims; with no claims, or a subject that - is not a UUID, the handler answers 401 itself (so in AUTH_MODE=dev, - which carries no claims, this route is always 401). Only a PENDING - invoice can be approved. Every service failure, including an unknown - id, a non pending status or a failed GL sync, is a 422. The response - carries vendor_name, approved_by and approved_at but not lines. + Role guard admin, owner or finance, held to the caller's branch wall. + Replaces the old /approve route. The body names the target status and + carries the revision (or If-Match); a write with neither is 428, a + mismatch 409 stale_revision. To approved: only from pending; the + entry posts through gl.PostEntry inside the transition's transaction + (a debit leg per line on the line's account, the tax spread across + the line accounts pro rata, the credit to Accounts Payable), and a + posting failure fails the act (a closed period is a 409 blocker + period_closed; a line with no account is a 409 blocker + line_needs_account). To voided: from pending (nothing posted) or + approved (the entry is reversed whole); a reason is required, and a + bill with money applied is refused with the has_payments blocker. parameters: - name: id in: path @@ -143,34 +183,55 @@ paths: schema: type: string format: uuid - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/XBranchId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApInvoiceTransitionRequest' responses: - "200": - description: The approved invoice. + '200': + description: The bill after the transition, with its lines. + headers: + ETag: + description: The document's new revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/HandlerUnauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntityEither" + $ref: '#/components/schemas/ApVendorInvoice' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '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': + $ref: '#/components/responses/InternalError' /api/v1/ap/payments: get: operationId: apPaymentList tags: [ap] summary: List vendor payments description: >- - Role guard admin, owner or finance. Optional vendor_id filter (an - unparseable value is ignored). No paging. Never null. + Role guard admin, owner or finance. A bare array until the payment + routes' own conversion (ADR 0008 section 11). Optional vendor_id + filter (an unparseable value is a 400 naming it); a parameter the + route does not declare is refused. parameters: - name: vendor_id in: query @@ -179,60 +240,66 @@ paths: type: string format: uuid responses: - "200": + '200': description: The vendor payments. content: application/json: schema: type: array items: - $ref: "#/components/schemas/ApPayment" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApPayment' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' post: operationId: apPaymentCreate tags: [ap] summary: Pay a vendor description: >- - Role guard admin, owner or finance. The amount is float dollars, - converted to int64 cents. The payment is created COMPLETE and applied - in order to the listed invoices up to each one's outstanding - balance, then synced to the GL. Only an undecodable body is a 400; - a malformed payment_date, an unknown invoice or any GL failure is a - 500. + Role guard admin, owner or finance. The request keeps its today shape + (float dollars, uppercase methods) until the payment routes' own + conversion. The payment locks the named bills in id order and applies + only to bills in approved or partial (a pending, paid or voided bill + is a 409 blocker invoice_not_approved; a bill of another vendor is a + 400 naming invoice_ids[i]); more than the named bills still owe is a + 409 blocker exceeds_open. The entry posts through gl.PostEntry inside + the act's transaction (DR Accounts Payable, CR Cash), and a posting + failure fails the act (a closed period is a 409 blocker + period_closed). parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApCreatePaymentRequest" + $ref: '#/components/schemas/ApCreatePaymentRequest' responses: - "201": + '201': description: The created payment. content: application/json: schema: - $ref: "#/components/schemas/ApPayment" - "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" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApPayment' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/aging: get: operationId: apAging @@ -240,109 +307,228 @@ paths: summary: AP aging by vendor description: >- Role guard admin, owner or finance. One row per vendor with open - balances in int64 cents buckets. Never null. + balances in int64 cents buckets, from amount_open; a paid or voided + bill owes nothing anywhere. A bare array until the aging route's own + conversion. No query parameters; one is refused. responses: - "200": + '200': description: The aging rows. content: application/json: schema: type: array items: - $ref: "#/components/schemas/ApAgingSummary" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApAgingSummary' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' components: schemas: + ApInvoicePage: + type: object + required: [items, next_cursor, limit] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ApVendorInvoiceSummary' + next_cursor: + type: [string, 'null'] + limit: + type: integer + total: + type: integer + description: Only under include=total. ApVendorInvoice: type: object - required: [id, vendor_id, invoice_number, invoice_date, due_date, subtotal, tax_amount, total, amount_paid, status, created_at] - description: A vendor bill (ap.VendorInvoice). Every money field is int64 cents. + required: [id, number, vendor_id, vendor_name, branch_id, vendor_invoice_number, currency, invoice_date, + due_date, po_id, subtotal_cents, tax_cents, total_cents, amount_paid_cents, amount_open_cents, + status, approved_by, approved_at, notes, revision, gl_entry_id, created_at, lines] + description: >- + A vendor bill (ap.Invoice): Gable's own number as number, the vendor's + as vendor_invoice_number, money in _cents, a lowercase status, the + revision, and the entry the approval posted. Optional fields are + present as null. properties: id: type: string format: uuid + number: + type: string + description: Gable's own number, AP- and six or more digits. vendor_id: type: string format: uuid vendor_name: type: string - description: Joined from the vendor on reads and approve; omitted on the create echo. - invoice_number: + branch_id: + type: string + format: uuid + vendor_invoice_number: + type: string + description: The vendor's own number for the bill. + currency: type: string + description: ISO 4217. invoice_date: type: string - format: date-time + format: date due_date: type: string - format: date-time + format: date po_id: - type: string + type: [string, 'null'] format: uuid - subtotal: + subtotal_cents: type: integer format: int64 - description: Cents. - tax_amount: + tax_cents: type: integer format: int64 - description: Cents. - total: + total_cents: type: integer format: int64 - description: Cents. - amount_paid: + amount_paid_cents: + type: integer + format: int64 + amount_open_cents: type: integer format: int64 - description: Cents. status: type: string - enum: [PENDING, APPROVED, PARTIAL, PAID, VOIDED] + enum: [pending, approved, partial, paid, voided] approved_by: - type: string + type: [string, 'null'] format: uuid approved_at: - type: string + type: [string, 'null'] format: date-time notes: type: string + revision: + type: integer + format: int64 + gl_entry_id: + type: [string, 'null'] + format: uuid created_at: type: string format: date-time lines: type: array - description: Present on the single read only. items: - $ref: "#/components/schemas/ApVendorInvoiceLine" - ApVendorInvoiceLine: + $ref: '#/components/schemas/ApVendorInvoiceLine' + ApVendorInvoiceSummary: type: object - required: [id, invoice_id, description, quantity, unit_price, line_total, created_at] - description: A vendor invoice line (ap.VendorInvoiceLine). unit_price and line_total are int64 cents. + required: [id, number, vendor_id, vendor_name, branch_id, vendor_invoice_number, currency, invoice_date, + due_date, po_id, subtotal_cents, tax_cents, total_cents, amount_paid_cents, amount_open_cents, + status, approved_by, approved_at, notes, revision, gl_entry_id, created_at] + description: A vendor bill header (ap.Summary), the list item and the shape the full document embeds. properties: id: type: string format: uuid - invoice_id: + number: + type: string + vendor_id: + type: string + format: uuid + vendor_name: + type: string + branch_id: + type: string + format: uuid + vendor_invoice_number: + type: string + currency: + type: string + invoice_date: + type: string + format: date + due_date: + type: string + format: date + po_id: + type: [string, 'null'] + format: uuid + subtotal_cents: + type: integer + format: int64 + tax_cents: + type: integer + format: int64 + total_cents: + type: integer + format: int64 + amount_paid_cents: + type: integer + format: int64 + amount_open_cents: + type: integer + format: int64 + status: + type: string + enum: [pending, approved, partial, paid, voided] + approved_by: + type: [string, 'null'] + format: uuid + approved_at: + type: [string, 'null'] + format: date-time + notes: + type: string + revision: + type: integer + format: int64 + gl_entry_id: + type: [string, 'null'] + format: uuid + created_at: + type: string + format: date-time + ApVendorInvoiceLine: + type: object + required: [id, position, description, quantity, unit_price_ten_thousandths, line_total_cents, gl_account_id, + purchase_order_line_id, product_id, po_freight_charge_id, created_at] + description: >- + A vendor invoice line (ap.InvoiceLine). The quantity is a decimal + string, the unit price is _ten_thousandths, and the extension is the + server's one rounding. The purchase order line and freight charge + links arrive with migration 106 and are written from package C's + freight rule on (ADR 0008 7.3): today's lines are not linked. + properties: + id: type: string format: uuid + position: + type: integer description: type: string quantity: - type: number - unit_price: + type: string + description: A plain decimal with at most 4 fraction digits. + unit_price_ten_thousandths: type: integer format: int64 - description: Cents. - line_total: + line_total_cents: type: integer format: int64 - description: Cents. gl_account_id: - type: string + type: [string, 'null'] + format: uuid + purchase_order_line_id: + type: [string, 'null'] + format: uuid + product_id: + type: [string, 'null'] + format: uuid + po_freight_charge_id: + type: [string, 'null'] format: uuid created_at: type: string @@ -350,7 +536,7 @@ components: ApPayment: type: object required: [id, vendor_id, amount, method, payment_date, status, created_at] - description: A payment to a vendor (ap.APPayment). amount is int64 cents. + description: A payment to a vendor (ap.APPayment), in its today shape. amount is int64 cents. properties: id: type: string @@ -376,7 +562,7 @@ components: type: string payment_date: type: string - format: date-time + format: date status: type: string description: PENDING, COMPLETE or VOIDED. @@ -415,13 +601,22 @@ components: description: Cents. ApCreateVendorInvoiceRequest: type: object - description: The create body. Dates are YYYY-MM-DD; tax_amount and line unit_price are float dollars today. + required: [vendor_id, vendor_invoice_number, invoice_date, due_date, lines] + description: >- + The create body. Dates are YYYY-MM-DD; money is _cents and the line + unit price is _ten_thousandths; the quantity is a decimal string; the + server prices each line and never accepts a status. properties: vendor_id: type: string format: uuid - invoice_number: + branch_id: + type: string + format: uuid + description: Defaults from the caller's context branch or the default branch. + vendor_invoice_number: type: string + description: 1 to 64 characters, unique per vendor. invoice_date: type: string format: date @@ -431,31 +626,61 @@ components: po_id: type: string format: uuid - tax_amount: - type: number - description: Float dollars today. + currency: + type: string + description: An ISO 4217 code of three capital letters; defaults from the setting. + tax_cents: + type: integer + format: int64 notes: type: string lines: type: array + minItems: 1 items: - $ref: "#/components/schemas/ApCreateVendorInvoiceLineRequest" + $ref: '#/components/schemas/ApCreateVendorInvoiceLineRequest' ApCreateVendorInvoiceLineRequest: type: object + required: [description, quantity, unit_price_ten_thousandths, gl_account_id] properties: description: type: string + description: 1 to 256 characters. quantity: - type: number - unit_price: - type: number - description: Float dollars today. + type: string + description: A plain decimal with at most 4 fraction digits, more than zero. + unit_price_ten_thousandths: + type: integer + format: int64 + description: Never negative. gl_account_id: type: string format: uuid + description: The account the approve entry debits for the line. + purchase_order_line_id: + type: string + format: uuid + product_id: + type: string + format: uuid + ApInvoiceTransitionRequest: + type: object + required: [to] + description: The transition body. The revision, or If-Match, is required (ADR 0001 section 11). + properties: + to: + type: string + enum: [approved, voided] + revision: + type: integer + format: int64 + reason: + type: string + description: Required to void; at most 500 characters. ApCreatePaymentRequest: type: object - description: The pay body. payment_date is YYYY-MM-DD; amount is float dollars today. + required: [vendor_id, amount, method, payment_date] + description: The pay body, in its today shape until the payment routes' own conversion. properties: vendor_id: type: string @@ -475,7 +700,7 @@ components: format: date invoice_ids: type: array - description: Invoices this payment is applied to, in order. + description: Bills this payment is applied to, in the order the desk pays them. items: type: string format: uuid diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 6b612e31d..480ad0c2e 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -1186,77 +1186,99 @@ paths: tags: [ap] summary: List vendor invoices description: >- - Role guard admin, owner or finance. Optional filters vendor_id (an unparseable value is ignored, not refused) and status (free text, compared as given). No paging parameters exist; limit and offset are silently ignored. Never null. Lines are not included. + Role guard admin, owner or finance, behind the branch middleware: a bound caller sees its branch's bills only. The cursor envelope, newest first by (created_at, id). vendor_id and po_id filter on their document and status on the lowercase lifecycle vocabulary (a comma separated list); a parameter the route does not declare, a status outside the vocabulary or an uppercase one, a malformed cursor or an out of range limit is a 400. total appears only under include=total. parameters: - name: vendor_id in: query required: false + description: Filter on the vendor. + schema: + type: string + format: uuid + - name: po_id + in: query + required: false + description: Filter on the purchase order. schema: type: string format: uuid - name: status in: query required: false - description: Invoice status filter, for example PENDING, APPROVED, PARTIAL, PAID or VOIDED. + description: Comma separated lowercase statuses (pending, approved, partial, paid, voided). schema: type: string + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Include' + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The vendor invoices, ordered by due_date ascending (earliest due first). + '200': + description: The page of vendor invoice summaries. content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/ApVendorInvoice" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApInvoicePage' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' post: operationId: apInvoiceCreate tags: [ap] summary: Enter a vendor invoice description: >- - Role guard admin, owner or finance. The request carries float dollars; subtotal, tax and line amounts are converted to int64 cents. The invoice starts PENDING with nothing paid. Only an undecodable body is a 400; a malformed invoice_date or due_date or any database refusal (unknown vendor, duplicate number) is a 500. The echo omits vendor_name and lines. + Role guard admin, owner or finance. The bill starts pending, owing its whole total, with Gable's own number. Money is _cents, the line unit price is _ten_thousandths and the quantity a decimal string; the server prices each line and never accepts a status. Every line names its gl_account_id (the approve entry debits each line's account). The branch defaults from the body branch, the caller's context branch or the default branch, and is held to the caller's wall (403 naming branch_id). Validation failures are one 400 naming every field with its full path; a duplicate vendor number for the vendor is a 409; an unknown vendor, purchase order or account is a 400 naming the field. parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/XBranchId' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApCreateVendorInvoiceRequest" + $ref: '#/components/schemas/ApCreateVendorInvoiceRequest' responses: - "201": - description: The created invoice. + '201': + description: The created bill, with its lines. + headers: + ETag: + description: The document's revision. + schema: + type: string + Location: + description: The bill's own route. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "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" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApVendorInvoice' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/invoices/{id}: get: operationId: apInvoiceGet tags: [ap] summary: Get one vendor invoice with lines description: >- - Role guard admin, owner or finance. Any service failure, not only a missing row, is a 404. lines is omitted when the invoice has none. + Role guard admin, owner or finance, held to the caller's branch wall: another branch's bill is a 404. Carries the revision's ETag. parameters: - name: id in: path @@ -1264,28 +1286,36 @@ paths: schema: type: string format: uuid + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The invoice with vendor_name and lines. + '200': + description: The bill with its lines. + headers: + ETag: + description: The document's revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - /api/v1/ap/invoices/{id}/approve: + $ref: '#/components/schemas/ApVendorInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/WireNotFound' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/ap/invoices/{id}/transitions: post: - operationId: apInvoiceApprove + operationId: apInvoiceTransition tags: [ap] - summary: Approve a pending vendor invoice + summary: Approve or void a vendor invoice description: >- - Role guard admin, owner or finance. No body. The approver is the subject of the caller's JWT claims; with no claims, or a subject that is not a UUID, the handler answers 401 itself (so in AUTH_MODE=dev, which carries no claims, this route is always 401). Only a PENDING invoice can be approved. Every service failure, including an unknown id, a non pending status or a failed GL sync, is a 422. The response carries vendor_name, approved_by and approved_at but not lines. + Role guard admin, owner or finance, held to the caller's branch wall. Replaces the old /approve route. The body names the target status and carries the revision (or If-Match); a write with neither is 428, a mismatch 409 stale_revision. To approved: only from pending; the entry posts through gl.PostEntry inside the transition's transaction (a debit leg per line on the line's account, the tax spread across the line accounts pro rata, the credit to Accounts Payable), and a posting failure fails the act (a closed period is a 409 blocker period_closed; a line with no account is a 409 blocker line_needs_account). To voided: from pending (nothing posted) or approved (the entry is reversed whole); a reason is required, and a bill with money applied is refused with the has_payments blocker. parameters: - name: id in: path @@ -1293,33 +1323,52 @@ paths: schema: type: string format: uuid - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/XBranchId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApInvoiceTransitionRequest' responses: - "200": - description: The approved invoice. + '200': + description: The bill after the transition, with its lines. + headers: + ETag: + description: The document's new revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/HandlerUnauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntityEither" + $ref: '#/components/schemas/ApVendorInvoice' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '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': + $ref: '#/components/responses/InternalError' /api/v1/ap/payments: get: operationId: apPaymentList tags: [ap] summary: List vendor payments description: >- - Role guard admin, owner or finance. Optional vendor_id filter (an unparseable value is ignored). No paging. Never null. + Role guard admin, owner or finance. A bare array until the payment routes' own conversion (ADR 0008 section 11). Optional vendor_id filter (an unparseable value is a 400 naming it); a parameter the route does not declare is refused. parameters: - name: vendor_id in: query @@ -1328,77 +1377,81 @@ paths: type: string format: uuid responses: - "200": + '200': description: The vendor payments. content: application/json: schema: type: array items: - $ref: "#/components/schemas/ApPayment" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApPayment' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' post: operationId: apPaymentCreate tags: [ap] summary: Pay a vendor description: >- - Role guard admin, owner or finance. The amount is float dollars, converted to int64 cents. The payment is created COMPLETE and applied in order to the listed invoices up to each one's outstanding balance, then synced to the GL. Only an undecodable body is a 400; a malformed payment_date, an unknown invoice or any GL failure is a 500. + Role guard admin, owner or finance. The request keeps its today shape (float dollars, uppercase methods) until the payment routes' own conversion. The payment locks the named bills in id order and applies only to bills in approved or partial (a pending, paid or voided bill is a 409 blocker invoice_not_approved; a bill of another vendor is a 400 naming invoice_ids[i]); more than the named bills still owe is a 409 blocker exceeds_open. The entry posts through gl.PostEntry inside the act's transaction (DR Accounts Payable, CR Cash), and a posting failure fails the act (a closed period is a 409 blocker period_closed). parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApCreatePaymentRequest" + $ref: '#/components/schemas/ApCreatePaymentRequest' responses: - "201": + '201': description: The created payment. content: application/json: schema: - $ref: "#/components/schemas/ApPayment" - "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" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApPayment' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '409': + $ref: '#/components/responses/ConflictEither' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/aging: get: operationId: apAging tags: [ap] summary: AP aging by vendor description: >- - Role guard admin, owner or finance. One row per vendor with open balances in int64 cents buckets. Never null. + Role guard admin, owner or finance. One row per vendor with open balances in int64 cents buckets, from amount_open; a paid or voided bill owes nothing anywhere. A bare array until the aging route's own conversion. No query parameters; one is refused. responses: - "200": + '200': description: The aging rows. content: application/json: schema: type: array items: - $ref: "#/components/schemas/ApAgingSummary" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApAgingSummary' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' /api/v1/apps: get: operationId: appsList @@ -14739,91 +14792,194 @@ components: properties: ok: type: boolean + ApInvoicePage: + type: object + required: [items, next_cursor, limit] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ApVendorInvoiceSummary' + next_cursor: + type: [string, 'null'] + limit: + type: integer + total: + type: integer + description: Only under include=total. ApVendorInvoice: type: object - required: [id, vendor_id, invoice_number, invoice_date, due_date, subtotal, tax_amount, total, amount_paid, status, created_at] - description: A vendor bill (ap.VendorInvoice). Every money field is int64 cents. + required: [id, number, vendor_id, vendor_name, branch_id, vendor_invoice_number, currency, invoice_date, due_date, po_id, subtotal_cents, tax_cents, total_cents, amount_paid_cents, amount_open_cents, status, approved_by, approved_at, notes, revision, gl_entry_id, created_at, lines] + description: >- + A vendor bill (ap.Invoice): Gable's own number as number, the vendor's as vendor_invoice_number, money in _cents, a lowercase status, the revision, and the entry the approval posted. Optional fields are present as null. properties: id: type: string format: uuid + number: + type: string + description: Gable's own number, AP- and six or more digits. vendor_id: type: string format: uuid vendor_name: type: string - description: Joined from the vendor on reads and approve; omitted on the create echo. - invoice_number: + branch_id: + type: string + format: uuid + vendor_invoice_number: + type: string + description: The vendor's own number for the bill. + currency: type: string + description: ISO 4217. invoice_date: type: string - format: date-time + format: date due_date: type: string - format: date-time + format: date po_id: - type: string + type: [string, 'null'] format: uuid - subtotal: + subtotal_cents: type: integer format: int64 - description: Cents. - tax_amount: + tax_cents: type: integer format: int64 - description: Cents. - total: + total_cents: type: integer format: int64 - description: Cents. - amount_paid: + amount_paid_cents: + type: integer + format: int64 + amount_open_cents: type: integer format: int64 - description: Cents. status: type: string - enum: [PENDING, APPROVED, PARTIAL, PAID, VOIDED] + enum: [pending, approved, partial, paid, voided] approved_by: - type: string + type: [string, 'null'] format: uuid approved_at: - type: string + type: [string, 'null'] format: date-time notes: type: string + revision: + type: integer + format: int64 + gl_entry_id: + type: [string, 'null'] + format: uuid created_at: type: string format: date-time lines: type: array - description: Present on the single read only. items: - $ref: "#/components/schemas/ApVendorInvoiceLine" - ApVendorInvoiceLine: + $ref: '#/components/schemas/ApVendorInvoiceLine' + ApVendorInvoiceSummary: type: object - required: [id, invoice_id, description, quantity, unit_price, line_total, created_at] - description: A vendor invoice line (ap.VendorInvoiceLine). unit_price and line_total are int64 cents. + required: [id, number, vendor_id, vendor_name, branch_id, vendor_invoice_number, currency, invoice_date, due_date, po_id, subtotal_cents, tax_cents, total_cents, amount_paid_cents, amount_open_cents, status, approved_by, approved_at, notes, revision, gl_entry_id, created_at] + description: A vendor bill header (ap.Summary), the list item and the shape the full document embeds. properties: id: type: string format: uuid - invoice_id: + number: + type: string + vendor_id: + type: string + format: uuid + vendor_name: + type: string + branch_id: type: string format: uuid + vendor_invoice_number: + type: string + currency: + type: string + invoice_date: + type: string + format: date + due_date: + type: string + format: date + po_id: + type: [string, 'null'] + format: uuid + subtotal_cents: + type: integer + format: int64 + tax_cents: + type: integer + format: int64 + total_cents: + type: integer + format: int64 + amount_paid_cents: + type: integer + format: int64 + amount_open_cents: + type: integer + format: int64 + status: + type: string + enum: [pending, approved, partial, paid, voided] + approved_by: + type: [string, 'null'] + format: uuid + approved_at: + type: [string, 'null'] + format: date-time + notes: + type: string + revision: + type: integer + format: int64 + gl_entry_id: + type: [string, 'null'] + format: uuid + created_at: + type: string + format: date-time + ApVendorInvoiceLine: + type: object + required: [id, position, description, quantity, unit_price_ten_thousandths, line_total_cents, gl_account_id, purchase_order_line_id, product_id, po_freight_charge_id, created_at] + description: >- + A vendor invoice line (ap.InvoiceLine). The quantity is a decimal string, the unit price is _ten_thousandths, and the extension is the server's one rounding. The purchase order line and freight charge links arrive with migration 106 and are written from package C's freight rule on (ADR 0008 7.3): today's lines are not linked. + properties: + id: + type: string + format: uuid + position: + type: integer description: type: string quantity: - type: number - unit_price: + type: string + description: A plain decimal with at most 4 fraction digits. + unit_price_ten_thousandths: type: integer format: int64 - description: Cents. - line_total: + line_total_cents: type: integer format: int64 - description: Cents. gl_account_id: - type: string + type: [string, 'null'] + format: uuid + purchase_order_line_id: + type: [string, 'null'] + format: uuid + product_id: + type: [string, 'null'] + format: uuid + po_freight_charge_id: + type: [string, 'null'] format: uuid created_at: type: string @@ -14831,7 +14987,7 @@ components: ApPayment: type: object required: [id, vendor_id, amount, method, payment_date, status, created_at] - description: A payment to a vendor (ap.APPayment). amount is int64 cents. + description: A payment to a vendor (ap.APPayment), in its today shape. amount is int64 cents. properties: id: type: string @@ -14857,7 +15013,7 @@ components: type: string payment_date: type: string - format: date-time + format: date status: type: string description: PENDING, COMPLETE or VOIDED. @@ -14896,13 +15052,20 @@ components: description: Cents. ApCreateVendorInvoiceRequest: type: object - description: The create body. Dates are YYYY-MM-DD; tax_amount and line unit_price are float dollars today. + required: [vendor_id, vendor_invoice_number, invoice_date, due_date, lines] + description: >- + The create body. Dates are YYYY-MM-DD; money is _cents and the line unit price is _ten_thousandths; the quantity is a decimal string; the server prices each line and never accepts a status. properties: vendor_id: type: string format: uuid - invoice_number: + branch_id: + type: string + format: uuid + description: Defaults from the caller's context branch or the default branch. + vendor_invoice_number: type: string + description: 1 to 64 characters, unique per vendor. invoice_date: type: string format: date @@ -14912,31 +15075,61 @@ components: po_id: type: string format: uuid - tax_amount: - type: number - description: Float dollars today. + currency: + type: string + description: An ISO 4217 code of three capital letters; defaults from the setting. + tax_cents: + type: integer + format: int64 notes: type: string lines: type: array + minItems: 1 items: - $ref: "#/components/schemas/ApCreateVendorInvoiceLineRequest" + $ref: '#/components/schemas/ApCreateVendorInvoiceLineRequest' ApCreateVendorInvoiceLineRequest: type: object + required: [description, quantity, unit_price_ten_thousandths, gl_account_id] properties: description: type: string + description: 1 to 256 characters. quantity: - type: number - unit_price: - type: number - description: Float dollars today. + type: string + description: A plain decimal with at most 4 fraction digits, more than zero. + unit_price_ten_thousandths: + type: integer + format: int64 + description: Never negative. gl_account_id: type: string format: uuid + description: The account the approve entry debits for the line. + purchase_order_line_id: + type: string + format: uuid + product_id: + type: string + format: uuid + ApInvoiceTransitionRequest: + type: object + required: [to] + description: The transition body. The revision, or If-Match, is required (ADR 0001 section 11). + properties: + to: + type: string + enum: [approved, voided] + revision: + type: integer + format: int64 + reason: + type: string + description: Required to void; at most 500 characters. ApCreatePaymentRequest: type: object - description: The pay body. payment_date is YYYY-MM-DD; amount is float dollars today. + required: [vendor_id, amount, method, payment_date] + description: The pay body, in its today shape until the payment routes' own conversion. properties: vendor_id: type: string @@ -14956,7 +15149,7 @@ components: format: date invoice_ids: type: array - description: Invoices this payment is applied to, in order. + description: Bills this payment is applied to, in the order the desk pays them. items: type: string format: uuid diff --git a/core/internal/apicontract/model_shape_test.go b/core/internal/apicontract/model_shape_test.go index 89d2d1987..cd012e670 100644 --- a/core/internal/apicontract/model_shape_test.go +++ b/core/internal/apicontract/model_shape_test.go @@ -296,8 +296,9 @@ var modelBoundSchemas = []struct { {"GlAccountLineItem", gl.AccountLineItem{}}, {"GlProfitAndLossReport", gl.ProfitAndLossReport{}}, {"GlBalanceSheetReport", gl.BalanceSheetReport{}}, - {"ApVendorInvoice", ap.VendorInvoice{}}, - {"ApVendorInvoiceLine", ap.VendorInvoiceLine{}}, + {"ApVendorInvoice", ap.Invoice{}}, + {"ApVendorInvoiceSummary", ap.Summary{}}, + {"ApVendorInvoiceLine", ap.InvoiceLine{}}, {"ApPayment", ap.APPayment{}}, {"ApAgingSummary", ap.APAgingSummary{}}, {"BankreconBankAccount", bankrecon.BankAccount{}}, diff --git a/core/internal/characterization/harness_test.go b/core/internal/characterization/harness_test.go index d9af8694e..9f1c40367 100644 --- a/core/internal/characterization/harness_test.go +++ b/core/internal/characterization/harness_test.go @@ -475,6 +475,7 @@ func seedVars(t *testing.T, dbURL string) map[string]string { if vars["glAccount2"] == "" { one("glAccount2", `SELECT id FROM gl_accounts ORDER BY code OFFSET 1 LIMIT 1`) } + one("expenseAccount", `SELECT id FROM gl_accounts WHERE code = '5020'`) one("vehicle", `SELECT id FROM vehicles WHERE deleted_at IS NULL ORDER BY name LIMIT 1`) one("driver", `SELECT id FROM drivers WHERE deleted_at IS NULL ORDER BY name LIMIT 1`) one("marketIndex", `SELECT id FROM market_indices WHERE index_code = 'RL_SPF_2X4'`) diff --git a/core/internal/characterization/scenarios_r1b_a_test.go b/core/internal/characterization/scenarios_r1b_a_test.go index 009a19c84..07dc1016b 100644 --- a/core/internal/characterization/scenarios_r1b_a_test.go +++ b/core/internal/characterization/scenarios_r1b_a_test.go @@ -592,18 +592,28 @@ func r1bAAPGroups() []groupDef { method: "POST", path: "/api/v1/ap/invoices", body: map[string]any{ - "vendor_id": "{vendor}", "invoice_number": "GOLD-AP-A01", - "invoice_date": "{today}", "due_date": "{today+30}", "tax_amount": 0, + "vendor_id": "{vendor}", "vendor_invoice_number": "GOLD-AP-A01", + "invoice_date": "{today}", "due_date": "{today+30}", "tax_cents": 0, "lines": []map[string]any{ - {"description": "golden ap payable line", "quantity": 2, "unit_price": 50.0}, + {"description": "golden ap payable line", "quantity": "2", + "unit_price_ten_thousandths": 500000, "gl_account_id": "{expenseAccount}"}, }, }, extract: map[string]string{"a_apinvoice": "/id"}, }, - // AUTH_MODE=dev attaches no claims, and the approve handler - // requires an authenticated approver: 401 on every dev call. - {name: "ap.invoice.approve.no_claims", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/approve"}, - {name: "ap.invoice.approve.bad_id", method: "POST", path: "/api/v1/ap/invoices/not-a-uuid/approve"}, + {name: "ap.invoice.approve.no_precondition", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/transitions", + body: map[string]any{"to": "approved"}}, + {name: "ap.invoice.approve", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/transitions", + body: map[string]any{"to": "approved", "revision": 1}}, + {name: "ap.invoice.approve.again", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/transitions", + body: map[string]any{"to": "approved", "revision": 2}}, + {name: "ap.invoice.transition.bad_id", method: "POST", path: "/api/v1/ap/invoices/not-a-uuid/transitions", + body: map[string]any{"to": "approved", "revision": 1}}, + // The live failure the C4-1b fixes pin in the golden: a paid bill + // takes no further payment, and one with money applied does not + // void. + {name: "ap.invoice.events", method: "GET", + path: "/api/v1/events?types=vendor_invoice.created,vendor_invoice.approved&limit=10"}, {name: "ap.payments.list_empty_for_vendor", method: "GET", path: "/api/v1/ap/payments?vendor_id={vendor}"}, { name: "ap.payment.create", @@ -628,6 +638,13 @@ func r1bAAPGroups() []groupDef { }, }, {name: "ap.invoice.get.after_settle", method: "GET", path: "/api/v1/ap/invoices/{a_apinvoice}"}, + {name: "ap.payment.create.paid_invoice", method: "POST", path: "/api/v1/ap/payments", + body: map[string]any{ + "vendor_id": "{vendor}", "amount": 1.0, "method": "CHECK", "payment_date": "{today}", + "invoice_ids": []any{"{a_apinvoice}"}, + }}, + {name: "ap.invoice.void.with_payments", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/transitions", + body: map[string]any{"to": "voided", "revision": 4, "reason": "golden void refused"}}, {name: "ap.payments.list_vendor", method: "GET", path: "/api/v1/ap/payments?vendor_id={vendor}", sortPrimaryArray: true}, {name: "ap.payment.create.bad_date", method: "POST", path: "/api/v1/ap/payments", body: map[string]any{ diff --git a/core/internal/characterization/scenarios_sales_test.go b/core/internal/characterization/scenarios_sales_test.go index c82e1f175..ceabe7ec4 100644 --- a/core/internal/characterization/scenarios_sales_test.go +++ b/core/internal/characterization/scenarios_sales_test.go @@ -891,16 +891,30 @@ func apGroups() []groupDef { method: "POST", path: "/api/v1/ap/invoices", body: map[string]any{ - "vendor_id": "{vendor}", "invoice_number": "GOLD-AP-001", - "invoice_date": "{today}", "due_date": "{today}", "tax_amount": 0, + "vendor_id": "{vendor}", "vendor_invoice_number": "GOLD-AP-001", + "invoice_date": "{today}", "due_date": "{today}", "tax_cents": 0, "lines": []map[string]any{ - {"description": "golden ap line", "quantity": 1, "unit_price": 100.0}, + {"description": "golden ap line", "quantity": "1", + "unit_price_ten_thousandths": 1000000, "gl_account_id": "{expenseAccount}"}, }, }, extract: map[string]string{"myAPInvoice": "/id"}, }, + {name: "ap.invoice.create.validation", method: "POST", path: "/api/v1/ap/invoices", + body: map[string]any{ + "vendor_id": "{vendor}", "vendor_invoice_number": "GOLD-AP-002", + "invoice_date": "{today}", "due_date": "{today}", + "lines": []map[string]any{ + {"description": "no account", "quantity": "1", "unit_price_ten_thousandths": 100}, + }, + }}, {name: "ap.invoice.get", method: "GET", path: "/api/v1/ap/invoices/{myAPInvoice}"}, {name: "ap.invoice.list", method: "GET", path: "/api/v1/ap/invoices?limit=3"}, + {name: "ap.invoice.list.status", method: "GET", path: "/api/v1/ap/invoices?limit=3&status=pending"}, + {name: "ap.invoice.list.bad_status", method: "GET", path: "/api/v1/ap/invoices?status=PENDING"}, + {name: "ap.invoice.void.pending", method: "POST", path: "/api/v1/ap/invoices/{myAPInvoice}/transitions", + body: map[string]any{"to": "voided", "revision": 1, "reason": "golden void"}}, + {name: "ap.invoice.voided.get", method: "GET", path: "/api/v1/ap/invoices/{myAPInvoice}"}, }, }, { name: "edi", diff --git a/core/internal/characterization/testdata/goldens/ap.json b/core/internal/characterization/testdata/goldens/ap.json index 7360087dc..b8bd6d14b 100644 --- a/core/internal/characterization/testdata/goldens/ap.json +++ b/core/internal/characterization/testdata/goldens/ap.json @@ -7,33 +7,99 @@ "body": { "due_date": "", "invoice_date": "", - "invoice_number": "GOLD-AP-001", "lines": [ { "description": "golden ap line", - "quantity": 1, - "unit_price": 100 + "gl_account_id": "", + "quantity": "1", + "unit_price_ten_thousandths": 1000000 } ], - "tax_amount": 0, - "vendor_id": "" + "tax_cents": 0, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001" } }, "response": { "status": 201, "content_type": "application/json", "body": { - "amount_paid": 0, + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-001", - "status": "PENDING", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "" + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "1", + "unit_price_ten_thousandths": 1000000 + } + ], + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + } + } + }, + { + "name": "ap.invoice.create.validation", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices", + "body": { + "due_date": "", + "invoice_date": "", + "lines": [ + { + "description": "no account", + "quantity": "1", + "unit_price_ten_thousandths": 100 + } + ], + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-002" + } + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "validation_failed", + "details": [ + { + "field": "lines[0].gl_account_id", + "message": "is required: the approve entry debits each line's account" + } + ], + "message": "one or more fields failed validation" + }, + "meta": { + "request_id": "" + } } } }, @@ -41,34 +107,48 @@ "name": "ap.invoice.get", "request": { "method": "GET", - "path": "/api/v1/ap/invoices/" + "path": "/api/v1/ap/invoices/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "amount_paid": 0, + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-001", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", "lines": [ { "created_at": "", "description": "golden ap line", - "id": "", - "invoice_id": "", - "line_total": 10000, - "quantity": 1, - "unit_price": 10000 + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "1", + "unit_price_ten_thousandths": 1000000 } ], - "status": "PENDING", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "", + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", "vendor_name": "BC Roofing Wholesale" } } @@ -82,78 +162,304 @@ "response": { "status": 200, "content_type": "application/json", - "body": [ - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-WIN-B91", - "status": "PENDING", - "subtotal": 91091, - "tax_amount": 0, - "total": 91091, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" - }, - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-WIN-B61", - "status": "PENDING", - "subtotal": 61061, - "tax_amount": 0, - "total": 61061, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" - }, - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-WIN-B31", - "status": "PENDING", - "subtotal": 31031, - "tax_amount": 0, - "total": 31031, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" - }, - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-WIN-B29", - "status": "PENDING", - "subtotal": 29029, - "tax_amount": 0, - "total": 29029, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" + "body": { + "items": [ + { + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + }, + { + "amount_open_cents": 29029, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "golden clock window fixture", + "number": "AP-000001", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 29029, + "tax_cents": 0, + "total_cents": 29029, + "vendor_id": "", + "vendor_invoice_number": "GOLD-WIN-B29", + "vendor_name": "BC Roofing Wholesale" + }, + { + "amount_open_cents": 31031, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "golden clock window fixture", + "number": "AP-000002", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 31031, + "tax_cents": 0, + "total_cents": 31031, + "vendor_id": "", + "vendor_invoice_number": "GOLD-WIN-B31", + "vendor_name": "BC Roofing Wholesale" + } + ], + "limit": 3, + "next_cursor": "" + } + } + }, + { + "name": "ap.invoice.list.status", + "request": { + "method": "GET", + "path": "/api/v1/ap/invoices?limit=3&status=pending" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "items": [ + { + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + }, + { + "amount_open_cents": 29029, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "golden clock window fixture", + "number": "AP-000001", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 29029, + "tax_cents": 0, + "total_cents": 29029, + "vendor_id": "", + "vendor_invoice_number": "GOLD-WIN-B29", + "vendor_name": "BC Roofing Wholesale" + }, + { + "amount_open_cents": 31031, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "golden clock window fixture", + "number": "AP-000002", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 31031, + "tax_cents": 0, + "total_cents": 31031, + "vendor_id": "", + "vendor_invoice_number": "GOLD-WIN-B31", + "vendor_name": "BC Roofing Wholesale" + } + ], + "limit": 3, + "next_cursor": "" + } + } + }, + { + "name": "ap.invoice.list.bad_status", + "request": { + "method": "GET", + "path": "/api/v1/ap/invoices?status=PENDING" + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "validation_failed", + "details": [ + { + "field": "status", + "message": "must be a comma separated list of: pending, approved, partial, paid, voided" + } + ], + "message": "one or more fields failed validation" }, - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-001", - "status": "PENDING", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" + "meta": { + "request_id": "" } - ] + } + } + }, + { + "name": "ap.invoice.void.pending", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices//transitions", + "body": { + "reason": "golden void", + "revision": 1, + "to": "voided" + } + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "amount_open_cents": 0, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "1", + "unit_price_ten_thousandths": 1000000 + } + ], + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 2, + "status": "voided", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + } + } + }, + { + "name": "ap.invoice.voided.get", + "request": { + "method": "GET", + "path": "/api/v1/ap/invoices/" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "amount_open_cents": 0, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "1", + "unit_price_ten_thousandths": 1000000 + } + ], + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 2, + "status": "voided", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + } } } ] diff --git a/core/internal/characterization/testdata/goldens/ap_payments.json b/core/internal/characterization/testdata/goldens/ap_payments.json index f313665c1..e2f1ecefa 100644 --- a/core/internal/characterization/testdata/goldens/ap_payments.json +++ b/core/internal/characterization/testdata/goldens/ap_payments.json @@ -7,81 +7,291 @@ "body": { "due_date": "", "invoice_date": "", - "invoice_number": "GOLD-AP-A01", "lines": [ { "description": "golden ap payable line", - "quantity": 2, - "unit_price": 50 + "gl_account_id": "", + "quantity": "2", + "unit_price_ten_thousandths": 500000 } ], - "tax_amount": 0, - "vendor_id": "" + "tax_cents": 0, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01" } }, "response": { "status": 201, "content_type": "application/json", "body": { - "amount_paid": 0, + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-A01", - "status": "PENDING", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "" + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap payable line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "2", + "unit_price_ten_thousandths": 500000 + } + ], + "notes": "", + "number": "AP-000006", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01", + "vendor_name": "BC Roofing Wholesale" + } + } + }, + { + "name": "ap.invoice.approve.no_precondition", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices//transitions", + "body": { + "to": "approved" + } + }, + "response": { + "status": 428, + "content_type": "application/json", + "body": { + "error": { + "code": "precondition_required", + "message": "this write needs If-Match or a body revision" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "ap.invoice.approve", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices//transitions", + "body": { + "revision": 1, + "to": "approved" + } + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": "", + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": "", + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap payable line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "2", + "unit_price_ten_thousandths": 500000 + } + ], + "notes": "", + "number": "AP-000006", + "po_id": null, + "revision": 2, + "status": "approved", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01", + "vendor_name": "BC Roofing Wholesale" } } }, { - "name": "ap.invoice.approve.no_claims", + "name": "ap.invoice.approve.again", "request": { "method": "POST", - "path": "/api/v1/ap/invoices//approve" + "path": "/api/v1/ap/invoices//transitions", + "body": { + "revision": 2, + "to": "approved" + } }, "response": { - "status": 401, + "status": 409, "content_type": "application/json", "body": { "error": { - "code": "UNAUTHORIZED", - "message": "Unauthorized" + "code": "invalid_state_transition", + "details": [ + { + "code": "invoice_not_pending", + "message": "the bill is approved" + } + ], + "message": "only a pending bill can be approved" }, "meta": { - "request_id": "" + "request_id": "" } } } }, { - "name": "ap.invoice.approve.bad_id", + "name": "ap.invoice.transition.bad_id", "request": { "method": "POST", - "path": "/api/v1/ap/invoices/not-a-uuid/approve" + "path": "/api/v1/ap/invoices/not-a-uuid/transitions", + "body": { + "revision": 1, + "to": "approved" + } }, "response": { "status": 400, "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "invalid vendor invoice id" }, "meta": { - "request_id": "" + "request_id": "" } } } }, + { + "name": "ap.invoice.events", + "request": { + "method": "GET", + "path": "/api/v1/events?types=vendor_invoice.created,vendor_invoice.approved&limit=10" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "items": [ + { + "at": "", + "branch_id": "", + "data": { + "amount_open_cents": 10000, + "branch_id": "", + "currency": "USD", + "from_status": "", + "number": "AP-000005", + "revision": 1, + "status": "pending", + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001" + }, + "entity": { + "id": "", + "kind": "vendor_invoice" + }, + "event_id": "", + "org": "default", + "type": "vendor_invoice.created" + }, + { + "at": "", + "branch_id": "", + "data": { + "amount_open_cents": 10000, + "branch_id": "", + "currency": "USD", + "from_status": "", + "number": "AP-000006", + "revision": 1, + "status": "pending", + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01" + }, + "entity": { + "id": "", + "kind": "vendor_invoice" + }, + "event_id": "", + "org": "default", + "type": "vendor_invoice.created" + }, + { + "at": "", + "branch_id": "", + "data": { + "amount_open_cents": 10000, + "branch_id": "", + "currency": "USD", + "from_status": "pending", + "number": "AP-000006", + "revision": 2, + "status": "approved", + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01" + }, + "entity": { + "id": "", + "kind": "vendor_invoice" + }, + "event_id": "", + "org": "default", + "type": "vendor_invoice.approved" + } + ], + "limit": 10, + "next_cursor": "" + } + } + }, { "name": "ap.payments.list_empty_for_vendor", "request": { "method": "GET", - "path": "/api/v1/ap/payments?vendor_id=" + "path": "/api/v1/ap/payments?vendor_id=" }, "response": { "status": 200, @@ -98,12 +308,12 @@ "amount": 40, "check_number": "GOLD-CHK-1", "invoice_ids": [ - "" + "" ], "method": "CHECK", "payment_date": "", "reference": "golden partial", - "vendor_id": "" + "vendor_id": "" } }, "response": { @@ -113,12 +323,12 @@ "amount": 4000, "check_number": "GOLD-CHK-1", "created_at": "", - "id": "", + "id": "", "method": "CHECK", - "payment_date": "", + "payment_date": "", "reference": "golden partial", "status": "COMPLETE", - "vendor_id": "" + "vendor_id": "" } } }, @@ -126,34 +336,48 @@ "name": "ap.invoice.get.after_partial", "request": { "method": "GET", - "path": "/api/v1/ap/invoices/" + "path": "/api/v1/ap/invoices/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "amount_paid": 4000, + "amount_open_cents": 6000, + "amount_paid_cents": 4000, + "approved_at": "", + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-A01", + "currency": "USD", + "due_date": "", + "gl_entry_id": "", + "id": "", + "invoice_date": "", "lines": [ { "created_at": "", "description": "golden ap payable line", - "id": "", - "invoice_id": "", - "line_total": 10000, - "quantity": 2, - "unit_price": 5000 + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "2", + "unit_price_ten_thousandths": 500000 } ], - "status": "PARTIAL", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "", + "notes": "", + "number": "AP-000006", + "po_id": null, + "revision": 3, + "status": "partial", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01", "vendor_name": "BC Roofing Wholesale" } } @@ -166,12 +390,12 @@ "body": { "amount": 60, "invoice_ids": [ - "" + "" ], "method": "ACH", "payment_date": "", "reference": "golden settle", - "vendor_id": "" + "vendor_id": "" } }, "response": { @@ -180,12 +404,12 @@ "body": { "amount": 6000, "created_at": "", - "id": "", + "id": "", "method": "ACH", - "payment_date": "", + "payment_date": "", "reference": "golden settle", "status": "COMPLETE", - "vendor_id": "" + "vendor_id": "" } } }, @@ -193,44 +417,124 @@ "name": "ap.invoice.get.after_settle", "request": { "method": "GET", - "path": "/api/v1/ap/invoices/" + "path": "/api/v1/ap/invoices/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "amount_paid": 10000, + "amount_open_cents": 0, + "amount_paid_cents": 10000, + "approved_at": "", + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-A01", + "currency": "USD", + "due_date": "", + "gl_entry_id": "", + "id": "", + "invoice_date": "", "lines": [ { "created_at": "", "description": "golden ap payable line", - "id": "", - "invoice_id": "", - "line_total": 10000, - "quantity": 2, - "unit_price": 5000 + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "2", + "unit_price_ten_thousandths": 500000 } ], - "status": "PAID", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "", + "notes": "", + "number": "AP-000006", + "po_id": null, + "revision": 4, + "status": "paid", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01", "vendor_name": "BC Roofing Wholesale" } } }, + { + "name": "ap.payment.create.paid_invoice", + "request": { + "method": "POST", + "path": "/api/v1/ap/payments", + "body": { + "amount": 1, + "invoice_ids": [ + "" + ], + "method": "CHECK", + "payment_date": "", + "vendor_id": "" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "invalid_state_transition", + "details": [ + { + "code": "invoice_not_approved", + "message": "AP-000006 is paid" + } + ], + "message": "only an approved or partial bill takes a payment" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "ap.invoice.void.with_payments", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices//transitions", + "body": { + "reason": "golden void refused", + "revision": 4, + "to": "voided" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "invalid_state_transition", + "details": [ + { + "code": "has_payments", + "message": "10000 cents have been applied to this bill" + } + ], + "message": "the bill carries payments: they must be undone before it is voided" + }, + "meta": { + "request_id": "" + } + } + } + }, { "name": "ap.payments.list_vendor", "sort_primary_array": true, "request": { "method": "GET", - "path": "/api/v1/ap/payments?vendor_id=" + "path": "/api/v1/ap/payments?vendor_id=" }, "response": { "status": 200, @@ -240,23 +544,23 @@ "amount": 4000, "check_number": "GOLD-CHK-1", "created_at": "", - "id": "", + "id": "", "method": "CHECK", - "payment_date": "", + "payment_date": "", "reference": "golden partial", "status": "COMPLETE", - "vendor_id": "", + "vendor_id": "", "vendor_name": "BC Roofing Wholesale" }, { "amount": 6000, "created_at": "", - "id": "", + "id": "", "method": "ACH", - "payment_date": "", + "payment_date": "", "reference": "golden settle", "status": "COMPLETE", - "vendor_id": "", + "vendor_id": "", "vendor_name": "BC Roofing Wholesale" } ] @@ -271,19 +575,25 @@ "amount": 1, "method": "CHECK", "payment_date": "31/12/2000", - "vendor_id": "" + "vendor_id": "" } }, "response": { - "status": 500, + "status": 400, "content_type": "application/json", "body": { "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "code": "validation_failed", + "details": [ + { + "field": "payment_date", + "message": "must be YYYY-MM-DD" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -296,23 +606,29 @@ "body": { "amount": 1, "invoice_ids": [ - "" + "" ], "method": "CHECK", "payment_date": "", - "vendor_id": "" + "vendor_id": "" } }, "response": { - "status": 500, + "status": 400, "content_type": "application/json", "body": { "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "code": "bad_request", + "details": [ + { + "field": "invoice_ids[0]", + "message": "no such invoice for this vendor" + } + ], + "message": "a referenced record does not exist" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -329,11 +645,11 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "message": "request body must be a JSON object" }, "meta": { - "request_id": "" + "request_id": "" } } } diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 372a2d3d5..e381eab06 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -234,3 +234,12 @@ get an entry. | C3-2A-units | quotes: the line's `tally` (on every quote read and write) | A line carried no tally: random length lumber was quoted as one quantity with no lengths | ADR 0006 section 4: a line on a random length product may carry `tally: {rows: [{pieces, length_ft}]}` (pieces a JSON integer, the length a decimal string, at most 100 rows, one row per length); its linear feet are the line's quantity (a sent quantity that disagrees is a 400 naming `lines[i].quantity`; the quantity may be omitted), its sale unit is LF, and the cross section is snapshotted from the product. The read serves the tally with `thickness_in`, `width_in`, `rows`, `linear_feet` and `board_feet` (the one display rounding, R4.3: 200/3 reads 66.6667); the money goes through the resolved pair (150 LF at 500.00 per MBF is 5000 cents). A tally on any other line is a 400 naming `lines[i].tally`, and a read only field in a request is a 400 naming it. A tally whose rows are each within the row limits but whose linear feet sum is past the quantity bound is a 400 naming `lines[i].tally` stating the cap (99999999.9999 linear feet), never an accepted wrapped sum. Every line carries `stock_uom`, `stock_quantity` and `tally`, null where they do not apply | | C3-2A-units | migration 099 `units_catalogue_and_sets` (operational, no route) | Units were the closed `uom_type` enum; a product had one unit; a quote line's pair was unvalidated client data | The unit catalogue replaces the enum (the three columns become TEXT with foreign keys to `units(code)`, values normalised and a value that cannot enter the catalogue aborts the migration naming it; `core migrate -report units` is the read only pre flight report, which compares against the section 2.2 seed codes as well as the live catalogue so a code the migration itself seeds is never listed as a new dealer unit), every product gains its stocking unit set row and the three default columns with the `price_uom = uom_primary` CHECK of the stocking unit hold, the quote line pairs become `product_unit_candidates` for the dealer to confirm (never applied), and the quote lines gain the tally table, the cross section columns and the stock backfill (a line that does not convert exactly keeps null stock fields and is reported). The raw writer defaults trigger keeps serving raw SQL (a stocking unit change drags the three defaults with it when the caller leaves them on the old stocking unit) but returns early for a writer that sets the transaction local `gable.unit_set_write` flag, which the unit set PUT does, because it names all four unit columns itself | | CORE-migrate | the one binary's `core migrate` (operational, no route) | `core migrate` refused every flag (`flag provided but not defined: -report`), so the read only units pre flight report that ADR 0006 section 8, migration 099 and the C3-2A-units row name as `core migrate -report units` ran only through the old `go run ./cmd/migrate -report units` entry | `core migrate` parses its own flags: `-report units` runs the pre flight report (the same output as the old entry), no flag applies the migrations as before, and any other flag, report or argument, or `-report` with an empty value, is refused with exit 2 (`-h` prints the usage and exits 0); the old `go run ./cmd/migrate` entry parses the same way, so a flag it once ignored and migrated past is now refused; the other subcommands still refuse every flag | +| C4-1b | ap: `GET /api/v1/ap/invoices` | A bare JSON array ordered by `due_date` ascending, no paging (a `?limit=` silently ignored), `vendor_id` and free text `status` filters (an unparseable vendor_id silently dropped), money as int64 cents but no revision, branch or Gable number | The cursor envelope `{items, next_cursor, limit}` newest first by `(created_at, id)`, the strict query guard (`vendor_id`, `po_id`, `status` as a comma separated list of the lowercase statuses `pending, approved, partial, paid, voided`, beside cursor, limit and include; unknown parameters, uppercase statuses, a malformed cursor or an out of range limit are 400s), `total` only under `include=total`, and the branch wall (a bound caller sees its branch's bills only). Every item carries `number` (Gable's own, `AP-`), `vendor_invoice_number` (the vendor's), `branch_id`, `currency`, `_cents` money, `amount_open_cents`, `revision`, `gl_entry_id` and the lowercase status | +| C4-1b | ap: `POST /api/v1/ap/invoices` | The body took float dollars (`tax_amount`, line `unit_price`, `quantity` as a float number); the bill's number was the vendor's own `invoice_number`; every service failure (a malformed date, an unknown vendor, a duplicate number) answered 500 | The wire contract's create: `tax_cents` int64, the line `unit_price_ten_thousandths` int64 and `quantity` a decimal string, the extension priced by the server with its one rounding; `vendor_invoice_number` (1 to 64 characters, unique per vendor, a duplicate a 409 naming it), an optional `branch_id` (defaults from the body, the caller's context branch or the default branch; a branch the caller may not target is a 403 naming `branch_id`), `currency` (defaults from the `currency.default` setting), an optional `po_id` (a real foreign key since migration 106), and per line an optional `purchase_order_line_id` and `product_id`. A line now requires its `gl_account_id` (the approve entry debits each line's account): a create without one is a 400 naming `lines[i].gl_account_id`. Validation failures are one 400 naming every field with its full path; the answer carries the revision and its `ETag`, `Location`, and `vendor_invoice.created` is written to the outbox with an audit row in the same transaction | +| C4-1b | ap: `GET /api/v1/ap/invoices/{id}` | Any service failure was a 404; the body carried the vendor's `invoice_number`, no `number`, `revision` or branch; lines were omitted when absent and priced in cents floats | The wire contract's read: a malformed id a 400 naming `id`, a missing or walled bill a 404, the revision's `ETag`, every optional field present as null, and lines in the shared shape (`position`, `quantity` as a decimal string, `unit_price_ten_thousandths`, `line_total_cents`, `gl_account_id`, `purchase_order_line_id`, `product_id`, `po_freight_charge_id`). The read is held to the caller's branch wall: another branch's bill is a 404 | +| C4-1b | ap: `POST /api/v1/ap/invoices/{id}/approve` replaced by `POST /api/v1/ap/invoices/{id}/transitions` | The approve route took no body, required the caller's JWT subject to be a UUID (401 otherwise, so in `AUTH_MODE=dev` it was always 401), mapped every service failure to 422, loaded no lines (so the entry it posted carried only the Accounts Payable credit leg), posted through `gl.SyncVendorInvoice` which swallowed the journal insert's error, and could never void | The transitions route of ADR 0008 7.4 with body `{to, revision, reason?}` (or `If-Match`; neither is 428, a stale one 409 `stale_revision`). `to: "approved"` (only from pending, else 409 `invalid_state_transition` with the `invoice_not_pending` blocker) loads the lines inside the transition's transaction, posts the entry through `gl.PostEntry` in that transaction (a debit leg per line on the line's account, the tax spread across the line accounts pro rata to their extensions with the last line taking the remainder, the credit to `2010` for the whole total), and a posting failure fails the act: a closed fiscal period is a 409 with the `period_closed` blocker, and a legacy line with no account is a 409 with `line_needs_account` naming the line (the base swallowed the posting error and answered 200 with the bill marked approved). `to: "voided"` (from pending, nothing posted; from approved, the entry reversed whole) requires a `reason` and is refused with the `has_payments` blocker while money is applied; `amount_open` goes to zero. The acts write `vendor_invoice.approved` and `vendor_invoice.voided` with audit rows in the same transaction. The targets `pending`, `partial` and `paid` are refused as transitions | +| C4-1b | ap: `POST /api/v1/ap/payments` | The payment applied to any invoice whatever its status (a PENDING bill was paid), read each invoice unlocked in the request's order, and posted through `gl.SyncVendorPayment`, which resolved its account ids by name and swallowed the journal insert's error | The payment act of ADR 0008 7.4, its request shape unchanged until the payment routes' own conversion (float `amount`, uppercase methods, `payment_date`): the named bills are locked in id order (section 9 step 5a) and the money applies in the order the request named them; only an `approved` or `partial` bill takes money (a pending, paid or voided bill is a 409 `invalid_state_transition` with the `invoice_not_approved` blocker, and a bill of another vendor a 400 naming `invoice_ids[i]`); more than the named bills still owe is a 409 with the `exceeds_open` blocker and writes nothing. The entry posts through `gl.PostEntry` inside the act's transaction (DR `2010` / CR `1010` for the applied sum) and a posting failure fails the act (a closed period a 409 `period_closed`). Each settled bill writes `vendor_invoice.partial` or `vendor_invoice.paid` and the payment an audit row, in the same transaction | +| C4-1b | ap: `GET /api/v1/ap/payments`, `GET /api/v1/ap/aging` | The payments list silently dropped an unparseable `vendor_id`; the aging buckets were computed from `total - amount_paid` with no parameter guard | Their shapes stay as they are until the payment and aging routes' own conversion (ADR 0008 section 11): the strict query guard now applies (an unparseable `vendor_id` is a 400 naming it on the payments list; an unknown parameter is a 400 on both), `payment_date` reads `YYYY-MM-DD`, and the aging buckets come from `amount_open` (a paid or voided bill owes nothing anywhere) | +| C4-1b | migration 106 `ap_wire_contract` (operational, no route) | `vendor_invoices` had no revision, number, branch, currency or `amount_open`; `po_id` was a bare UUID column with no foreign key; `vendor_invoice_lines` had no `position`, purchase line, product or freight charge links and a scale 2 unit price; nothing was unique | The C4-1b migration of ADR 0008 section 12: `created_at` NOT NULL, `revision`, `number` (`AP-`, a sequence, backfilled in `(created_at, id)` order, the DEFAULT minting through the writer's transaction), `branch_id` (backfilled from the purchase order's branch when that column exists, else the default branch; a defaults trigger serves raw inserts), `currency` (from the `currency.default` setting), a status CHECK on today's values (lowercase spellings uppercased and unknown values become PENDING, each with a notice), `po_id` a real foreign key with orphans set null (notice), `UNIQUE (vendor_id, invoice_number)` after duplicates are suffixed `#2`, `#3` in `(created_at, id)` order (notice), `amount_open` (the total less paid, zero on a voided bill), `gl_entry_id`; the lines gain `position` (backfilled in `(created_at, id)` order), `purchase_order_line_id`, `product_id`, a `unit_price` widened to scale 4 and `po_freight_charge_id` (written from package C's freight rule on; today's lines are not linked, the positional pairing being the defect); the keyset indexes. The down reverses each step, keeps the suffixed duplicates, normalized statuses and nulled orphans (each valid old shape data), and refuses while any unit price is finer than cents | +| C4-1b | ap module scope and wiring (ADR 0002) | The AP routes sat behind `RequireRole` only, with no branch middleware, no outbox and no audit log; `SyncVendorInvoice` and `SyncVendorPayment` lived in `internal/gl` | The routes sit behind `scoped("admin", "owner", "finance")` with the branch middleware and the branch guard on the create's payload branch; the service carries the outbox writer, the audit logger and the database as transaction runner, wired in serve as every converted module is; the two broken sync functions are deleted from `internal/gl` (their only caller was AP), and `gl` gains the `2010` account code constant beside the sales postings' | +| C4-1b | goldens that follow | `ap` created a bill in float dollars and read it back; `ap_payments` scripted the 401 approve, the float create and payments that paid a pending bill | `ap` and `ap_payments` re-recorded on the converted routes: the create in cents, ten thousandths and a decimal string quantity, the 428 transition without a precondition, the approve through `/transitions` and its 409 on a second attempt, the events feed step, the partial and settling payments, the paid bill's further payment refused `invoice_not_approved`, the void with payments refused `has_payments`, the validation 400, the status filters and the pending void | From e4696fead4945cf74f25782d964ab71c115170b4 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 05:41:50 -0700 Subject: [PATCH 04/32] desk: the AP page on the converted vendor invoice routes The service walks the invoice list's cursor to the last page; the types carry the wire's cents, ten thousandths, decimal string quantities, lowercase statuses, revision and both numbers (Gable's AP- and the vendor's own); the page approves through the transitions route with the revision it loaded, pays from amount_open_cents, and prices the draft total in exact integer arithmetic with the server's one rounding (half away from zero), so the preview never drifts a cent from the bill that is filed; the bill form sends cents, a decimal string quantity and ten thousandths with each line's gl_account_id. The tests assert the request bodies at the wire's scales beside the rendering, and the generated client schema follows the fragment. Signed-off-by: colton --- .../pages/accounting/AccountsPayable.test.ts | 175 +++++++--- .../src/pages/accounting/AccountsPayable.ts | 138 +++++--- web/apps/desk/src/services/APService.ts | 33 +- web/apps/desk/src/types/ap.ts | 81 +++-- web/packages/api-client/src/schema.d.ts | 319 ++++++++++++------ 5 files changed, 501 insertions(+), 245 deletions(-) diff --git a/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts b/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts index d61654dd9..97719fee0 100644 --- a/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts +++ b/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts @@ -4,18 +4,22 @@ /** * Accounts Payable UI. * - * This page straddles the ERP's money split, so the tests below pin both - * sides of it: + * The vendor invoice routes are on the wire contract (C4-1b, ADR 0008 7.4), + * so the tests below pin both sides of the money split: * - * read — every amount from /api/v1/ap/* is int64 CENTS - * (core/internal/ap/model.go), rendered with the shared - * `formatCents()`. - * write — POST /api/v1/ap/invoices and /api/v1/ap/payments take float64 - * DOLLARS (ap/model.go:100,109,116), which the service multiplies - * by 100 on the way in (ap/service.go:53,56,81,167). + * read — every invoice amount from /api/v1/ap/invoices is int64 CENTS and + * every line unit price int64 TEN THOUSANDTHS + * (core/internal/ap/model.go), rendered with `formatCents()` and + * `formatPrice4()`; the list arrives in the cursor envelope, which + * the service walks. + * write — POST /api/v1/ap/invoices takes cents, a decimal string quantity + * and ten thousandths; the approve goes through + * /ap/invoices/{id}/transitions with the revision; POST + * /api/v1/ap/payments still takes float dollars until its own + * conversion. * - * Getting that backwards in either direction is a 100x error on a vendor - * payment, so the request bodies are asserted, not just the rendering. + * Getting the scales backwards in either direction is a 100x error on a + * vendor payment, so the request bodies are asserted, not just the rendering. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import './AccountsPayable' @@ -25,20 +29,31 @@ import type { Vendor } from '../../types/vendor' import { mountAsync, text, jsonResponse, clickByText, update, flush, q } from '../../test/dom' const VENDOR_ID = '11111111-1111-1111-1111-111111111111' +const BRANCH_ID = '22222222-2222-4222-8222-222222222222' function invoice(overrides: Partial = {}): VendorInvoice { return { id: 'inv-1', + number: 'AP-000001', vendor_id: VENDOR_ID, vendor_name: 'Cascade Lumber Co', - invoice_number: 'CL-4471', + branch_id: BRANCH_ID, + vendor_invoice_number: 'CL-4471', + currency: 'USD', invoice_date: '2026-08-01', due_date: '2026-08-31', - subtotal: 1_200_000, - tax_amount: 34_567, - total: 1_234_567, // $12,345.67 - amount_paid: 0, - status: 'APPROVED', + po_id: null, + subtotal_cents: 1_200_000, + tax_cents: 34_567, + total_cents: 1_234_567, // $12,345.67 + amount_paid_cents: 0, + amount_open_cents: 1_234_567, + status: 'approved', + approved_by: null, + approved_at: null, + notes: '', + revision: 1, + gl_entry_id: null, created_at: '2026-08-01T10:00:00Z', ...overrides, } @@ -92,7 +107,11 @@ interface Fixture { /** Serve the five endpoints the page loads on connect. Returns the fetch spy. */ function serve(f: Fixture = {}) { const spy = vi.fn((url: string, _init?: RequestInit) => { - if (url.includes('/ap/invoices')) return Promise.resolve(jsonResponse(f.invoices ?? [])) + // The invoice list is the envelope; one invoice's own route serves the + // document itself. + const byId = f.invoices?.find((inv) => url.endsWith(`/ap/invoices/${inv.id}`)) + if (byId) return Promise.resolve(jsonResponse(byId)) + if (url.includes('/ap/invoices')) return Promise.resolve(jsonResponse({ items: f.invoices ?? [], next_cursor: null, limit: 100 })) if (url.includes('/ap/payments')) return Promise.resolve(jsonResponse(f.payments ?? [])) if (url.includes('/ap/aging')) return Promise.resolve(jsonResponse(f.agingSummary ?? [])) if (url.includes('/vendors')) return Promise.resolve(jsonResponse([vendor])) @@ -143,32 +162,39 @@ describe('accounts payable — reading cents', () => { expect(text(el)).not.toContain('$1,234,567.00') }) + it('shows the vendor own number and Gable number beside each other', async () => { + serve({ invoices: [invoice()] }) + const el = await mountAsync('gable-accounts-payable') + + expect(text(el)).toContain('CL-4471') + }) + it('groups thousands on a large outstanding balance', async () => { - serve({ invoices: [invoice({ total: 987_654_321_00, subtotal: 987_654_321_00, tax_amount: 0 })] }) + serve({ invoices: [invoice({ total_cents: 987_654_321_00, subtotal_cents: 987_654_321_00, tax_cents: 0, amount_open_cents: 987_654_321_00 })] }) const el = await mountAsync('gable-accounts-payable') expect(text(el)).toContain('$987,654,321.00') }) it('renders a zero amount paid as $0.00', async () => { - serve({ invoices: [invoice({ amount_paid: 0 })] }) + serve({ invoices: [invoice({ amount_paid_cents: 0 })] }) const el = await mountAsync('gable-accounts-payable') expect(text(el)).toContain('$0.00') }) - it('totals outstanding AP as invoice total less amount already paid', async () => { + it('totals outstanding AP from the open amounts', async () => { serve({ invoices: [ - invoice({ id: 'a', total: 1_000_000, amount_paid: 250_000, status: 'PARTIAL' }), - invoice({ id: 'b', total: 500_000, amount_paid: 0, status: 'APPROVED' }), - // PENDING is not yet an obligation; it must not count as outstanding. - invoice({ id: 'c', total: 900_000, amount_paid: 0, status: 'PENDING' }), + invoice({ id: 'a', total_cents: 1_000_000, amount_paid_cents: 250_000, amount_open_cents: 750_000, status: 'partial' }), + invoice({ id: 'b', total_cents: 500_000, amount_paid_cents: 0, amount_open_cents: 500_000, status: 'approved' }), + // pending is not yet an obligation; it must not count as outstanding. + invoice({ id: 'c', total_cents: 900_000, amount_paid_cents: 0, amount_open_cents: 900_000, status: 'pending' }), ], }) const el = await mountAsync('gable-accounts-payable') - // (1,000,000 - 250,000) + 500,000 = 1,250,000 cents. + // 750,000 + 500,000 = 1,250,000 cents. expect(text(el)).toContain('$12,500.00') // ...and the pending bill is reported separately, at its full value. expect(text(el)).toContain('$9,000.00') @@ -226,9 +252,24 @@ describe('accounts payable — reading cents', () => { expect(text(el)).toContain('$7,388.07') expect(text(el)).toContain('10432') }) + + it('renders a line unit price at four decimals in the bill details', async () => { + serve({ invoices: [invoice({ lines: [ + // 10 at $73.8813: the server's one rounding makes the line $7.39 + { id: 'l1', position: 0, description: '2x4 SPF', quantity: '10', unit_price_ten_thousandths: 738813, line_total_cents: 739, + gl_account_id: null, purchase_order_line_id: null, product_id: null, po_freight_charge_id: null, created_at: '2026-08-01T10:00:00Z' }, + ] })] }) + const el = await mountAsync('gable-accounts-payable') + + await (el as unknown as { _viewInvoiceDetails: (id: string) => Promise })._viewInvoiceDetails('inv-1') + await update(el, {}) + + expect(text(el)).toContain('$73.8813') + expect(text(el)).toContain('$7.39') + }) }) -describe('accounts payable — writing dollars', () => { +describe('accounts payable — writing the wire scales', () => { /** Open the bill drawer and fill one line plus tax. */ async function draftBill( el: LitElement, @@ -247,9 +288,10 @@ describe('accounts payable — writing dollars', () => { input.dispatchEvent(new Event('change', { bubbles: true })) } - // Two text inputs in this drawer: the invoice number, then the single - // line item's description. Both are `required` and the submit handler - // rejects a blank description, so the draft is not submittable without it. + // Two text inputs in this drawer: the vendor invoice number, then the + // single line item's description. Both are `required` and the submit + // handler rejects a blank description, so the draft is not submittable + // without it. const textInputs = Array.from(el.querySelectorAll('input[type="text"]')) setText(textInputs[0], 'CL-9001') setText(textInputs[1], opts.description ?? '2x4 SPF #2') @@ -275,41 +317,35 @@ describe('accounts payable — writing dollars', () => { it('keeps the preview to two decimal places when the arithmetic does not divide evenly', async () => { serve() const el = await mountAsync('gable-accounts-payable') - // 3 x $1.005. Before the preview was computed in cents it rendered - // "$3.015" — three decimal places on a money field, because - // `toLocaleString` defaults `maximumFractionDigits` to 3. + // 3 x $1.005 = $3.015, which rounds once, half away from zero, to $3.02: + // the preview and the server's exact decimal arithmetic agree, where a + // float preview (3 * 1.005 = 3.014999...) would show $3.01 and drift a + // cent from the bill that is filed. await draftBill(el, { qty: '3', unitPrice: '1.005', tax: '0' }) const body = text(el) expect(body).not.toMatch(/\$\d+\.\d{3}/) - - // $3.01, not the $3.02 exact decimal arithmetic would give: 1.005 is not - // representable in binary floating point and lands just below, so - // 3 * 1.005 * 100 = 301.49999999999994 and rounds down. The backend - // computes the stored figure the same way — `int64(UnitPrice*Quantity*100 - // + 0.5)` at ap/service.go:53 gives int64(301.99999999999994) = 301 — so - // the preview and the saved invoice agree. Pinned because they must keep - // agreeing: whichever side is "fixed" alone would introduce a drift - // between what the user is shown and what is filed. - expect(body).toContain('$3.01') + expect(body).toContain('$3.02') }) - it('sends unit price and tax as dollars, the units the endpoint documents', async () => { + it('sends cents, a decimal string quantity and ten thousandths, the units the endpoint documents', async () => { const spy = serve() const el = await mountAsync('gable-accounts-payable') await draftBill(el, { qty: '10', unitPrice: '73.88', tax: '12.34' }) await submitOpenForm(el) - const bill = postedBodies(spy).find((b) => 'invoice_number' in b) + const bill = postedBodies(spy).find((b) => 'vendor_invoice_number' in b) expect(bill).toBeDefined() - // Dollars, NOT 7388 / 1234 cents — ap/service.go multiplies by 100 itself, - // so sending cents here would store a bill 100x too large. - expect(bill!.tax_amount).toBe(12.34) - expect((bill!.lines as { unit_price: number }[])[0].unit_price).toBe(73.88) + // Cents and ten thousandths, NOT dollars — sending dollars here would + // store a bill 100x (and a price 10000x) too small. + expect(bill!.tax_cents).toBe(1234) + expect((bill!.lines as { quantity: string; unit_price_ten_thousandths: number }[])[0].quantity).toBe('10') + expect((bill!.lines as { quantity: string; unit_price_ten_thousandths: number }[])[0].unit_price_ten_thousandths).toBe(738800) + expect(bill!.vendor_invoice_number).toBe('CL-9001') }) it('converts the selected invoices to dollars when prefilling a payment', async () => { - const spy = serve({ invoices: [invoice({ total: 1_234_567, amount_paid: 0 })] }) + const spy = serve({ invoices: [invoice({ amount_open_cents: 1_234_567 })] }) const el = await mountAsync('gable-accounts-payable') await clickByText(el, 'button', 'Record Payment') @@ -327,7 +363,8 @@ describe('accounts payable — writing dollars', () => { const pmt = postedBodies(spy).find((b) => 'invoice_ids' in b) expect(pmt).toBeDefined() - // 1,234,567 cents = $12,345.67 — dollars on the wire. + // The payment route still takes float dollars until its own conversion: + // 1,234,567 cents = $12,345.67. expect(pmt!.amount).toBe(12345.67) }) }) @@ -343,15 +380,41 @@ describe('accounts payable — endpoints', () => { expect(urls.some((u) => u.includes('/api/v1/ap/aging'))).toBe(true) }) - it('approves an invoice through the approve endpoint', async () => { - const spy = serve({ invoices: [invoice({ status: 'PENDING' })] }) + it('walks the invoice list cursor to the last page', async () => { + const page1 = { items: [invoice({ id: 'inv-1' })], next_cursor: 'next', limit: 100 } + const page2 = { items: [invoice({ id: 'inv-2', number: 'AP-000002' })], next_cursor: null, limit: 100 } + let calls = 0 + const spy = vi.fn((url: string) => { + if (url.includes('/ap/invoices')) { + calls += 1 + return Promise.resolve(jsonResponse(calls === 1 ? page1 : page2)) + } + if (url.includes('/ap/payments')) return Promise.resolve(jsonResponse([])) + if (url.includes('/ap/aging')) return Promise.resolve(jsonResponse([])) + if (url.includes('/vendors')) return Promise.resolve(jsonResponse([vendor])) + if (url.includes('/gl/accounts')) return Promise.resolve(jsonResponse([])) + return Promise.resolve(jsonResponse([])) + }) + vi.stubGlobal('fetch', spy) + + const el = await mountAsync('gable-accounts-payable') + + const listUrl = spy.mock.calls.map((c) => String(c[0])).filter((u) => u.includes('/ap/invoices') && u.includes('cursor=next')) + expect(listUrl, 'the second page is fetched with the first cursor').toHaveLength(1) + expect(text(el)).toContain('CL-4471') // both pages' vendor numbers render + vi.unstubAllGlobals() + }) + + it('approves an invoice through the transitions route with its revision', async () => { + const spy = serve({ invoices: [invoice({ status: 'pending' })] }) const el = await mountAsync('gable-accounts-payable') await clickByText(el, 'button', 'Approve') - const approved = spy.mock.calls.find((c) => String(c[0]).includes('/approve')) - expect(approved, 'expected a POST to /api/v1/ap/invoices/{id}/approve').toBeDefined() - expect(String(approved![0])).toContain('/api/v1/ap/invoices/inv-1/approve') - expect(approved![1]?.method).toBe('POST') + const approve = spy.mock.calls.find((c) => String(c[0]).includes('/transitions')) + expect(approve, 'expected a POST to /api/v1/ap/invoices/{id}/transitions').toBeDefined() + expect(String(approve![0])).toContain('/api/v1/ap/invoices/inv-1/transitions') + expect(approve![1]?.method).toBe('POST') + expect(JSON.parse(approve![1]!.body as string)).toEqual({ to: 'approved', revision: 1 }) }) }) diff --git a/web/apps/desk/src/pages/accounting/AccountsPayable.ts b/web/apps/desk/src/pages/accounting/AccountsPayable.ts index cf27ee334..b3fb2b6a2 100644 --- a/web/apps/desk/src/pages/accounting/AccountsPayable.ts +++ b/web/apps/desk/src/pages/accounting/AccountsPayable.ts @@ -4,7 +4,7 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; -import { formatCents } from '../../lib/utils'; +import { formatCents, formatPrice4 } from '../../lib/utils'; import { ToastService } from '../../lib/toast-service.ts'; import { APService } from '../../services/APService.ts'; import { VendorService } from '../../services/VendorService.ts'; @@ -16,6 +16,50 @@ import { CheckCircle2, PlusCircle, Trash2, Receipt, Info } from 'lucide'; +/** + * One line's extension in cents, exact: the decimal strings are read as + * scaled integers (quantity and price at scale 4 each), multiplied, and + * rounded once to cents, half away from zero, the way the server's + * httpx.Extend prices the line. Never parseFloat: 1.005 is not representable + * and a float preview can disagree with the exact bill that is filed. + */ +function exactLineCents(qty: string, price: string): number { + const scaled = (raw: string): bigint => { + const t = raw.trim(); + if (t === '') return 0n; + const neg = t.startsWith('-'); + const body = neg ? t.slice(1) : t; + const [int = '0', frac = ''] = body.split('.'); + const frac4 = (frac + '0000').slice(0, 4); + const v = BigInt(int || '0') * 10000n + BigInt(frac4 || '0'); + return neg ? -v : v; + }; + const product = scaled(qty) * scaled(price); // scale 8 + const neg = product < 0n; + const abs = neg ? -product : product; + const cents = (abs + 500_000n) / 1_000_000n; // one rounding, half away from zero + return Number(neg ? -cents : cents); +} + +/** The decimal string with trailing fraction zeros dropped, the wire's form. */ +function trimDecimal(raw: string): string { + const t = raw.trim(); + if (!t.includes('.')) return t; + return t.replace(/0+$/, '').replace(/\.$/, ''); +} + +/** A decimal string read as the scaled integer the wire carries (scale 4). */ +function scaledTenThousandths(raw: string): bigint { + const t = raw.trim(); + if (t === '') return 0n; + const neg = t.startsWith('-'); + const body = neg ? t.slice(1) : t; + const [int = '0', frac = ''] = body.split('.'); + const frac4 = (frac + '0000').slice(0, 4); + const v = BigInt(int || '0') * 10000n + BigInt(frac4 || '0'); + return neg ? -v : v; +} + @customElement('gable-accounts-payable') export class AccountsPayable extends LitElement { createRenderRoot() { return this; } @@ -89,35 +133,33 @@ export class AccountsPayable extends LitElement { } /** - * Draft-invoice total in cents, for the "Estimated Total" preview. + * Draft-bill total in cents, for the "Estimated Total" preview. * - * The bill form is denominated in DOLLARS because that is what - * POST /api/v1/ap/invoices takes (ap/model.go:100,109 — `TaxAmount` and - * `UnitPrice` are float64 dollars). The preview is computed here the same - * way the server computes the stored figure — rounding each line to a cent - * individually and adding tax separately (ap/service.go:53,56) — rather - * than summing floats and formatting at the end. Summing first can leave - * the preview a cent away from the invoice that is actually saved. + * The form is denominated in dollars for the clerk; the route takes a + * decimal string quantity and ten thousandths (C4-1b, ADR 0008 7.4) and + * the server prices each line exactly with one rounding to cents, half + * away from zero (httpx.Extend). The preview does the same arithmetic in + * integers, so it never lands a cent away from the bill that is saved. */ private get _estimatedTotalCents(): number { const lines = this.billLines.reduce( - (sum, l) => sum + Math.round(Number(l.quantity) * Number(l.unit_price) * 100), + (sum, l) => sum + exactLineCents(String(l.quantity), String(l.unit_price)), 0, ); - return lines + Math.round(Number(this.billTaxAmount) * 100); + return lines + exactLineCents('1', String(this.billTaxAmount)); } private _statusBadgeClass(status: string): string { switch (status) { - case 'PENDING': + case 'pending': return 'bg-amber-500/10 text-amber-400 border border-amber-500/20'; - case 'APPROVED': + case 'approved': return 'bg-blue-500/10 text-blue-400 border border-blue-500/20'; - case 'PARTIAL': + case 'partial': return 'bg-purple-500/10 text-purple-400 border border-purple-500/20'; - case 'PAID': + case 'paid': return 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'; - case 'VOIDED': + case 'voided': return 'bg-zinc-800 text-zinc-500 border border-zinc-700'; default: return 'bg-zinc-800 text-zinc-400'; @@ -173,16 +215,16 @@ export class AccountsPayable extends LitElement { try { const req = { vendor_id: this.billVendorId, - invoice_number: this.billInvoiceNumber.trim(), + vendor_invoice_number: this.billInvoiceNumber.trim(), invoice_date: this.billInvoiceDate, due_date: this.billDueDate, - tax_amount: Number(this.billTaxAmount), + tax_cents: exactLineCents('1', String(this.billTaxAmount)), notes: this.billNotes.trim(), lines: this.billLines.map(l => ({ description: l.description.trim(), - quantity: Number(l.quantity), - unit_price: Number(l.unit_price), - gl_account_id: l.gl_account_id || undefined + quantity: trimDecimal(String(l.quantity)), + unit_price_ten_thousandths: Number(scaledTenThousandths(String(l.unit_price))), + gl_account_id: l.gl_account_id })) }; @@ -213,9 +255,9 @@ export class AccountsPayable extends LitElement { this.vendorOutstandingInvoices = []; return; } - // Invoices that are APPROVED or PARTIAL are outstanding + // Invoices that are approved or partial are outstanding this.vendorOutstandingInvoices = this.invoices.filter( - inv => inv.vendor_id === this.payVendorId && (inv.status === 'APPROVED' || inv.status === 'PARTIAL') + inv => inv.vendor_id === this.payVendorId && (inv.status === 'approved' || inv.status === 'partial') ); } @@ -231,7 +273,7 @@ export class AccountsPayable extends LitElement { for (const invId of this.paySelectedInvoiceIds) { const inv = this.invoices.find(i => i.id === invId); if (inv) { - sum += (inv.total - inv.amount_paid); + sum += inv.amount_open_cents; } } this.payAmount = Number((sum / 100).toFixed(2)); @@ -270,9 +312,9 @@ export class AccountsPayable extends LitElement { } } - private async _handleApproveInvoice(invoiceId: string) { + private async _handleApproveInvoice(invoice: VendorInvoice) { try { - await APService.approveVendorInvoice(invoiceId); + await APService.transitionVendorInvoice(invoice.id, { to: 'approved', revision: invoice.revision }); ToastService.show('Vendor invoice approved and posted to General Ledger', 'success'); if (this.selectedInvoice && this.selectedInvoice.id === invoiceId) { this.selectedInvoice = await APService.getVendorInvoice(invoiceId); @@ -307,14 +349,14 @@ export class AccountsPayable extends LitElement { // Summary Statistics const totalOutstanding = this.invoices - .filter(i => i.status === 'APPROVED' || i.status === 'PARTIAL') - .reduce((sum, i) => sum + (i.total - i.amount_paid), 0); + .filter(i => i.status === 'approved' || i.status === 'partial') + .reduce((sum, i) => sum + i.amount_open_cents, 0); const pendingApproval = this.invoices - .filter(i => i.status === 'PENDING') - .reduce((sum, i) => sum + i.total, 0); + .filter(i => i.status === 'pending') + .reduce((sum, i) => sum + i.total_cents, 0); - const pendingApprovalCount = this.invoices.filter(i => i.status === 'PENDING').length; + const pendingApprovalCount = this.invoices.filter(i => i.status === 'pending').length; // Paid Month-To-Date const currentMonth = new Date().getMonth(); @@ -477,23 +519,23 @@ export class AccountsPayable extends LitElement { ${inv.vendor_name || 'Seeded Vendor'} ${new Date(inv.invoice_date).toLocaleDateString('en-US', { timeZone: 'UTC' })} ${new Date(inv.due_date).toLocaleDateString('en-US', { timeZone: 'UTC' })} - ${formatCents(inv.total)} - ${formatCents(inv.amount_paid)} + ${formatCents(inv.total_cents)} + ${formatCents(inv.amount_paid_cents)} ${inv.status} - ${inv.status === 'PENDING' ? html` + ${inv.status === 'pending' ? html`