/// /** * Behavioral coverage for 0017_document_total_line_invariant -- the storage * tie-out between a document's denormalized header totals and its own lines. * * Before this migration a header that ignored a negative retainage line was * silently accepted and then frozen at posting. The ledger, projected from * the lines, remained correct while header-driven lists and reports did not. * * This suite keeps one rejection and one valid control: the historical gross * header is rejected after the complete line shape exists; the correct net * header commits, posts through the real kernel, and ties to the posted open * item. Like every DB-backed suite it self-skips without OPENBOOKS_DB_URL. */ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import test from "node:test"; import { sql } from "drizzle-orm"; /** Drizzle wraps driver errors (DrizzleQueryError), hiding the PostgreSQL * message in `cause`; match the whole rendered chain so a trigger rejection * stays assertable. */ function pgMessage(error: unknown): string { const cause = (error as { cause?: unknown }).cause; return `${String(error)}\n${cause === undefined ? "" : String(cause)}`; } const DB = Boolean(process.env.OPENBOOKS_DB_URL); type EngineDb = typeof import("@openbooks/engine/platform/db"); type EnginePosting = typeof import("@openbooks/engine/ledger/posting-document"); type EngineFixtures = typeof import("@openbooks/engine/testing/fixtures"); type Tx = Parameters[0]>[0]; type Harness = { db: EngineDb["db"]; postDocument: EnginePosting["postDocument"]; org: Awaited>; retainageAccountId: string; }; let harness: Harness | null = null; async function ctx(): Promise { if (!harness) { const [{ db }, { postDocument }, { createScratchOrg }] = await Promise.all([ import("@openbooks/engine/platform/db"), import("@openbooks/engine/ledger/posting-document"), import("@openbooks/engine/testing/fixtures"), ]); const org = await createScratchOrg(); harness = { db, postDocument, org, retainageAccountId: randomUUID() }; } // The tenant fixture is pooled and reset between files, which drops any // account this suite added. Re-assert it on every entry rather than only on // first construction, or the second test inserts a line referencing an // account_id the reset already removed and trips document_lines_account_id_fkey. await harness.db.execute(sql` insert into accounts (id, org_id, number, name, type, is_summary, is_active, eliminate, reconcilable, required_dimensions, custom, subsidiary_include_children) values (${harness.retainageAccountId}, ${harness.org.orgId}, '1150', 'Retainage Receivable', 'asset_current_other', false, true, false, false, '[]'::jsonb, '{}'::jsonb, true) on conflict (id) do nothing`); return harness; } /** A progress invoice: gross income plus negative retained holdback. */ function progressInvoiceLineSql( h: Harness, documentId: string, actor: string, ): ReturnType[] { return [ sql`insert into document_lines (org_id, document_id, line_number, account_id, description, quantity, unit_price, amount, created_by) values (${h.org.orgId}, ${documentId}, 1, ${h.org.accounts.revenue}, 'Sov work completed', '1', '10000.0000', '10000.0000', ${actor})`, sql`insert into document_lines (org_id, document_id, line_number, account_id, description, quantity, unit_price, amount, created_by) values (${h.org.orgId}, ${documentId}, 2, ${h.retainageAccountId}, 'Less retainage held', '1', '-1000.0000', '-1000.0000', ${actor})`, ]; } async function insertProgressInvoice( h: Harness, tx: Tx, subtotal: string, total: string, ): Promise { const id = randomUUID(); const actor = randomUUID(); await tx.execute(sql` insert into documents (id, org_id, kind, status, document_number, party_id, document_date, currency, subtotal, tax_total, total, created_by) values (${id}, ${h.org.orgId}, 'customer_invoice', 'draft', ${`INV-${id.slice(0, 8)}`}, ${h.org.customerId}, ${h.org.date}, 'CAD', '0', '0', '0', ${actor})`); for (const line of progressInvoiceLineSql(h, id, actor)) await tx.execute(line); // Match the real construction writer's ordering: draft header, lines, then // the list header. The storage line trigger has already derived the net // value; a writer that now asserts the old gross value must be rejected. await tx.execute(sql` update documents set subtotal = ${subtotal}, tax_total = '0', total = ${total} where id = ${id} and org_id = ${h.org.orgId}`); return id; } test( "a header that ignores the retainage line is rejected at commit, not stored", { skip: !DB, timeout: 120_000 }, async () => { const h = await ctx(); await assert.rejects( () => h.db.transaction(async (tx) => { await insertProgressInvoice(h, tx, "10000.0000", "10000.0000"); }), (error: unknown) => pgMessage(error).includes("header totals do not tie to its lines"), ); const residue = await h.db.execute<{ n: number }>(sql` select count(*)::int as n from documents where org_id = ${h.org.orgId} and kind = 'customer_invoice'`); assert.equal(residue.rows[0]!.n, 0); }, ); test( "the correct net header commits, posts through the kernel, and ties to the open item", { skip: !DB, timeout: 120_000 }, async () => { const h = await ctx(); const invoiceId = await h.db.transaction((tx) => insertProgressInvoice(h, tx, "9000.0000", "9000.0000"), ); await h.db.execute(sql` update documents set status = 'approved' where id = ${invoiceId} and org_id = ${h.org.orgId}`); const entryId = await h.postDocument(invoiceId, { control: { ar: h.org.accounts.ar, ap: h.org.accounts.ap, bank: h.org.accounts.bank, }, }); const doc = await h.db.execute<{ subtotal: string; tax_total: string; total: string; }>(sql` select subtotal::text as subtotal, tax_total::text as tax_total, total::text as total from documents where id = ${invoiceId} and org_id = ${h.org.orgId}`); assert.equal(doc.rows[0]!.subtotal, "9000.0000"); assert.equal(doc.rows[0]!.tax_total, "0.0000"); assert.equal(doc.rows[0]!.total, "9000.0000"); const entry = await h.db.execute<{ signed_sum: string; oi_sum: string; debit_sum: string; retainage_debit: string; income_credit: string; }>(sql` select coalesce(sum(jl.amount), 0)::text as signed_sum, coalesce(sum(jl.amount) filter (where jl.is_open_item), 0)::text as oi_sum, coalesce(sum(jl.amount) filter (where jl.amount > 0), 0)::text as debit_sum, coalesce(sum(jl.amount) filter (where jl.account_id = ${h.retainageAccountId} and jl.amount > 0), 0)::text as retainage_debit, coalesce(sum(jl.amount) filter (where jl.account_id = ${h.org.accounts.revenue}), 0)::text as income_credit from journal_lines jl where jl.entry_id = ${entryId} and jl.org_id = ${h.org.orgId}`); const leg = entry.rows[0]!; assert.equal(BigInt(leg.signed_sum.replace(".", "")), 0n); assert.equal(leg.retainage_debit, "1000.0000"); assert.equal(leg.income_credit, "-10000.0000"); // For an open-item document, total is the collectible AR/AP leg. The // debit-side sum is gross (AR + retained receivable), so it is deliberately // not the net header total for a retainage invoice. assert.equal(leg.oi_sum, doc.rows[0]!.total); assert.notEqual(leg.debit_sum, leg.oi_sum); }, ); if (DB) { test.after(async () => { if (!harness) return; const { dropScratchOrg } = await import("@openbooks/engine/testing/fixtures"); await dropScratchOrg(harness.org.orgId); harness = null; }); }