Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion src/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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`);
}
Expand Down
77 changes: 75 additions & 2 deletions tests/fixtures.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]
});
Expand Down Expand Up @@ -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;
Expand Down