From 963d48f1404774e64c7067970d075fecaf3b2e17 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:57:50 -0400 Subject: [PATCH] fix(api): stop PATCH from skipping the approval workflow --- packages/api/openapi/paths/invoices-id.yaml | 5 +++-- packages/api/src/routes/invoices.test.ts | 13 +++++++++++++ packages/api/src/routes/invoices.ts | 16 +++++++++------- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/api/openapi/paths/invoices-id.yaml b/packages/api/openapi/paths/invoices-id.yaml index a79fe43..ca7de24 100644 --- a/packages/api/openapi/paths/invoices-id.yaml +++ b/packages/api/openapi/paths/invoices-id.yaml @@ -64,8 +64,9 @@ patch: example: "2026-09-01" status: type: string - enum: [pending_approval, approved, scheduled, paid, rejected, void] - example: approved + enum: [void] + description: The only status PATCH may set. Approval and payment statuses go through decision routes. + example: void paymentMethod: type: string enum: [check, ach] diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts index 72f12de..fe84394 100644 --- a/packages/api/src/routes/invoices.test.ts +++ b/packages/api/src/routes/invoices.test.ts @@ -155,6 +155,19 @@ describe("invoice stubs", () => { expect(body.items).toHaveLength(2); }); + it("rejects PATCH that sets approved without an approval decision", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}`, { + method: "PATCH", + headers: jsonHeaders, + body: JSON.stringify({ status: "approved" }), + }); + expect(response.status).toBe(400); + expectEnvelope(await response.json(), "VALIDATION_ERROR"); + }); + it("keeps existing lines when a replace insert fails", async () => { const store = emptyStore(); const handle = createFakeDb(store); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index e37d933..ac76206 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -23,7 +23,6 @@ import { rowsOf, } from "./helpers.js"; -const STATUSES = ["pending_approval", "approved", "scheduled", "paid", "rejected", "void"] as const; const PAYMENT_METHODS = ["check", "ach"] as const; type InvoiceRow = typeof invoices.$inferSelect; @@ -37,10 +36,6 @@ type LineInput = { departmentId: string | null; }; -function isStatus(value: string): value is (typeof STATUSES)[number] { - return (STATUSES as readonly string[]).includes(value); -} - function isPaymentMethod(value: string): value is (typeof PAYMENT_METHODS)[number] { return (PAYMENT_METHODS as readonly string[]).includes(value); } @@ -305,8 +300,15 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { } if (body.status !== undefined) { const status = asString(body.status); - if (!isStatus(status)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid status."); - patch.status = status; + if (status !== "void") { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "PATCH may only set status to void. Approval and payment statuses go through decision routes.", + ); + } + patch.status = "void"; } if (body.paymentMethod !== undefined) { const method = asString(body.paymentMethod);