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:
45
src/app/api/accounting/journal-entries/[id]/audit/route.ts
Normal file
45
src/app/api/accounting/journal-entries/[id]/audit/route.ts
Normal 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);
|
||||
})();
|
||||
}
|
||||
60
src/app/api/reports/expenses/by-vendor/route.ts
Normal file
60
src/app/api/reports/expenses/by-vendor/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
63
src/app/api/reports/expenses/route.ts
Normal file
63
src/app/api/reports/expenses/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
169
src/lib/services/audit-trail-service.ts
Normal file
169
src/lib/services/audit-trail-service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
214
src/lib/services/expense-report-service.ts
Normal file
214
src/lib/services/expense-report-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user