From 7e186e060b6058f556b3643597a0d91fdbc39ed0 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:49:03 -0400 Subject: [PATCH] fix(api): create invoices and lines in one transaction --- packages/api/src/routes/invoices.test.ts | 41 +++++++++++++++++++ packages/api/src/routes/invoices.ts | 50 ++++++++++++++---------- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts index 2292c40..72f12de 100644 --- a/packages/api/src/routes/invoices.test.ts +++ b/packages/api/src/routes/invoices.test.ts @@ -1,3 +1,4 @@ +import { getTableName } from "drizzle-orm"; import { describe, expect, it, vi } from "vitest"; import { createApp } from "../app.js"; import { createMemoryDocumentsStore } from "../documents.js"; @@ -62,6 +63,46 @@ describe("invoice stubs", () => { }); }); + it("does not persist a partial invoice when a line insert fails", async () => { + const store = emptyStore(); + const handle = createFakeDb(store); + const db = handle.db as { insert: ReturnType }; + const originalInsert = db.insert.getMockImplementation() ?? db.insert; + db.insert.mockImplementation((table: unknown) => { + if (getTableName(table as never) === "invoice_lines") { + return { + values: () => ({ + returning: async () => { + throw new Error("insert failed"); + }, + }), + }; + } + return originalInsert(table); + }); + const app = createApp(envFor("admin"), handle, { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-2003", + amount: "100.00", + dueDate: "2026-10-01", + lines: [ + { description: "Half", amount: "40.00" }, + { description: "Rest", amount: "60.00" }, + ], + }), + }); + expect(response.status).toBe(500); + expect(store.invoices.map((row) => row.invoiceNumber)).toEqual(["INV-1001"]); + expect(store.invoiceLines).toHaveLength(1); + expect(store.invoiceLines[0]?.id).toBe(SEED.line.id); + }); + it("rejects a duplicate active vendor and invoice number", async () => { const app = createApp(envFor("admin"), createFakeDb(), { documents: createMemoryDocumentsStore(), diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index 488669c..e37d933 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -215,21 +215,33 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { "An active invoice already uses this vendor and invoice number.", ); } - let row: InvoiceRow; + let latest: InvoiceRow | undefined; + let currentLines: LineRow[] = []; try { - [row] = await handle.db - .insert(invoices) - .values({ - vendorId, - invoiceNumber, - amount, - amountDue: amount, - dueDate, - paymentMethod: method, - memo: asString(body.memo), - status: "pending_approval", - }) - .returning(); + await handle.db.transaction(async (tx) => { + const scoped = { ...handle, db: tx } as typeof handle; + const [row] = await tx + .insert(invoices) + .values({ + vendorId, + invoiceNumber, + amount, + amountDue: amount, + dueDate, + paymentMethod: method, + memo: asString(body.memo), + status: "pending_approval", + }) + .returning(); + for (const line of parsedLines) { + await tx + .insert(invoiceLines) + .values({ invoiceId: row.id, ...line }) + .returning(); + } + latest = await applyMatchingPolicy(scoped, row, caller(c).id); + currentLines = await linesFor(scoped, row.id); + }); } catch (error) { if (isUniqueViolation(error)) { return errorJson( @@ -241,14 +253,10 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { } throw error; } - for (const line of parsedLines) { - await handle.db - .insert(invoiceLines) - .values({ invoiceId: row.id, ...line }) - .returning(); + if (!latest) { + return errorJson(c, 500, "INTERNAL_ERROR", "Invoice create did not complete."); } - const latest = await applyMatchingPolicy(handle, row, caller(c).id); - return c.json(toInvoice(latest, await linesFor(handle, row.id)), 201); + return c.json(toInvoice(latest, currentLines), 201); }); routes.patch("/invoices/:id", async (c) => {