import { eq } from "drizzle-orm"; import { Hono } from "hono"; import type { Db, DbHandle } from "../db/client.js"; import type { DocumentsStore } from "../documents.js"; import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js"; import type { AppBindings } from "../auth/middleware.js"; import { errorJson } from "../http.js"; import { applyMatchingPolicy, closePendingSteps } from "../approvals.js"; import { asMoney, asString, caller, firstById, isDateOnly, isUniqueViolation, iso, isUuid, moneyCents, newId, optionalString, parseJsonBody, requireCan, rowsOf, } from "./helpers.js"; const PAYMENT_METHODS = ["check", "ach"] as const; type InvoiceRow = typeof invoices.$inferSelect; type LineRow = typeof invoiceLines.$inferSelect; type DocumentRow = typeof documents.$inferSelect; type VendorRow = typeof vendors.$inferSelect; type LineInput = { description: string; amount: string; glAccountId: string | null; departmentId: string | null; }; function isPaymentMethod(value: string): value is (typeof PAYMENT_METHODS)[number] { return (PAYMENT_METHODS as readonly string[]).includes(value); } function toInvoice(row: InvoiceRow, lines: LineRow[]) { return { id: row.id, vendorId: row.vendorId, invoiceNumber: row.invoiceNumber, amount: row.amount, amountDue: row.amountDue, dueDate: row.dueDate, payDate: row.payDate, sendPaymentOn: row.sendPaymentOn, status: row.status, paymentMethod: row.paymentMethod, memo: row.memo, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt), lines: lines.map(toLine), }; } function toLine(row: LineRow) { return { id: row.id, invoiceId: row.invoiceId, description: row.description, amount: row.amount, glAccountId: row.glAccountId, departmentId: row.departmentId, createdAt: iso(row.createdAt), }; } function toDocument( row: DocumentRow, urls: { uploadUrl?: string; uploadHeaders?: Record; downloadUrl?: string } = {}, ) { return { id: row.id, invoiceId: row.invoiceId, objectKey: row.objectKey, contentType: row.contentType, fileName: row.fileName, uploadedByUserId: row.uploadedByUserId, createdAt: iso(row.createdAt), ...urls, }; } function parseLine(body: Record): LineInput | string { const description = asString(body.description).trim(); const amount = asMoney(body.amount); if (!description || !amount) return "Line description and amount are required."; const glAccountId = optionalString(body.glAccountId) ?? null; const departmentId = optionalString(body.departmentId) ?? null; if (glAccountId && !isUuid(glAccountId)) return "Invalid glAccountId."; if (departmentId && !isUuid(departmentId)) return "Invalid departmentId."; return { description, amount, glAccountId, departmentId }; } function linesSumToAmount(lines: Array<{ amount: string }>, amount: string): boolean { const sum = lines.reduce((total, line) => total + moneyCents(line.amount), 0); return sum === moneyCents(amount); } async function linesFor(handle: DbHandle, invoiceId: string): Promise { const rows = await rowsOf(handle, invoiceLines); return rows.filter((row) => row.invoiceId === invoiceId); } async function activeDuplicate( handle: DbHandle, vendorId: string, invoiceNumber: string, exceptId?: string, ): Promise { const rows = await rowsOf(handle, invoices); return rows.some( (row) => row.vendorId === vendorId && row.invoiceNumber === invoiceNumber && row.status !== "void" && row.id !== exceptId, ); } function safeFileName(value: string): string { return ( value .replace(/[/\\]+/g, "_") .replace(/^\.+/, "_") .slice(0, 180) || "document" ); } export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { const routes = new Hono(); routes.get("/invoices", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const rows = await rowsOf(handle, invoices); const allLines = await rowsOf(handle, invoiceLines); return c.json({ items: rows.map((row) => toInvoice( row, allLines.filter((line) => line.invoiceId === row.id), ), ), }); }); routes.get("/invoices/:id", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); const row = await firstById(handle, invoices, id); if (!row) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); return c.json(toInvoice(row, await linesFor(handle, id))); }); routes.post("/invoices", async (c) => { const denied = requireCan(c, "write:invoices"); if (denied) return denied; const body = await parseJsonBody(c); const vendorId = asString(body.vendorId); const invoiceNumber = asString(body.invoiceNumber).trim(); const amount = asMoney(body.amount); const dueDate = asString(body.dueDate); if (!isUuid(vendorId) || !invoiceNumber || !amount || !isDateOnly(dueDate)) { return errorJson( c, 400, "VALIDATION_ERROR", "vendorId, invoiceNumber, amount, and dueDate are required.", ); } const vendor = await firstById(handle, vendors, vendorId); if (!vendor) return errorJson(c, 400, "VALIDATION_ERROR", "Vendor not found."); const method = asString(body.paymentMethod, vendor.defaultPaymentMethod); if (!isPaymentMethod(method)) { return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod."); } const parsedLines: LineInput[] = []; if (Array.isArray(body.lines)) { for (const raw of body.lines) { if (!raw || typeof raw !== "object") { return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object."); } const parsed = parseLine(raw as Record); if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); parsedLines.push(parsed); } if (!linesSumToAmount(parsedLines, amount)) { return errorJson( c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount.", ); } } if (await activeDuplicate(handle, vendorId, invoiceNumber)) { return errorJson( c, 409, "CONFLICT", "An active invoice already uses this vendor and invoice number.", ); } let latest: InvoiceRow | undefined; let currentLines: LineRow[] = []; try { await handle.db.transaction(async (tx) => { const scoped: DbHandle = { db: tx }; 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( c, 409, "CONFLICT", "An active invoice already uses this vendor and invoice number.", ); } throw error; } if (!latest) { return errorJson(c, 500, "INTERNAL_ERROR", "Invoice create did not complete."); } return c.json(toInvoice(latest, currentLines), 201); }); routes.patch("/invoices/:id", async (c) => { const denied = requireCan(c, "write:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); const existing = await firstById(handle, invoices, id); if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const body = await parseJsonBody(c); const patch: Partial = { id, updatedAt: new Date() }; if (body.vendorId !== undefined) { const vendorId = asString(body.vendorId); if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId."); patch.vendorId = vendorId; } if (body.invoiceNumber !== undefined) { const invoiceNumber = asString(body.invoiceNumber).trim(); if (!invoiceNumber) return errorJson(c, 400, "VALIDATION_ERROR", "invoiceNumber is required."); patch.invoiceNumber = invoiceNumber; } if (body.amount !== undefined) { const amount = asMoney(body.amount); if (!amount) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid amount."); patch.amount = amount; patch.amountDue = amount; } if (body.dueDate !== undefined) { const dueDate = asString(body.dueDate); if (!isDateOnly(dueDate)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid dueDate."); patch.dueDate = dueDate; } if (body.payDate !== undefined) { const payDate = optionalString(body.payDate) ?? null; if (payDate && !isDateOnly(payDate)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid payDate."); patch.payDate = payDate; } if (body.sendPaymentOn !== undefined) { const sendPaymentOn = optionalString(body.sendPaymentOn) ?? null; if (sendPaymentOn && !isDateOnly(sendPaymentOn)) { return errorJson(c, 400, "VALIDATION_ERROR", "Invalid sendPaymentOn."); } patch.sendPaymentOn = sendPaymentOn; } if (body.status !== undefined) { const status = asString(body.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); if (!isPaymentMethod(method)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod."); patch.paymentMethod = method; } if (body.memo !== undefined) patch.memo = asString(body.memo); const nextVendor = patch.vendorId ?? existing.vendorId; const nextNumber = patch.invoiceNumber ?? existing.invoiceNumber; const nextStatus = patch.status ?? existing.status; if (nextStatus !== "void" && (await activeDuplicate(handle, nextVendor, nextNumber, id))) { return errorJson( c, 409, "CONFLICT", "An active invoice already uses this vendor and invoice number.", ); } const nextAmount = patch.amount ?? existing.amount; const currentLines = await linesFor(handle, id); if (currentLines.length > 0 && !linesSumToAmount(currentLines, nextAmount)) { return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); } let row: InvoiceRow; try { if (patch.status === "void") { await closePendingSteps(handle, id, caller(c).id); } [row] = await handle.db.update(invoices).set(patch).where(eq(invoices.id, id)).returning(); } catch (error) { if (isUniqueViolation(error)) { return errorJson( c, 409, "CONFLICT", "An active invoice already uses this vendor and invoice number.", ); } throw error; } return c.json(toInvoice(row, currentLines)); }); routes.post("/invoices/:id/lines", async (c) => { const denied = requireCan(c, "write:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); const invoice = await firstById(handle, invoices, id); if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const parsed = parseLine(await parseJsonBody(c)); if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); const [row] = await handle.db .insert(invoiceLines) .values({ invoiceId: id, ...parsed }) .returning(); return c.json(toLine(row), 201); }); routes.put("/invoices/:id/lines", async (c) => { const denied = requireCan(c, "write:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); const invoice = await firstById(handle, invoices, id); if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const body = await parseJsonBody(c); if (!Array.isArray(body.items)) { return errorJson(c, 400, "VALIDATION_ERROR", "items must be an array of lines."); } const parsedLines: LineInput[] = []; for (const raw of body.items) { if (!raw || typeof raw !== "object") { return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object."); } const parsed = parseLine(raw as Record); if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); parsedLines.push(parsed); } if (!linesSumToAmount(parsedLines, invoice.amount)) { return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); } const created: LineRow[] = []; 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) }); }); routes.post("/invoices/:id/documents", async (c) => { const denied = requireCan(c, "write:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); const invoice = await firstById(handle, invoices, id); if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const body = await parseJsonBody(c); const fileName = safeFileName(asString(body.fileName).trim()); const contentType = asString(body.contentType, "application/pdf").trim() || "application/pdf"; if (!asString(body.fileName).trim()) { return errorJson(c, 400, "VALIDATION_ERROR", "fileName is required."); } const documentId = newId(); const objectKey = `invoices/${id}/${documentId}/${fileName}`; const [row] = await handle.db .insert(documents) .values({ id: documentId, invoiceId: id, objectKey, contentType, fileName, uploadedByUserId: caller(c).id, }) .returning(); const put = await store.presignPut({ objectKey, contentType }); return c.json(toDocument(row, { uploadUrl: put.url, uploadHeaders: put.headers }), 201); }); routes.post("/documents/:id/confirmations", async (c) => { const denied = requireCan(c, "write:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id."); const row = await firstById(handle, documents, id); if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found."); if (!(await store.objectExists(row.objectKey))) { return errorJson(c, 409, "CONFLICT", "Object has not been uploaded."); } return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) })); }); routes.get("/documents/:id", async (c) => { const denied = requireCan(c, "read:invoices"); if (denied) return denied; const id = c.req.param("id"); if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id."); const row = await firstById(handle, documents, id); if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found."); return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) })); }); return routes; }