fix(api): replace invoice lines in a single transaction

This commit is contained in:
Adam Moussa 2026-09-22 13:20:52 -04:00
parent 280014e89d
commit 2d3c3cb099
No known key found for this signature in database
3 changed files with 57 additions and 10 deletions

View file

@ -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<typeof vi.fn> };
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 });

View file

@ -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) });
});

View file

@ -233,6 +233,22 @@ export function createFakeDb(store: Store = emptyStore()): Db {
rows.splice(0, rows.length);
}),
})),
transaction: vi.fn(async (callback: (tx: never) => Promise<unknown>) => {
const snapshot = structuredClone(store) as Store;
try {
return await callback(db as never);
} catch (error) {
(Object.keys(store) as Array<keyof Store>).forEach((key) => {
const rows = store[key] as unknown as Array<Record<string, unknown>>;
rows.splice(
0,
rows.length,
...(snapshot[key] as unknown as Array<Record<string, unknown>>),
);
});
throw error;
}
}),
};
return {