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:
531
src/lib/__tests__/expense-report-service.test.ts
Normal file
531
src/lib/__tests__/expense-report-service.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user