diff --git a/README.md b/README.md index 07c412d..522ec60 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,13 @@ require non-empty `payment_id`, `invoice_id`, and `vendor_id`, a finite `amount`, a `paid_at` calendar date, and a method of `ach`, `wire`, or `instant`. Categories are free-form non-empty strings. +Each invoice `vendor_id` must identify a vendor in `vendors.json`. Each payment +must identify an existing invoice and vendor, and its vendor must match the +vendor on that invoice. Vendor, invoice, and payment identifiers are unique; +duplicates are rejected rather than resolved by first/last-row precedence. +Relationship and duplicate errors report the source row and field so custom +fixtures can be corrected deterministically. + Dates use strict `YYYY-MM-DD` calendar values, so impossible dates such as `2026-02-30` are rejected. Invalid shapes and fields throw `LedgerpetError` with code `INVALID_FIXTURE_SCHEMA` and a row plus field diagnostic; invalid diff --git a/src/fixtures.js b/src/fixtures.js index 82940e4..0882486 100644 --- a/src/fixtures.js +++ b/src/fixtures.js @@ -32,7 +32,9 @@ export function normalizeFixture(fixture, sources = {}) { amount: parseAmount(payment.amount, sources.payments ?? "payments", index), synthetic: true })); - return { ...fixture, vendors, invoices, payments }; + const normalized = { ...fixture, vendors, invoices, payments }; + validateRelationships(normalized, sources); + return normalized; } export function validateFixture(fixture, sources = {}) { @@ -65,9 +67,41 @@ export function validateFixture(fixture, sources = {}) { requireText(row, "method", location); if (!PAYMENT_METHODS.has(row.method)) fail(`${location} field method must be one of: ach, wire, instant`); }); + return fixture; } +function validateRelationships(fixture, sources) { + const vendorsById = uniqueRows(fixture.vendors, "vendor_id", sources.vendors ?? "vendors", 1); + const invoicesById = uniqueRows(fixture.invoices, "invoice_id", sources.invoices ?? "invoices", 2); + uniqueRows(fixture.payments, "payment_id", sources.payments ?? "payments", 2); + + fixture.invoices.forEach((row, index) => { + if (!vendorsById.has(row.vendor_id)) { + fail(`${sources.invoices ?? "invoices"}:${index + 2} field vendor_id references unknown vendor ${row.vendor_id}`); + } + }); + fixture.payments.forEach((row, index) => { + const location = `${sources.payments ?? "payments"}:${index + 2}`; + const invoice = invoicesById.get(row.invoice_id); + if (!invoice) fail(`${location} field invoice_id references unknown invoice ${row.invoice_id}`); + if (!vendorsById.has(row.vendor_id)) fail(`${location} field vendor_id references unknown vendor ${row.vendor_id}`); + if (row.vendor_id !== invoice.vendor_id) { + fail(`${location} field vendor_id ${row.vendor_id} does not match invoice ${row.invoice_id} vendor_id ${invoice.vendor_id}`); + } + }); +} + +function uniqueRows(rows, field, source, firstRow) { + const byId = new Map(); + rows.forEach((row, index) => { + const value = row[field]; + if (byId.has(value)) fail(`${source}:${index + firstRow} field ${field} duplicates ${value}`); + byId.set(value, row); + }); + return byId; +} + function requireCollection(fixture, field, source) { if (!Array.isArray(fixture[field])) fail(`${source} field ${field} must be an array`); } diff --git a/tests/fixtures.test.js b/tests/fixtures.test.js index d1f36c3..04f7cc4 100644 --- a/tests/fixtures.test.js +++ b/tests/fixtures.test.js @@ -42,7 +42,7 @@ test("loadFixture identifies non-finite invoice and payment amounts", async () = test("normalizeFixture accepts decimal and zero amounts", () => { const fixture = normalizeFixture({ metadata: { name: "test" }, - vendors: [], + vendors: [vendor("VEN-1")], invoices: [{ invoice_id: "INV-1", vendor_id: "VEN-1", issued_at: "2026-01-01", due_date: "2026-01-02", amount: "12.50", category: "test" }], payments: [{ payment_id: "PAY-1", invoice_id: "INV-1", vendor_id: "VEN-1", paid_at: "2026-01-02", amount: "0", method: "ach" }] }); @@ -88,10 +88,83 @@ test("normalizeFixture rejects unsupported payment methods", () => { ); }); +test("normalizeFixture enforces unique identifiers with row and field diagnostics", () => { + for (const [collection, duplicate, message] of [ + ["vendors", vendor("VEN-1"), "vendors:2 field vendor_id duplicates VEN-1"], + ["invoices", invoice("INV-1", "VEN-1"), "invoices:3 field invoice_id duplicates INV-1"], + ["payments", payment("PAY-1", "INV-1", "VEN-1"), "payments:3 field payment_id duplicates PAY-1"] + ]) { + const fixture = validFixture(); + fixture[collection].push(duplicate); + assert.throws(() => normalizeFixture(fixture), invalidSchema(message)); + } +}); + +test("normalizeFixture rejects invoices referencing unknown vendors", () => { + const fixture = validFixture(); + fixture.invoices[0].vendor_id = "VEN-MISSING"; + assert.throws( + () => normalizeFixture(fixture), + invalidSchema("invoices:2 field vendor_id references unknown vendor VEN-MISSING") + ); +}); + +test("normalizeFixture rejects payments referencing unknown invoices or vendors", () => { + for (const [field, value, message] of [ + ["invoice_id", "INV-MISSING", "payments:2 field invoice_id references unknown invoice INV-MISSING"], + ["vendor_id", "VEN-MISSING", "payments:2 field vendor_id references unknown vendor VEN-MISSING"] + ]) { + const fixture = validFixture(); + fixture.payments[0][field] = value; + assert.throws(() => normalizeFixture(fixture), invalidSchema(message)); + } +}); + +test("normalizeFixture rejects payment vendors that differ from their invoice", () => { + const fixture = validFixture(); + fixture.vendors.push(vendor("VEN-2")); + fixture.payments[0].vendor_id = "VEN-2"; + assert.throws( + () => normalizeFixture(fixture), + invalidSchema("payments:2 field vendor_id VEN-2 does not match invoice INV-1 vendor_id VEN-1") + ); +}); + +test("normalizeFixture accepts a complete relationship graph", () => { + const fixture = normalizeFixture(validFixture()); + assert.equal(fixture.invoices[0].vendor_id, fixture.vendors[0].vendor_id); + assert.equal(fixture.payments[0].vendor_id, fixture.invoices[0].vendor_id); +}); + +function validFixture() { + return { + metadata: { name: "test" }, + vendors: [vendor("VEN-1")], + invoices: [invoice("INV-1", "VEN-1")], + payments: [payment("PAY-1", "INV-1", "VEN-1")] + }; +} + +function vendor(vendorId) { + return { vendor_id: vendorId, name: "Vendor", category: "test", bank_account_last4: "1234" }; +} + +function invoice(invoiceId, vendorId) { + return { invoice_id: invoiceId, vendor_id: vendorId, issued_at: "2026-01-01", due_date: "2026-01-02", amount: "12.50", category: "test" }; +} + +function payment(paymentId, invoiceId, vendorId) { + return { payment_id: paymentId, invoice_id: invoiceId, vendor_id: vendorId, paid_at: "2026-01-02", amount: "12.50", method: "ach" }; +} + +function invalidSchema(message) { + return (error) => error.code === "INVALID_FIXTURE_SCHEMA" && error.message === message; +} + async function makeFixture() { const dir = await mkdtemp(join(tmpdir(), "ledgerpet-amount-")); await writeFile(join(dir, "metadata.json"), JSON.stringify({ name: "test", watermark: SYNTHETIC_WATERMARK })); - await writeFile(join(dir, "vendors.json"), "[]"); + await writeFile(join(dir, "vendors.json"), JSON.stringify([vendor("VEN-1")])); await writeFile(join(dir, "invoices.csv"), "invoice_id,vendor_id,issued_at,due_date,amount,category\n"); await writeFile(join(dir, "payments.csv"), "payment_id,invoice_id,vendor_id,paid_at,amount,method\n"); return dir;