diff --git a/src/lib/__tests__/expense-report-service.test.ts b/src/lib/__tests__/expense-report-service.test.ts new file mode 100644 index 0000000..dc4dab8 --- /dev/null +++ b/src/lib/__tests__/expense-report-service.test.ts @@ -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, + }); + + // 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, + }); + + // 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, + }); + 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"); + }); +});