seahaven-ap/packages/api/src/routes/invoices.ts
Adam Moussa 864531b51e
feat(api): portal contract, cookie auth, and domain stubs (AP-51) (#64)
* feat(api): serve portal-shaped health and error envelope

Move liveness to GET /api/health { stage, sha } with a Node 24 image on 8080 so ALB probes and deploy verify do not need auth or a database ping.

* feat(api): switch live auth to host cookie BFF

Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include.

* feat(web): add unused cookie SPA API client

Land a credentials-include fetch helper and hand-synced health/me types without wiring pages or domain hooks, so mocks stay the default data path.

* feat(api): add master-data OpenAPI and Hono stubs

* feat(api): add invoice, line, and document stubs

* feat(api): add approval policy, inbox, and activity stubs

* test(web): fix SPA client fetch mock types

* test(web): cast fetch mock call args for tsc

* fix(api): do not default DEV_AUTH_BYPASS outside local migrate

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

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

* fix(api): inline GIT_SHA from the image build arg

* fix(api): stop PATCH from skipping the approval workflow

* fix(api): address review feedback

* fix(ci): format upsert-user test

* fix(api): document only the auth statuses the routes return

* fix(api): drop health 400 responses the routes never return
2026-09-25 22:43:44 +00:00

463 lines
16 KiB
TypeScript

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<string, string>; 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<string, unknown>): 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<LineRow[]> {
const rows = await rowsOf<LineRow>(handle, invoiceLines);
return rows.filter((row) => row.invoiceId === invoiceId);
}
async function activeDuplicate(
handle: DbHandle,
vendorId: string,
invoiceNumber: string,
exceptId?: string,
): Promise<boolean> {
const rows = await rowsOf<InvoiceRow>(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<AppBindings>();
routes.get("/invoices", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await rowsOf<InvoiceRow>(handle, invoices);
const allLines = await rowsOf<LineRow>(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<InvoiceRow>(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<VendorRow>(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<string, unknown>);
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<InvoiceRow>(handle, invoices, id);
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof invoices.$inferInsert> = { 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<InvoiceRow>(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<InvoiceRow>(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<string, unknown>);
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<InvoiceRow>(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<DocumentRow>(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<DocumentRow>(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;
}