feat(05-05): billing workflow end-to-end test

- Subscriber registration -> invoice generation -> payment recording
- Full payment marks invoice PAID with correct JE (DR Cash, CR AR)
- Partial payment marks invoice PARTIAL with balanced JE
- Trial balance verified balanced after all transactions
- Dashboard reflects billing activity (revenue + subscriber metrics)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 17:41:07 +08:00
parent 82c8dc39d1
commit abf9ee0eae

View File

@@ -0,0 +1,463 @@
/**
* End-to-End Workflow Integration Tests
*
* Tests the three critical ISP business processes end-to-end, exercising
* multiple services in sequence to prove they integrate correctly and
* produce accurate accounting entries.
*
* INFRA-04: Prove the system works as a coherent whole, not just isolated units.
*
* Workflow 1: Subscriber Registration -> Invoice Generation -> Payment Recording
* Workflow 2: Collector Collection -> Remittance Verification
* Workflow 3: Ticket Creation -> Job Order -> Completion -> Auto-Resolve
*
* CLEANUP ORDER (comprehensive, covering all subsystems):
* ticketComments -> jobOrders -> tickets -> ticketCategories ->
* collectionAllocations -> collections -> remittances ->
* paymentAllocations -> payments -> invoiceLines -> invoices ->
* journalEntryLines -> null reversesEntryId -> journalEntries ->
* stockMovements -> inventoryItems -> expenses -> vendors ->
* expenseCategories (custom) -> subscribers -> servicePlans ->
* tenantSettings -> accountingPeriods -> accounts ->
* zoneAssignments -> zones -> technicianProfiles -> jobTypeRates ->
* expenseCategories (system) -> users -> tenant
*/
import { prisma } from "@/lib/prisma";
import { withTenantContext } from "@/lib/prisma-tenant";
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
import { createSubscriber } from "@/lib/services/subscriber-service";
import {
generateInvoiceForSubscriber,
computeBillingPeriod,
} from "@/lib/services/billing-service";
import { recordPayment } from "@/lib/services/payment-service";
import { recordCollection } from "@/lib/services/collector-service";
import {
createRemittance,
verifyRemittance,
} from "@/lib/services/remittance-service";
import { createTicket, transitionTicketStatus } from "@/lib/services/ticket-service";
import {
createJobOrder,
updateJobOrderStatus,
} from "@/lib/services/job-order-service";
import { DashboardService } from "@/lib/services/dashboard-service";
import {
Prisma,
Role,
TenantStatus,
BillingType,
PaymentMethod,
TicketPriority,
TicketSource,
TicketStatus,
JobOrderStatus,
} from "@prisma/client";
// ---------------------------------------------------------------------------
// Shared test state
// ---------------------------------------------------------------------------
const TS = Date.now();
let tenantId: string;
let adminUserId: string;
let officeStaffUserId: string;
let collectorUserId: string;
let technicianUserId: string;
let servicePlanId: string;
let zoneId: string;
let ticketCategoryId: string;
// Billing workflow state
let subscriberId: string;
let subscriberAccountNumber: string;
let invoice1Id: string;
let invoice2Id: string;
// Collection workflow state
let collectionSubscriberId: string;
let collectionInvoiceId: string;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function tp() {
return withTenantContext(tenantId);
}
let invoiceCounter = 0;
/**
* Helper to create a raw invoice for tests that need one without going
* through the full billing service (e.g., collection workflow).
*/
async function createRawInvoice(
subscriberIdArg: string,
amount: number,
status = "SENT" as const,
) {
invoiceCounter++;
const periodStart = new Date(Date.UTC(2025, 0, invoiceCounter));
return prisma.invoice.create({
data: {
tenantId,
invoiceNumber: `INV-E2E-${TS}-${invoiceCounter}`,
subscriberId: subscriberIdArg,
periodStart,
periodEnd: new Date(Date.UTC(2025, 0, invoiceCounter + 30)),
dueDate: new Date(Date.UTC(2025, 1, invoiceCounter)),
subtotal: new Prisma.Decimal(amount),
totalAmount: new Prisma.Decimal(amount),
amountPaid: new Prisma.Decimal(0),
status,
} as Record<string, unknown>,
});
}
// ---------------------------------------------------------------------------
// Setup / Teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
// 1. Create tenant
const tenant = await prisma.tenant.create({
data: {
name: `E2E Workflow Test ${TS}`,
slug: `e2e-workflow-${TS}`,
ownerEmail: `e2e-${TS}@test.example`,
status: TenantStatus.ACTIVE,
},
});
tenantId = tenant.id;
// 2. Seed COA
await prisma.$transaction(async (tx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await seedChartOfAccounts(tx as any, tenantId);
});
// 3. Seed ticket categories
const cat = await prisma.ticketCategory.create({
data: { name: "No Connection", tenantId, isActive: true },
});
ticketCategoryId = cat.id;
// 4. Create users
const admin = await prisma.user.create({
data: {
email: `e2e-admin-${TS}@test.example`,
passwordHash: "hashed",
firstName: "E2E",
lastName: "Admin",
tenantId,
roles: [Role.ADMIN],
isActive: true,
},
});
adminUserId = admin.id;
const officeStaff = await prisma.user.create({
data: {
email: `e2e-office-${TS}@test.example`,
passwordHash: "hashed",
firstName: "E2E",
lastName: "OfficeStaff",
tenantId,
roles: [Role.OFFICE_STAFF],
isActive: true,
},
});
officeStaffUserId = officeStaff.id;
const collector = await prisma.user.create({
data: {
email: `e2e-collector-${TS}@test.example`,
passwordHash: "hashed",
firstName: "E2E",
lastName: "Collector",
tenantId,
roles: [Role.COLLECTOR],
isActive: true,
},
});
collectorUserId = collector.id;
const technician = await prisma.user.create({
data: {
email: `e2e-tech-${TS}@test.example`,
passwordHash: "hashed",
firstName: "E2E",
lastName: "Technician",
tenantId,
roles: [Role.TECHNICIAN],
isActive: true,
},
});
technicianUserId = technician.id;
// 5. Create service plan
const plan = await prisma.servicePlan.create({
data: {
tenantId,
name: `Fiber 50 Mbps ${TS}`,
speed: "50 Mbps",
monthlyPrice: new Prisma.Decimal(1500),
billingType: BillingType.POSTPAID,
},
});
servicePlanId = plan.id;
// 6. Create zone and assign collector
const zone = await prisma.zone.create({
data: { tenantId, name: `Zone Alpha ${TS}` },
});
zoneId = zone.id;
await prisma.zoneAssignment.create({
data: { tenantId, userId: collectorUserId, zoneId },
});
}, 60000);
afterAll(async () => {
// Comprehensive cleanup order covering all subsystems
await prisma.ticketComment.deleteMany({ where: { tenantId } });
await prisma.jobOrder.deleteMany({ where: { tenantId } });
await prisma.ticket.deleteMany({ where: { tenantId } });
await prisma.ticketCategory.deleteMany({ where: { tenantId } });
await prisma.collectionAllocation.deleteMany({ where: { tenantId } });
await prisma.collection.deleteMany({ where: { tenantId } });
await prisma.remittance.deleteMany({ where: { tenantId } });
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.stockMovement.deleteMany({ where: { tenantId } });
await prisma.inventoryItem.deleteMany({ where: { tenantId } });
await prisma.expense.deleteMany({ where: { tenantId } });
await prisma.vendor.deleteMany({ where: { tenantId } });
await prisma.expenseCategory.deleteMany({
where: { tenantId, isSystemCategory: false },
});
await prisma.subscriber.deleteMany({ where: { tenantId } });
await prisma.servicePlan.deleteMany({ where: { tenantId } });
await prisma.tenantSettings.deleteMany({ where: { tenantId } });
await prisma.accountingPeriod.deleteMany({ where: { tenantId } });
await prisma.account.deleteMany({ where: { tenantId } });
await prisma.zoneAssignment.deleteMany({ where: { tenantId } });
await prisma.zone.deleteMany({ where: { tenantId } });
await prisma.technicianProfile.deleteMany({ where: { tenantId } });
await prisma.jobTypeRate.deleteMany({ where: { tenantId } });
await prisma.expenseCategory.deleteMany({ where: { tenantId } });
await prisma.user.deleteMany({ where: { tenantId } });
await prisma.tenant.deleteMany({ where: { id: tenantId } });
});
// =============================================================================
// WORKFLOW 1: Subscriber Registration -> Invoice -> Payment -> Balanced Books
// =============================================================================
describe("E2E: Billing Workflow", () => {
test("1. Register a new subscriber", async () => {
const subscriber = await createSubscriber(tp(), {
firstName: "Juan",
lastName: "dela Cruz",
address: "123 Mango St, Barangay 1",
servicePlanId,
zoneId,
});
subscriberId = subscriber.id;
subscriberAccountNumber = subscriber.accountNumber;
// Subscriber created with ACTIVE status
expect(subscriber.status).toBe("ACTIVE");
// Account number generated with SUB-NNNN format
expect(subscriber.accountNumber).toMatch(/^SUB-\d{4,}$/);
// Correct plan assigned
expect(subscriber.servicePlanId).toBe(servicePlanId);
expect(subscriber.servicePlan.monthlyPrice.toNumber()).toBe(1500);
});
test("2. Generate invoice for subscriber", async () => {
// Load subscriber with plan for billing service
const subscriber = await tp().subscriber.findFirst({
where: { id: subscriberId },
include: { servicePlan: true },
});
expect(subscriber).not.toBeNull();
const period = computeBillingPeriod(new Date(), subscriber!.billingDay);
const result = await generateInvoiceForSubscriber(
tp(),
tenantId,
subscriber!,
period,
adminUserId,
);
expect(result).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const invoice = (result as any).invoice;
invoice1Id = invoice.id;
// Invoice created with correct amount matching plan price
expect(new Prisma.Decimal(invoice.totalAmount).toNumber()).toBe(1500);
// Invoice has SENT status (unpaid)
expect(invoice.status).toBe("SENT");
// Verify JE posted: DR 1100 AR, CR 4010 Service Revenue
const trialBalance = await JournalEntryService.getTrialBalance({
tenantPrisma: tp(),
});
const arLine = trialBalance.find((l) => l.accountCode === "1100");
const revLine = trialBalance.find((l) => l.accountCode === "4010");
// AR should have a debit balance of 1500
expect(arLine!.debitBalance.toNumber()).toBe(1500);
// Revenue should have a credit balance of 1500
expect(revLine!.creditBalance.toNumber()).toBe(1500);
});
test("3. Record full payment against invoice", async () => {
const paymentResult = await recordPayment(tp(), tenantId, {
subscriberId,
amount: 1500,
paymentMethod: PaymentMethod.CASH,
paymentDate: new Date(),
idempotencyKey: `e2e-full-pay-${TS}`,
recordedById: adminUserId,
});
// Payment created with COMPLETED status
expect(paymentResult.payment.status).toBe("COMPLETED");
expect(paymentResult.idempotent).toBe(false);
// Full amount allocated
expect(paymentResult.allocations.length).toBe(1);
expect(paymentResult.allocations[0].amount.toNumber()).toBe(1500);
// Invoice status = PAID
const invoice = await tp().invoice.findFirst({
where: { id: invoice1Id },
});
expect(invoice!.status).toBe("PAID");
expect(new Prisma.Decimal(invoice!.amountPaid).toNumber()).toBe(1500);
// JE posted: DR 1010 Cash, CR 1100 AR
const trialBalance = await JournalEntryService.getTrialBalance({
tenantPrisma: tp(),
});
const cashLine = trialBalance.find((l) => l.accountCode === "1010");
// Cash should have debit balance of 1500
expect(cashLine!.debitBalance.toNumber()).toBe(1500);
// AR should now be zero (1500 debit from invoice - 1500 credit from payment)
const arLine = trialBalance.find((l) => l.accountCode === "1100");
expect(arLine!.debitBalance.toNumber()).toBe(0);
expect(arLine!.creditBalance.toNumber()).toBe(0);
});
test("4. Record partial payment on second invoice", async () => {
// Generate second invoice
const subscriber = await tp().subscriber.findFirst({
where: { id: subscriberId },
include: { servicePlan: true },
});
// Use a different period to avoid idempotency skip
const period = {
periodStart: new Date(Date.UTC(2025, 5, 15)),
periodEnd: new Date(Date.UTC(2025, 6, 14)),
dueDate: new Date(Date.UTC(2025, 7, 14)),
};
const result2 = await generateInvoiceForSubscriber(
tp(),
tenantId,
subscriber!,
period,
adminUserId,
);
expect(result2).not.toBeNull();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
invoice2Id = (result2 as any).invoice.id;
// Record partial payment (50%)
const paymentResult = await recordPayment(tp(), tenantId, {
subscriberId,
amount: 750,
paymentMethod: PaymentMethod.CASH,
paymentDate: new Date(),
idempotencyKey: `e2e-partial-pay-${TS}`,
recordedById: adminUserId,
});
expect(paymentResult.payment.status).toBe("COMPLETED");
expect(paymentResult.allocations[0].amount.toNumber()).toBe(750);
// Invoice status = PARTIAL, amountPaid = 750
const invoice2 = await tp().invoice.findFirst({
where: { id: invoice2Id },
});
expect(invoice2!.status).toBe("PARTIAL");
expect(new Prisma.Decimal(invoice2!.amountPaid).toNumber()).toBe(750);
// JE for partial amount is balanced
const trialBalance = await JournalEntryService.getTrialBalance({
tenantPrisma: tp(),
});
const totalDebits = trialBalance.reduce(
(sum, l) => sum.plus(l.debitBalance),
new Prisma.Decimal(0),
);
const totalCredits = trialBalance.reduce(
(sum, l) => sum.plus(l.creditBalance),
new Prisma.Decimal(0),
);
expect(totalDebits.toNumber()).toBe(totalCredits.toNumber());
});
test("5. Verify trial balance is balanced after all transactions", async () => {
const trialBalance = await JournalEntryService.getTrialBalance({
tenantPrisma: tp(),
});
const totalDebits = trialBalance.reduce(
(sum, l) => sum.plus(l.debitBalance),
new Prisma.Decimal(0),
);
const totalCredits = trialBalance.reduce(
(sum, l) => sum.plus(l.creditBalance),
new Prisma.Decimal(0),
);
// Self-verifying books: debits === credits
expect(totalDebits.toNumber()).toBe(totalCredits.toNumber());
// Should have meaningful balances (not all zeros)
expect(totalDebits.toNumber()).toBeGreaterThan(0);
});
test("6. Dashboard reflects billing activity", async () => {
// Revenue metrics
const revenue = await DashboardService.getRevenueMetrics(tp(), tenantId);
// revenueToday includes the payments made (1500 + 750 = 2250)
expect(revenue.revenueToday.toNumber()).toBeGreaterThanOrEqual(2250);
// Subscriber metrics
const subscribers = await DashboardService.getSubscriberMetrics(
tp(),
tenantId,
);
// Active count includes the registered subscriber
expect(subscribers.active).toBeGreaterThanOrEqual(1);
expect(subscribers.total).toBeGreaterThanOrEqual(1);
});
});