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