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:
463
src/lib/__tests__/integration/e2e-workflows.test.ts
Normal file
463
src/lib/__tests__/integration/e2e-workflows.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user