fix(api): create invoices and lines in one transaction

This commit is contained in:
Adam Moussa 2026-09-22 13:49:03 -04:00
parent 2d3c3cb099
commit 7e186e060b
No known key found for this signature in database
2 changed files with 70 additions and 21 deletions

View file

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

View file

@ -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) => {