import type { Context, Hono } from "hono"; import type { AuthUser } from "./auth.ts"; import { tenantScope } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; import type { Db } from "./db.ts"; import { callCoreFn } from "./rpc.ts"; import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts"; import { requirePermission } from "./permissions.ts"; import { encryptBytes, decryptBytes } from "./docs_crypto.ts"; import { expenseDocKey, getObject, putObject, s3Configured } from "./storage.ts"; import { denyUnlessProjectScope } from "./scope_enforcement.ts"; import { randomBytes } from "node:crypto"; type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; function tid(c: { get: (k: "user") => AuthUser }): number { return tenantScope(c.get("user")) ?? 0; } function actor(c: Context): { id: number; name: string } { const u = c.get("user"); return { id: u.id, name: u.display_name }; } export function registerExpenseRoutes(app: App) { app.get("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => { const projectId = c.req.query("project_id"); if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_expense_list", { tenant_id: tid(c), project_id: projectId, company_id: c.req.query("company_id"), date_from: c.req.query("date_from"), date_to: c.req.query("date_to"), budget_item_id: c.req.query("budget_item_id"), source: c.req.query("source"), }, { route: routeLabel(c) }); return respondRpc(c, env); }); app.get("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => { const db = c.get("db"); const env = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); if (env.ok && env.data) { const expense = (env.data as { expense?: { project_id?: number } }).expense ?? env.data; const projectId = Number((expense as { project_id?: number }).project_id); if (projectId && !await denyUnlessProjectScope(c, projectId)) return; } return respondRpc(c, env); }); app.post("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.create"), async (c) => { const body = await c.req.json>(); if (!await denyUnlessProjectScope(c, Number(body.project_id))) return; const db = c.get("db"); const a = actor(c); const env = await callCoreFn(db, "core.fn_expense_create", { ...body, tenant_id: tid(c), created_by_id: a.id, created_by_name: a.name, }, { route: routeLabel(c) }); return respondRpc(c, env); }); app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.update"), async (c) => { const body = await c.req.json>(); const db = c.get("db"); const got = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); if (got.ok && got.data) { const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data; if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return; } const env = await callCoreFn(db, "core.fn_expense_update", { ...body, id: Number(c.req.param("id")), tenant_id: tid(c), }, { route: routeLabel(c) }); return respondRpc(c, env); }); app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.delete"), async (c) => { const db = c.get("db"); const got = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); if (got.ok && got.data) { const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data; if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return; } const env = await callCoreFn(db, "core.fn_expense_void", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); return respondRpc(c, env); }); app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("expenses.update"), async (c) => { const expenseId = Number(c.req.param("id")); const db = c.get("db"); const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId }, { route: routeLabel(c) }); if (got.ok && got.data) { const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data; if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return; } const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) { const env = await callCoreFn(db, "core.fn_expense_attachment_add", { expense_id: expenseId, ...JSON.parse(String(form.get("meta") ?? "{}")), }, { route: routeLabel(c) }); return respondRpc(c, env); } const bytes = new Uint8Array(await file.arrayBuffer()); const { iv, cipher } = await encryptBytes(bytes); const storageName = `${Date.now()}-${randomBytes(8).toString("hex")}`; if (s3Configured()) { await putObject(expenseDocKey(expenseId, storageName), cipher); } const a = actor(c); const env = await callCoreFn(db, "core.fn_expense_attachment_add", { expense_id: expenseId, original_name: file.name, mime: file.type || "application/pdf", size_bytes: bytes.length, sha256: "", iv, storage_name: storageName, uploaded_by_id: a.id, uploaded_by_name: a.name, }, { route: routeLabel(c) }); return respondRpc(c, env); }); app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => { const expenseId = Number(c.req.param("id")); const attId = Number(c.req.param("attId")); const db = c.get("db"); const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId }, { route: routeLabel(c) }); if (got.ok && got.data) { const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data; if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return; } const row = await db.prepare( "SELECT storage_name, iv, mime, original_name FROM expense_attachments WHERE id = ? AND expense_id = ?", ).get(attId, expenseId) as { storage_name: string; iv: string; mime: string; original_name: string } | undefined; if (!row) return respondRpc(c, { ok: false, code: "NOT_FOUND", layer: "api", message: "Comprobante no encontrado", data: null, errors: null }); const enc = await getObject(expenseDocKey(expenseId, row.storage_name)); const plain = await decryptBytes(row.iv, enc); c.header("Content-Type", row.mime); c.header("Content-Disposition", `inline; filename="${row.original_name}"`); return c.body(plain.buffer as ArrayBuffer); }); app.get("/v1/iva/summary", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => { const db = c.get("db"); const env = await callCoreFn(db, "core.fn_iva_period_summary", { tenant_id: tid(c), company_id: c.req.query("company_id"), year: c.req.query("year"), month: c.req.query("month"), }, { route: routeLabel(c) }); return respondRpc(c, env); }); app.get("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.view"), async (c) => { const db = c.get("db"); const env = await callCoreFn(db, "core.fn_cost_settings_get", { tenant_id: tid(c) }, { route: routeLabel(c) }); return respondRpc(c, env); }); app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.update"), async (c) => { const body = await c.req.json>(); const db = c.get("db"); const env = await callCoreFn(db, "core.fn_cost_settings_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }); return respondRpc(c, env); }); }