From 2d3c3cb0991fc5e11d7f1674441877d79bd7340d Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:20:52 -0400 Subject: [PATCH] fix(api): replace invoice lines in a single transaction --- packages/api/src/routes/invoices.test.ts | 33 ++++++++++++++++++++++-- packages/api/src/routes/invoices.ts | 18 +++++++------ packages/api/src/test/fake-db.ts | 16 ++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts index 23489a4..2292c40 100644 --- a/packages/api/src/routes/invoices.test.ts +++ b/packages/api/src/routes/invoices.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createApp } from "../app.js"; import { createMemoryDocumentsStore } from "../documents.js"; import { loadEnv } from "../env.js"; import type { ErrorEnvelope } from "../http.js"; -import { createFakeDb, SEED } from "../test/fake-db.js"; +import { createFakeDb, emptyStore, SEED } from "../test/fake-db.js"; function expectEnvelope(body: unknown, code: string) { const envelope = body as ErrorEnvelope; @@ -114,6 +114,35 @@ describe("invoice stubs", () => { expect(body.items).toHaveLength(2); }); + it("keeps existing lines when a replace insert fails", async () => { + const store = emptyStore(); + const handle = createFakeDb(store); + const db = handle.db as { insert: ReturnType }; + db.insert.mockImplementation(() => ({ + values: () => ({ + returning: async () => { + throw new Error("insert failed"); + }, + }), + })); + const app = createApp(envFor("admin"), handle, { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [ + { description: "Labor", amount: "1000.00" }, + { description: "Parts", amount: "250.00" }, + ], + }), + }); + expect(response.status).toBe(500); + expect(store.invoiceLines).toHaveLength(1); + expect(store.invoiceLines[0]?.id).toBe(SEED.line.id); + }); + it("presigns a document and confirms after upload", async () => { const documents = createMemoryDocumentsStore(); const app = createApp(envFor("admin"), createFakeDb(), { documents }); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index 1528ed9..488669c 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -379,15 +379,17 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { if (!linesSumToAmount(parsedLines, invoice.amount)) { return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); } - await handle.db.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id)); const created: LineRow[] = []; - for (const line of parsedLines) { - const [row] = await handle.db - .insert(invoiceLines) - .values({ invoiceId: id, ...line }) - .returning(); - created.push(row); - } + await handle.db.transaction(async (tx) => { + await tx.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id)); + for (const line of parsedLines) { + const [row] = await tx + .insert(invoiceLines) + .values({ invoiceId: id, ...line }) + .returning(); + created.push(row); + } + }); return c.json({ items: created.map(toLine) }); }); diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts index d18380f..1e5a843 100644 --- a/packages/api/src/test/fake-db.ts +++ b/packages/api/src/test/fake-db.ts @@ -233,6 +233,22 @@ export function createFakeDb(store: Store = emptyStore()): Db { rows.splice(0, rows.length); }), })), + transaction: vi.fn(async (callback: (tx: never) => Promise) => { + const snapshot = structuredClone(store) as Store; + try { + return await callback(db as never); + } catch (error) { + (Object.keys(store) as Array).forEach((key) => { + const rows = store[key] as unknown as Array>; + rows.splice( + 0, + rows.length, + ...(snapshot[key] as unknown as Array>), + ); + }); + throw error; + } + }), }; return {