test(04-04): expense report and audit trail tests -- 9 passing

- Expense by category: correct totals, excludes DRAFT/VOIDED
- Expense by vendor: correct totals, No Vendor bucket
- Expense summary: combined view with grand total
- Date range filtering works
- JE audit trail: createdBy, source, referenceType for expense JE
- Audit trail for entity: original + void reversal entries
- Cross-source audit: Payment JE referenceType correctly returned

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 14:52:44 +08:00
parent 6bc24a3fb4
commit 787b22e6a1

View File

@@ -0,0 +1,531 @@
/**
* Expense Report & Audit Trail Integration Tests
*
* Tests expense reporting (by category, vendor, date range) and
* the journal entry audit trail (ACCT-08 compliance).
*
* Setup:
* - Create tenant, admin user, 2 vendors
* - Seed 9 default expense categories + 1 custom category
* - Create expenses across 3 categories (Bandwidth, Equipment, custom)
* - Some linked to vendors, some without vendor
* - All POSTED (so JEs exist) plus one DRAFT (excluded from reports)
* - Create a payment JE directly (to test audit trail across JE sources)
*
* CLEANUP ORDER:
* expenses -> vendors -> expenseCategories (custom only) -> paymentAllocations ->
* payments -> invoiceLines -> invoices -> journalEntryLines ->
* null reversesEntryId -> journalEntries -> subscribers -> servicePlans ->
* accountingPeriods -> accounts -> ticketCategories -> expenseCategories (system) ->
* users -> tenant
*/
import { prisma } from "@/lib/prisma";
import { withTenantContext } from "@/lib/prisma-tenant";
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
import { ExpenseService } from "@/lib/services/expense-service";
import { ExpenseReportService } from "@/lib/services/expense-report-service";
import { AuditTrailService } from "@/lib/services/audit-trail-service";
import { VendorService } from "@/lib/services/vendor-service";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
import {
Prisma,
Role,
TenantStatus,
ExpensePaymentMethod,
JournalEntrySource,
BillingType,
PaymentMethod,
} from "@prisma/client";
// ---------------------------------------------------------------------------
// Shared test state
// ---------------------------------------------------------------------------
const TS = Date.now();
let tenantId: string;
let userId: string;
// Account IDs
let cashAccountId: string; // 1010
let bankAccountId: string; // 1020
let arAccountId: string; // 1100
// Category IDs (from seeding)
let bandwidthCategoryId: string;
let equipmentCategoryId: string;
let customCategoryId: string;
// Vendor IDs
let vendor1Id: string;
let vendor2Id: string;
// Expense IDs (for audit trail tests)
let postedExpense1Id: string;
let postedExpense1JeId: string;
let voidedExpenseId: string;
// Payment JE ID (for cross-source audit test)
let paymentJeId: string;
let paymentId: string;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function tp() {
return withTenantContext(tenantId);
}
// ---------------------------------------------------------------------------
// Setup / Teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
// Create tenant
const tenant = await prisma.tenant.create({
data: {
name: `ExpReport Test ${TS}`,
slug: `expreport-test-${TS}`,
ownerEmail: `expreport-${TS}@test.example`,
status: TenantStatus.ACTIVE,
},
});
tenantId = tenant.id;
// Seed COA
await prisma.$transaction(async (tx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await seedChartOfAccounts(tx as any, tenantId);
});
// Seed ticket categories (needed for cleanup order)
await prisma.ticketCategory.createMany({
data: [{ name: "Test Category", tenantId }],
});
// Seed expense categories (9 defaults)
const defaultExpenseCategories = [
{ name: "Internet Bandwidth", accountCode: "5040", isSystemCategory: true, tenantId },
{ name: "Equipment & Supplies", accountCode: "5030", isSystemCategory: true, tenantId },
{ name: "Salary & Wages", accountCode: "5010", isSystemCategory: true, tenantId },
{ name: "Technician Compensation", accountCode: "5020", isSystemCategory: true, tenantId },
{ name: "Office Supplies", accountCode: "5050", isSystemCategory: true, tenantId },
{ name: "Utilities", accountCode: "5060", isSystemCategory: true, tenantId },
{ name: "Fuel & Transportation", accountCode: "5080", isSystemCategory: true, tenantId },
{ name: "Rent", accountCode: "5085", isSystemCategory: true, tenantId },
{ name: "Other", accountCode: "5090", isSystemCategory: true, tenantId },
];
await prisma.expenseCategory.createMany({ data: defaultExpenseCategories });
// Look up account IDs
const accounts = await prisma.account.findMany({
where: { tenantId, code: { in: ["1010", "1020", "1100"] } },
select: { id: true, code: true },
});
const accountMap = new Map(accounts.map((a) => [a.code, a.id]));
cashAccountId = accountMap.get("1010")!;
bankAccountId = accountMap.get("1020")!;
arAccountId = accountMap.get("1100")!;
// Look up category IDs
const categories = await prisma.expenseCategory.findMany({
where: { tenantId, name: { in: ["Internet Bandwidth", "Equipment & Supplies"] } },
select: { id: true, name: true },
});
const catMap = new Map(categories.map((c) => [c.name, c.id]));
bandwidthCategoryId = catMap.get("Internet Bandwidth")!;
equipmentCategoryId = catMap.get("Equipment & Supplies")!;
// Create admin user
const user = await prisma.user.create({
data: {
email: `expreport-admin-${TS}@test.example`,
passwordHash: "hashed",
firstName: "Report",
lastName: "Admin",
tenantId,
roles: [Role.ADMIN],
isActive: true,
},
});
userId = user.id;
// Create 2 vendors
const v1 = await VendorService.createVendor(tp(), tenantId, {
name: `ISP Upstream ${TS}`,
});
vendor1Id = v1.id;
const v2 = await VendorService.createVendor(tp(), tenantId, {
name: `Hardware Supplier ${TS}`,
});
vendor2Id = v2.id;
// Create custom expense category
const customCat = await ExpenseService.createCategory(tp(), tenantId, {
name: `Custom Test Category ${TS}`,
accountCode: "5090",
});
customCategoryId = customCat.id;
// Create expenses across 3 categories:
// Expense 1: Bandwidth, vendor1, 5000.00, Jan 15
const exp1 = await ExpenseService.createExpense(tp(), tenantId, {
categoryId: bandwidthCategoryId,
vendorId: vendor1Id,
amount: 5000,
expenseDate: new Date("2026-01-15"),
description: "Monthly bandwidth payment",
paymentMethod: ExpensePaymentMethod.BANK_TRANSFER,
createdById: userId,
});
postedExpense1Id = exp1.id;
postedExpense1JeId = exp1.journalEntryId;
// Expense 2: Bandwidth, vendor1, 5000.00, Feb 15
await ExpenseService.createExpense(tp(), tenantId, {
categoryId: bandwidthCategoryId,
vendorId: vendor1Id,
amount: 5000,
expenseDate: new Date("2026-02-15"),
description: "Monthly bandwidth payment Feb",
paymentMethod: ExpensePaymentMethod.BANK_TRANSFER,
createdById: userId,
});
// Expense 3: Equipment, vendor2, 2000.00, Jan 20
await ExpenseService.createExpense(tp(), tenantId, {
categoryId: equipmentCategoryId,
vendorId: vendor2Id,
amount: 2000,
expenseDate: new Date("2026-01-20"),
description: "Router purchase",
paymentMethod: ExpensePaymentMethod.CASH,
createdById: userId,
});
// Expense 4: Custom category, no vendor, 300.00, Jan 25
await ExpenseService.createExpense(tp(), tenantId, {
categoryId: customCategoryId,
amount: 300,
expenseDate: new Date("2026-01-25"),
description: "Miscellaneous custom expense",
paymentMethod: ExpensePaymentMethod.CASH,
createdById: userId,
});
// Expense 5: DRAFT only (should be excluded from reports)
await ExpenseService.createExpense(tp(), tenantId, {
categoryId: bandwidthCategoryId,
amount: 999,
expenseDate: new Date("2026-01-28"),
description: "Draft expense - should not appear",
paymentMethod: ExpensePaymentMethod.CASH,
createdById: userId,
requireApproval: true, // stays DRAFT
});
// Expense 6: Create and void (tests audit trail for entity)
const expToVoid = await ExpenseService.createExpense(tp(), tenantId, {
categoryId: equipmentCategoryId,
vendorId: vendor2Id,
amount: 500,
expenseDate: new Date("2026-01-22"),
description: "Voided equipment expense",
paymentMethod: ExpensePaymentMethod.CASH,
createdById: userId,
});
voidedExpenseId = expToVoid.id;
await ExpenseService.voidExpense(tp(), tenantId, {
expenseId: voidedExpenseId,
voidedById: userId,
});
// Create a payment JE directly (to test cross-source audit trail)
// First need a subscriber and invoice for realistic payment reference
const plan = await prisma.servicePlan.create({
data: {
tenantId,
name: `Test Plan ${TS}`,
speed: "50 Mbps",
monthlyPrice: new Prisma.Decimal(1000),
billingType: BillingType.POSTPAID,
},
});
const subscriber = await prisma.subscriber.create({
data: {
tenantId,
accountNumber: `SUB-RPT-${TS}`,
firstName: "Test",
lastName: "Sub",
address: "123 Test St",
servicePlanId: plan.id,
billingDay: 15,
} as Record<string, unknown>,
});
// Create an invoice
const invoice = await prisma.invoice.create({
data: {
tenantId,
invoiceNumber: `INV-RPT-${TS}`,
subscriberId: subscriber.id,
periodStart: new Date("2026-01-01"),
periodEnd: new Date("2026-01-31"),
dueDate: new Date("2026-02-15"),
subtotal: new Prisma.Decimal(1000),
totalAmount: new Prisma.Decimal(1000),
} as Record<string, unknown>,
});
// Create a payment with JE (referenceType: "Payment")
const revenueAccount = await prisma.account.findFirst({
where: { tenantId, code: "4010" },
select: { id: true },
});
const paymentRecord = await prisma.payment.create({
data: {
tenantId,
subscriberId: subscriber.id,
amount: new Prisma.Decimal(1000),
paymentMethod: PaymentMethod.CASH,
paymentDate: new Date("2026-01-20"),
idempotencyKey: `pay-rpt-${TS}`,
recordedById: userId,
} as Record<string, unknown>,
});
paymentId = paymentRecord.id;
const paymentJe = await JournalEntryService.createEntry({
tenantPrisma: tp(),
tenantId,
date: new Date("2026-01-20"),
description: "Payment received",
source: JournalEntrySource.SYSTEM,
referenceType: "Payment",
referenceId: paymentId,
createdById: userId,
lines: [
{ accountId: cashAccountId, debit: 1000, credit: 0, description: "Cash received" },
{ accountId: arAccountId, debit: 0, credit: 1000, description: "AR reduced" },
],
});
paymentJeId = paymentJe.id;
}, 60000);
afterAll(async () => {
// Cleanup in order
await prisma.expense.deleteMany({ where: { tenantId } });
await prisma.vendor.deleteMany({ where: { tenantId } });
await prisma.expenseCategory.deleteMany({ where: { tenantId, isSystemCategory: false } });
await prisma.paymentAllocation.deleteMany({ where: { tenantId } });
await prisma.payment.deleteMany({ where: { tenantId } });
await prisma.invoiceLine.deleteMany({ where: { tenantId } });
await prisma.invoice.deleteMany({ where: { tenantId } });
await prisma.journalEntryLine.deleteMany({ where: { tenantId } });
await prisma.journalEntry.updateMany({
where: { tenantId, reversesEntryId: { not: null } },
data: { reversesEntryId: null },
});
await prisma.journalEntry.deleteMany({ where: { tenantId } });
await prisma.subscriber.deleteMany({ where: { tenantId } });
await prisma.servicePlan.deleteMany({ where: { tenantId } });
await prisma.accountingPeriod.deleteMany({ where: { tenantId } });
await prisma.account.deleteMany({ where: { tenantId } });
await prisma.ticketCategory.deleteMany({ where: { tenantId } });
await prisma.expenseCategory.deleteMany({ where: { tenantId } });
await prisma.user.deleteMany({ where: { tenantId } });
await prisma.tenant.deleteMany({ where: { id: tenantId } });
});
// ---------------------------------------------------------------------------
// Tests: Expense Reports
// ---------------------------------------------------------------------------
describe("Expense report by category", () => {
it("returns correct totals per category for date range", async () => {
// Jan only: bandwidth=5000, equipment=2000+500(voided), custom=300
// Voided expenses are VOIDED status, so NOT included
const result = await ExpenseReportService.getExpensesByCategory(tp(), {
startDate: new Date("2026-01-01"),
endDate: new Date("2026-01-31"),
});
expect(result.length).toBe(3);
// Ordered by totalAmount DESC
const bandwidth = result.find((r) => r.categoryId === bandwidthCategoryId)!;
expect(bandwidth).toBeDefined();
expect(bandwidth.totalAmount.toString()).toBe("5000");
expect(bandwidth.expenseCount).toBe(1);
const equipment = result.find((r) => r.categoryId === equipmentCategoryId)!;
expect(equipment).toBeDefined();
expect(equipment.totalAmount.toString()).toBe("2000");
expect(equipment.expenseCount).toBe(1);
const custom = result.find((r) => r.categoryId === customCategoryId)!;
expect(custom).toBeDefined();
expect(custom.totalAmount.toString()).toBe("300");
expect(custom.expenseCount).toBe(1);
});
it("excludes DRAFT and VOIDED expenses (only POSTED counted)", async () => {
const result = await ExpenseReportService.getExpensesByCategory(tp(), {
startDate: new Date("2026-01-01"),
endDate: new Date("2026-01-31"),
});
// Grand total should be 5000 + 2000 + 300 = 7300
// NOT include 999 (DRAFT) or 500 (VOIDED)
const total = result.reduce(
(sum, r) => sum.add(r.totalAmount),
new Prisma.Decimal(0)
);
expect(total.toString()).toBe("7300");
});
});
describe("Expense report by vendor", () => {
it("returns correct totals per vendor", async () => {
const result = await ExpenseReportService.getExpensesByVendor(tp(), {
startDate: new Date("2026-01-01"),
endDate: new Date("2026-02-28"),
});
// vendor1: 5000 (Jan) + 5000 (Feb) = 10000
// vendor2: 2000 (equipment, Jan)
// No Vendor: 300 (custom, Jan)
expect(result.length).toBe(3);
const v1 = result.find((r) => r.vendorId === vendor1Id)!;
expect(v1).toBeDefined();
expect(v1.totalAmount.toString()).toBe("10000");
expect(v1.expenseCount).toBe(2);
const v2 = result.find((r) => r.vendorId === vendor2Id)!;
expect(v2).toBeDefined();
expect(v2.totalAmount.toString()).toBe("2000");
expect(v2.expenseCount).toBe(1);
});
it('includes "No Vendor" bucket for unlinked expenses', async () => {
const result = await ExpenseReportService.getExpensesByVendor(tp(), {
startDate: new Date("2026-01-01"),
endDate: new Date("2026-01-31"),
});
const noVendor = result.find((r) => r.vendorId === null)!;
expect(noVendor).toBeDefined();
expect(noVendor.vendorName).toBe("No Vendor");
expect(noVendor.totalAmount.toString()).toBe("300");
expect(noVendor.expenseCount).toBe(1);
});
});
describe("Expense summary", () => {
it("combines category and vendor views with grand total", async () => {
const summary = await ExpenseReportService.getExpenseSummary(tp(), {
startDate: new Date("2026-01-01"),
endDate: new Date("2026-02-28"),
});
// Total: 5000 + 5000 + 2000 + 300 = 12300
expect(summary.totalExpenses.toString()).toBe("12300");
expect(summary.expenseCount).toBe(4);
expect(summary.byCategory.length).toBeGreaterThanOrEqual(2);
expect(summary.byVendor.length).toBeGreaterThanOrEqual(2);
});
});
describe("Date range filtering", () => {
it("excludes expenses outside the date range", async () => {
// Feb only: bandwidth=5000
const result = await ExpenseReportService.getExpensesByCategory(tp(), {
startDate: new Date("2026-02-01"),
endDate: new Date("2026-02-28"),
});
expect(result.length).toBe(1);
expect(result[0].categoryId).toBe(bandwidthCategoryId);
expect(result[0].totalAmount.toString()).toBe("5000");
expect(result[0].expenseCount).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Tests: Audit Trail
// ---------------------------------------------------------------------------
describe("JE audit trail", () => {
it("shows createdBy user, source, and referenceType for an expense JE", async () => {
const audit = await AuditTrailService.getJournalEntryAudit(
tp(),
postedExpense1JeId
);
expect(audit.entryNumber).toMatch(/^JE-\d{4}-\d{4}$/);
expect(audit.source).toBe("SYSTEM");
expect(audit.referenceType).toBe("Expense");
expect(audit.referenceId).toBe(postedExpense1Id);
expect(audit.createdBy.email).toBe(`expreport-admin-${TS}@test.example`);
expect(audit.createdBy.name).toBe("Report Admin");
expect(audit.createdAt).toBeInstanceOf(Date);
expect(audit.lines.length).toBe(2);
// DR expense account, CR bank account
const drLine = audit.lines.find((l) => l.debit !== "0")!;
expect(drLine).toBeDefined();
expect(drLine.accountCode).toBe("5040"); // Internet Bandwidth
const crLine = audit.lines.find((l) => l.credit !== "0")!;
expect(crLine).toBeDefined();
expect(crLine.accountCode).toBe("1020"); // Cash in Bank (BANK_TRANSFER)
});
});
describe("Audit trail for entity", () => {
it("returns all JEs for an expense (original + void reversal)", async () => {
const trail = await AuditTrailService.getAuditTrailForEntity(tp(), {
referenceType: "Expense",
referenceId: voidedExpenseId,
});
// Original + reversing entry
expect(trail.length).toBe(2);
const original = trail.find((t) => t.reversesEntryId === null)!;
expect(original).toBeDefined();
expect(original.referenceType).toBe("Expense");
const reversal = trail.find((t) => t.reversesEntryId !== null)!;
expect(reversal).toBeDefined();
expect(reversal.reversesEntryId).toBe(original.id);
});
});
describe("Cross-source audit trail (Payment)", () => {
it("returns correct referenceType=Payment for a payment JE", async () => {
const audit = await AuditTrailService.getJournalEntryAudit(
tp(),
paymentJeId
);
expect(audit.referenceType).toBe("Payment");
expect(audit.referenceId).toBe(paymentId);
expect(audit.source).toBe("SYSTEM");
expect(audit.createdBy.email).toBe(`expreport-admin-${TS}@test.example`);
expect(audit.lines.length).toBe(2);
// DR Cash, CR AR
const drLine = audit.lines.find((l) => l.debit !== "0")!;
expect(drLine.accountCode).toBe("1010");
const crLine = audit.lines.find((l) => l.credit !== "0")!;
expect(crLine.accountCode).toBe("1100");
});
});