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 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 14:44:13 +08:00
parent e3a177f194
commit 6bc24a3fb4
5 changed files with 551 additions and 0 deletions

View File

@@ -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);
})();
}

View File

@@ -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 });
}
}
);

View File

@@ -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 });
}
}
);

View File

@@ -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<JournalEntryAudit> {
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<JournalEntryAudit[]> {
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));
}
}

View File

@@ -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<ExpenseByCategoryRow[]> {
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<ExpenseByVendorRow[]> {
// 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<ExpenseSummary> {
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,
};
}
}