From 6bc24a3fb48ce81760ad3610bbc957039f3f2e4b Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 14:44:13 +0800 Subject: [PATCH] feat(04-04): ExpenseReportService, AuditTrailService, and API routes - ExpenseReportService: getExpensesByCategory, getExpensesByVendor, getExpenseSummary - AuditTrailService: getJournalEntryAudit, getAuditTrailForEntity (ACCT-08) - GET /api/reports/expenses -- expense summary by category - GET /api/reports/expenses/by-vendor -- expense summary by vendor - GET /api/accounting/journal-entries/[id]/audit -- JE audit trail Co-Authored-By: Claude Opus 4.6 --- .../journal-entries/[id]/audit/route.ts | 45 ++++ .../api/reports/expenses/by-vendor/route.ts | 60 +++++ src/app/api/reports/expenses/route.ts | 63 ++++++ src/lib/services/audit-trail-service.ts | 169 ++++++++++++++ src/lib/services/expense-report-service.ts | 214 ++++++++++++++++++ 5 files changed, 551 insertions(+) create mode 100644 src/app/api/accounting/journal-entries/[id]/audit/route.ts create mode 100644 src/app/api/reports/expenses/by-vendor/route.ts create mode 100644 src/app/api/reports/expenses/route.ts create mode 100644 src/lib/services/audit-trail-service.ts create mode 100644 src/lib/services/expense-report-service.ts diff --git a/src/app/api/accounting/journal-entries/[id]/audit/route.ts b/src/app/api/accounting/journal-entries/[id]/audit/route.ts new file mode 100644 index 0000000..01c6ae5 --- /dev/null +++ b/src/app/api/accounting/journal-entries/[id]/audit/route.ts @@ -0,0 +1,45 @@ +/** + * GET /api/accounting/journal-entries/[id]/audit + * + * Full audit trail for a single journal entry (ACCT-08 compliance). + * Shows who created it, when, source reference, approval info, and all lines. + * + * Access: ADMIN, OFFICE_STAFF. + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { AuditTrailService } from "@/lib/services/audit-trail-service"; + +export function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return (async () => { + const { id } = await params; + const handler = withPermission("read", "Account")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context -- super-admins must use the admin API" }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const audit = await AuditTrailService.getJournalEntryAudit(tenantPrisma, id); + return NextResponse.json(audit); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch audit trail"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } + } + ); + return handler(req); + })(); +} diff --git a/src/app/api/reports/expenses/by-vendor/route.ts b/src/app/api/reports/expenses/by-vendor/route.ts new file mode 100644 index 0000000..53a1f88 --- /dev/null +++ b/src/app/api/reports/expenses/by-vendor/route.ts @@ -0,0 +1,60 @@ +/** + * GET /api/reports/expenses/by-vendor + * + * Expense summary report grouped by vendor for a date range. + * Returns totals per vendor including "No Vendor" bucket (POSTED expenses only). + * + * Query params: + * startDate - ISO date string (required) + * endDate - ISO date string (required) + * vendorId - UUID (optional, filter to single vendor) + * + * Access: ADMIN only. + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { ExpenseReportService } from "@/lib/services/expense-report-service"; + +export const GET = withPermission("read", "Report")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context -- super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const startDate = searchParams.get("startDate"); + const endDate = searchParams.get("endDate"); + const vendorId = searchParams.get("vendorId") ?? undefined; + + if (!startDate || !endDate) { + return NextResponse.json( + { error: "startDate and endDate query parameters are required" }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const report = await ExpenseReportService.getExpensesByVendor(tenantPrisma, { + startDate: new Date(startDate), + endDate: new Date(endDate), + vendorId, + }); + + return NextResponse.json( + report.map((r) => ({ + ...r, + totalAmount: r.totalAmount.toString(), + })) + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to generate vendor expense report"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } +); diff --git a/src/app/api/reports/expenses/route.ts b/src/app/api/reports/expenses/route.ts new file mode 100644 index 0000000..4bbcae4 --- /dev/null +++ b/src/app/api/reports/expenses/route.ts @@ -0,0 +1,63 @@ +/** + * GET /api/reports/expenses + * + * Expense summary report by category for a date range. + * Returns totals per category and grand total (POSTED expenses only). + * + * Query params: + * startDate - ISO date string (required) + * endDate - ISO date string (required) + * + * Access: ADMIN only. + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { ExpenseReportService } from "@/lib/services/expense-report-service"; + +export const GET = withPermission("read", "Report")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context -- super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const startDate = searchParams.get("startDate"); + const endDate = searchParams.get("endDate"); + + if (!startDate || !endDate) { + return NextResponse.json( + { error: "startDate and endDate query parameters are required" }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const report = await ExpenseReportService.getExpenseSummary(tenantPrisma, { + startDate: new Date(startDate), + endDate: new Date(endDate), + }); + + return NextResponse.json({ + ...report, + totalExpenses: report.totalExpenses.toString(), + byCategory: report.byCategory.map((r) => ({ + ...r, + totalAmount: r.totalAmount.toString(), + })), + byVendor: report.byVendor.map((r) => ({ + ...r, + totalAmount: r.totalAmount.toString(), + })), + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to generate expense report"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } +); diff --git a/src/lib/services/audit-trail-service.ts b/src/lib/services/audit-trail-service.ts new file mode 100644 index 0000000..5c16442 --- /dev/null +++ b/src/lib/services/audit-trail-service.ts @@ -0,0 +1,169 @@ +/** + * AuditTrailService -- Journal Entry audit trail for ACCT-08 compliance. + * + * Every journal entry in the system has a traceable audit trail: + * - Who created it (createdBy user) + * - When it was created (createdAt) + * - Who approved it (approvedBy user, if manual) + * - What source transaction created it (referenceType + referenceId) + * - Full line details (accounts, debits, credits) + * + * Works across ALL JE sources: Invoice, Payment, Expense, Collection, + * Remittance, StockMovement, and manual entries. + */ + +import { JournalEntryStatus } from "@prisma/client"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AuditUser { + id: string; + email: string; + name: string; +} + +export interface AuditLine { + accountCode: string; + accountName: string; + debit: string; + credit: string; + description: string | null; +} + +export interface JournalEntryAudit { + id: string; + entryNumber: string; + date: Date; + description: string; + source: string; + status: JournalEntryStatus; + referenceType: string | null; + referenceId: string | null; + createdBy: AuditUser; + createdAt: Date; + approvedBy: AuditUser | null; + approvedAt: Date | null; + reversesEntryId: string | null; + lines: AuditLine[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatUser(user: { + id: string; + email: string; + firstName: string; + lastName: string; +}): AuditUser { + return { + id: user.id, + email: user.email, + name: `${user.firstName} ${user.lastName}`.trim(), + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mapToAudit(je: any): JournalEntryAudit { + return { + id: je.id, + entryNumber: je.entryNumber, + date: je.date, + description: je.description, + source: je.source, + status: je.status, + referenceType: je.referenceType ?? null, + referenceId: je.referenceId ?? null, + createdBy: formatUser(je.createdBy), + createdAt: je.createdAt, + approvedBy: je.approvedBy ? formatUser(je.approvedBy) : null, + approvedAt: je.approvedAt ?? null, + reversesEntryId: je.reversesEntryId ?? null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + lines: je.lines.map((line: any) => ({ + accountCode: line.account.code, + accountName: line.account.name, + debit: line.debit.toString(), + credit: line.credit.toString(), + description: line.description ?? null, + })), + }; +} + +const JE_INCLUDE = { + createdBy: { + select: { id: true, email: true, firstName: true, lastName: true }, + }, + approvedBy: { + select: { id: true, email: true, firstName: true, lastName: true }, + }, + lines: { + include: { + account: { select: { code: true, name: true } }, + }, + orderBy: { debit: "desc" as const }, + }, +}; + +// --------------------------------------------------------------------------- +// AuditTrailService +// --------------------------------------------------------------------------- + +export class AuditTrailService { + /** + * Get the full audit trail for a single journal entry. + * + * Returns who created it, when, source reference, approval info, + * and all line details with account codes and names. + * + * This is the ACCT-08 compliance endpoint: every JE is traceable + * to its creator and source transaction. + */ + static async getJournalEntryAudit( + tenantPrisma: TenantPrismaClient, + entryId: string + ): Promise { + const je = await tenantPrisma.journalEntry.findFirst({ + where: { id: entryId }, + include: JE_INCLUDE, + }); + + if (!je) { + throw new Error(`Journal entry not found: ${entryId}`); + } + + return mapToAudit(je); + } + + /** + * Get all journal entries for a specific source entity. + * + * Enables "show all journal entries for this invoice/payment/expense/etc." + * Includes reversing entries (entries that reverse a JE matching the reference). + * + * @param referenceType - e.g., "Invoice", "Payment", "Expense", "Collection", "Remittance", "StockMovement" + * @param referenceId - The UUID of the source record + */ + static async getAuditTrailForEntity( + tenantPrisma: TenantPrismaClient, + params: { referenceType: string; referenceId: string } + ): Promise { + const entries = await tenantPrisma.journalEntry.findMany({ + where: { + referenceType: params.referenceType, + referenceId: params.referenceId, + }, + include: JE_INCLUDE, + orderBy: { createdAt: "asc" }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return entries.map((je: any) => mapToAudit(je)); + } +} diff --git a/src/lib/services/expense-report-service.ts b/src/lib/services/expense-report-service.ts new file mode 100644 index 0000000..26fe3d0 --- /dev/null +++ b/src/lib/services/expense-report-service.ts @@ -0,0 +1,214 @@ +/** + * ExpenseReportService -- Expense reporting by category and vendor. + * + * Provides aggregated expense views for ISP owners to understand spending: + * - By category: which expense types consume the most budget + * - By vendor: which suppliers are paid the most + * - Summary: combined view with grand totals + * + * Only POSTED expenses are included in reports (DRAFT/APPROVED/VOIDED excluded). + */ + +import { Prisma, ExpenseStatus } from "@prisma/client"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ExpenseReportFilter { + startDate: Date; + endDate: Date; +} + +export interface ExpenseByCategoryRow { + categoryId: string; + categoryName: string; + totalAmount: Prisma.Decimal; + expenseCount: number; +} + +export interface ExpenseByVendorRow { + vendorId: string | null; + vendorName: string; + totalAmount: Prisma.Decimal; + expenseCount: number; +} + +export interface ExpenseSummary { + totalExpenses: Prisma.Decimal; + expenseCount: number; + byCategory: ExpenseByCategoryRow[]; + byVendor: ExpenseByVendorRow[]; +} + +// --------------------------------------------------------------------------- +// ExpenseReportService +// --------------------------------------------------------------------------- + +export class ExpenseReportService { + /** + * Get expenses grouped by category within a date range. + * Only POSTED expenses are included. + * Ordered by totalAmount DESC (biggest spending category first). + */ + static async getExpensesByCategory( + tenantPrisma: TenantPrismaClient, + filter: ExpenseReportFilter + ): Promise { + const expenses = await tenantPrisma.expense.findMany({ + where: { + status: ExpenseStatus.POSTED, + expenseDate: { + gte: filter.startDate, + lte: filter.endDate, + }, + }, + include: { + category: { select: { id: true, name: true } }, + }, + }); + + // Group by category in JS + const categoryMap = new Map< + string, + { categoryId: string; categoryName: string; total: Prisma.Decimal; count: number } + >(); + + for (const exp of expenses) { + const catId = exp.categoryId as string; + const catName = exp.category.name as string; + const existing = categoryMap.get(catId); + if (existing) { + existing.total = existing.total.add(new Prisma.Decimal(exp.amount)); + existing.count += 1; + } else { + categoryMap.set(catId, { + categoryId: catId, + categoryName: catName, + total: new Prisma.Decimal(exp.amount), + count: 1, + }); + } + } + + // Convert to array and sort by totalAmount DESC + const rows: ExpenseByCategoryRow[] = Array.from(categoryMap.values()).map((v) => ({ + categoryId: v.categoryId, + categoryName: v.categoryName, + totalAmount: v.total, + expenseCount: v.count, + })); + + rows.sort((a, b) => { + if (b.totalAmount.greaterThan(a.totalAmount)) return 1; + if (b.totalAmount.lessThan(a.totalAmount)) return -1; + return 0; + }); + + return rows; + } + + /** + * Get expenses grouped by vendor within a date range. + * Only POSTED expenses are included. + * Includes a "No Vendor" bucket for expenses without vendorId. + * Optional vendorId filter for single-vendor detail. + * Ordered by totalAmount DESC. + */ + static async getExpensesByVendor( + tenantPrisma: TenantPrismaClient, + filter: ExpenseReportFilter & { vendorId?: string } + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const where: any = { + status: ExpenseStatus.POSTED, + expenseDate: { + gte: filter.startDate, + lte: filter.endDate, + }, + }; + + if (filter.vendorId) { + where.vendorId = filter.vendorId; + } + + const expenses = await tenantPrisma.expense.findMany({ + where, + include: { + vendor: { select: { id: true, name: true } }, + }, + }); + + // Group by vendor in JS + const vendorMap = new Map< + string, + { vendorId: string | null; vendorName: string; total: Prisma.Decimal; count: number } + >(); + + const NO_VENDOR_KEY = "__no_vendor__"; + + for (const exp of expenses) { + const vId = (exp.vendorId as string | null) ?? NO_VENDOR_KEY; + const vName = exp.vendor ? (exp.vendor.name as string) : "No Vendor"; + const existing = vendorMap.get(vId); + if (existing) { + existing.total = existing.total.add(new Prisma.Decimal(exp.amount)); + existing.count += 1; + } else { + vendorMap.set(vId, { + vendorId: vId === NO_VENDOR_KEY ? null : vId, + vendorName: vName, + total: new Prisma.Decimal(exp.amount), + count: 1, + }); + } + } + + const rows: ExpenseByVendorRow[] = Array.from(vendorMap.values()).map((v) => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + totalAmount: v.total, + expenseCount: v.count, + })); + + rows.sort((a, b) => { + if (b.totalAmount.greaterThan(a.totalAmount)) return 1; + if (b.totalAmount.lessThan(a.totalAmount)) return -1; + return 0; + }); + + return rows; + } + + /** + * Get a combined expense summary: grand total + by-category + by-vendor. + * Only POSTED expenses are included. + */ + static async getExpenseSummary( + tenantPrisma: TenantPrismaClient, + filter: ExpenseReportFilter + ): Promise { + const [byCategory, byVendor] = await Promise.all([ + ExpenseReportService.getExpensesByCategory(tenantPrisma, filter), + ExpenseReportService.getExpensesByVendor(tenantPrisma, filter), + ]); + + // Compute grand total from category rows (avoids double-counting) + let totalExpenses = new Prisma.Decimal(0); + let expenseCount = 0; + for (const row of byCategory) { + totalExpenses = totalExpenses.add(row.totalAmount); + expenseCount += row.expenseCount; + } + + return { + totalExpenses, + expenseCount, + byCategory, + byVendor, + }; + } +}