/** * CollectorService — Field cash collection recording with FIFO allocation and zone enforcement. * * ARCHITECTURE: * This service handles the full collection lifecycle: * - Collector logs cash received from a subscriber (lump sum) * - FIFO allocation: oldest unpaid invoices (by dueDate) get allocated first * - Zone enforcement: collector can only collect from subscribers in their assigned zones * - Every collection creates a balanced JE: DR 1030 Cash in Transit, CR 1100 AR * - Void uses reversing journal entries — no deletions * - Collector balances derived from transactions (never stored) * * ACCOUNT CODES USED: * 1030 — Cash in Transit (cash in collector's hands, not yet remitted) * 1100 — Accounts Receivable (AR) * * JOURNAL ENTRY PATTERN (Collection): * DR Cash in Transit (1030) [amount collected] * CR Accounts Receivable (1100) [AR reduced] * * ZONE SECURITY BOUNDARY: * Enforced at data layer — collector cannot collect from subscribers * outside their assigned zones. Throws (not empty return) if violated. */ import { Prisma, InvoiceStatus, JournalEntrySource, CollectionStatus } from "@prisma/client"; import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type TenantPrismaClient = any; // --------------------------------------------------------------------------- // Input types // --------------------------------------------------------------------------- export interface RecordCollectionInput { /** The collector recording this collection */ collectorId: string; /** The subscriber who paid */ subscriberId: string; /** Total cash received */ amount: number | string; /** When the cash was collected (economic date) */ collectionDate: Date; notes?: string; } export interface CollectionAllocationRecord { invoiceId: string; amount: Prisma.Decimal; } export interface RecordCollectionResult { collection: { id: string; tenantId: string; collectorId: string; subscriberId: string; amount: Prisma.Decimal; collectionDate: Date; status: CollectionStatus; notes: string | null; journalEntryId: string | null; createdAt: Date; updatedAt: Date; }; allocations: CollectionAllocationRecord[]; journalEntryId: string; } export interface VoidCollectionResult { collection: unknown; voidJournalEntryId: string; } export interface GetCollectionHistoryOptions { page?: number; pageSize?: number; } export interface GetCollectionHistoryResult { collections: unknown[]; total: number; page: number; pageSize: number; } // --------------------------------------------------------------------------- // recordCollection // --------------------------------------------------------------------------- /** * Record a cash collection from a subscriber by a field collector. * * FIFO allocation: oldest unpaid invoices (by dueDate) get allocated first. * Zone enforcement: collector must be assigned to the subscriber's zone. * * Collection JE: DR 1030 Cash in Transit, CR 1100 Accounts Receivable * * @throws Error if amount <= 0, subscriber not found, or zone violation */ export async function recordCollection( tenantPrisma: TenantPrismaClient, tenantId: string, input: RecordCollectionInput ): Promise { const { collectorId, subscriberId, amount: rawAmount, collectionDate, notes } = input; // Validate amount const amount = new Prisma.Decimal(rawAmount); if (amount.lessThanOrEqualTo(0)) { throw new Error("Collection amount must be greater than zero."); } // Validate subscriber exists within tenant const subscriber = await tenantPrisma.subscriber.findFirst({ where: { id: subscriberId }, select: { id: true, zoneId: true, firstName: true, lastName: true }, }); if (!subscriber) { throw new Error(`Subscriber not found: ${subscriberId}`); } // Zone enforcement: collector must be assigned to subscriber's zone if (!subscriber.zoneId) { throw new Error( `Subscriber ${subscriberId} is not assigned to any zone. ` + `Assign the subscriber to a zone before collecting.` ); } // Check collector is assigned to subscriber's zone const zoneAssignment = await tenantPrisma.zoneAssignment.findFirst({ where: { userId: collectorId, zoneId: subscriber.zoneId }, select: { id: true }, }); if (!zoneAssignment) { throw new Error( `Collector ${collectorId} is not assigned to the zone of subscriber ${subscriberId}. ` + `Zone enforcement violation — collection rejected.` ); } // Find required accounts const [transitAccount, arAccount] = await Promise.all([ tenantPrisma.account.findFirst({ where: { code: "1030" }, select: { id: true } }), tenantPrisma.account.findFirst({ where: { code: "1100" }, select: { id: true } }), ]); if (!transitAccount || !arAccount) { throw new Error( `Required accounts (1030, 1100) not found for this tenant.` ); } // FIFO: find unpaid/partial invoices ordered by dueDate ASC const unpaidInvoices = await tenantPrisma.invoice.findMany({ where: { subscriberId, status: { in: [InvoiceStatus.SENT, InvoiceStatus.PARTIAL, InvoiceStatus.OVERDUE] }, }, orderBy: { dueDate: "asc" }, select: { id: true, invoiceNumber: true, totalAmount: true, amountPaid: true, status: true, }, }); // FIFO allocation let remaining = new Prisma.Decimal(amount); const allocations: Array<{ invoiceId: string; amount: Prisma.Decimal; newAmountPaid: Prisma.Decimal; newStatus: InvoiceStatus; }> = []; for (const invoice of unpaidInvoices) { if (remaining.lessThanOrEqualTo(0)) break; const invoiceTotal = new Prisma.Decimal(invoice.totalAmount); const alreadyPaid = new Prisma.Decimal(invoice.amountPaid); const invoiceOutstanding = invoiceTotal.minus(alreadyPaid); if (invoiceOutstanding.lessThanOrEqualTo(0)) continue; const allocateAmount = remaining.lessThan(invoiceOutstanding) ? remaining : invoiceOutstanding; const newAmountPaid = alreadyPaid.plus(allocateAmount); const isFullyPaid = newAmountPaid.greaterThanOrEqualTo(invoiceTotal); allocations.push({ invoiceId: invoice.id, amount: allocateAmount, newAmountPaid, newStatus: isFullyPaid ? InvoiceStatus.PAID : InvoiceStatus.PARTIAL, }); remaining = remaining.minus(allocateAmount); } // Build journal entry lines: DR 1030 Cash in Transit, CR 1100 AR // Note: if amount exceeds all invoices, AR credit is capped at invoiced amount const totalAllocated = allocations.reduce( (sum, a) => sum.plus(a.amount), new Prisma.Decimal(0) ); const journalLines: Array<{ accountId: string; debit: number; credit: number; description?: string; }> = [ { accountId: transitAccount.id, debit: amount.toNumber(), credit: 0, description: `Cash collected from subscriber`, }, ]; if (totalAllocated.greaterThan(0)) { journalLines.push({ accountId: arAccount.id, debit: 0, credit: totalAllocated.toNumber(), description: `AR reduction: ${totalAllocated.toFixed(2)}`, }); } // If amount > invoices, the excess goes to 1030 but we still need to balance the JE. // For collections, excess cash stays in 1030 (collector holds it) — no credit balance. // The full amount DR 1030, CR 1100 for allocated portion only. // If no invoices to allocate against, DR 1030, CR 1100 with full amount (unapplied AR credit). if (totalAllocated.lessThanOrEqualTo(0) || totalAllocated.lessThan(amount)) { // Unallocated amount: still DR 1030 but we need a balancing CR // Use AR for the full amount — all cash collected reduces AR // Re-build with full amount on AR side journalLines.length = 0; journalLines.push( { accountId: transitAccount.id, debit: amount.toNumber(), credit: 0, description: `Cash collected from subscriber`, }, { accountId: arAccount.id, debit: 0, credit: amount.toNumber(), description: `AR reduction: full collection`, } ); } // Create journal entry (SYSTEM source — auto-posts) const journalEntry = await JournalEntryService.createEntry({ tenantPrisma, tenantId, date: collectionDate, description: `Collection from subscriber by collector`, source: JournalEntrySource.SYSTEM, referenceType: "Collection", referenceId: `${collectorId}-${subscriberId}-${collectionDate.toISOString()}`, createdById: collectorId, lines: journalLines, }); // Persist collection and allocations in a single transaction const result = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => { // Create collection record const collection = await tx.collection.create({ data: { tenantId, collectorId, subscriberId, amount, collectionDate, status: CollectionStatus.COMPLETED, notes: notes ?? null, journalEntryId: journalEntry.id, }, }); // Create allocations and update invoice statuses for (const alloc of allocations) { await tx.collectionAllocation.create({ data: { tenantId, collectionId: collection.id, invoiceId: alloc.invoiceId, amount: alloc.amount, }, }); // Update invoice amountPaid and status await tx.invoice.update({ where: { id: alloc.invoiceId, tenantId }, data: { amountPaid: alloc.newAmountPaid, status: alloc.newStatus, paidAt: alloc.newStatus === InvoiceStatus.PAID ? new Date() : null, }, }); } return collection; }); return { collection: result, allocations: allocations.map((a) => ({ invoiceId: a.invoiceId, amount: a.amount })), journalEntryId: journalEntry.id, }; } // --------------------------------------------------------------------------- // voidCollection // --------------------------------------------------------------------------- /** * Void a collection by: * 1. Reversing all invoice allocations (recalculate amountPaid and status) * 2. Creating a reversing journal entry for the original collection JE * 3. Setting collection status to VOIDED * * @throws Error if collection not found, already VOIDED, or JE missing */ export async function voidCollection( tenantPrisma: TenantPrismaClient, tenantId: string, collectionId: string, voidedById: string ): Promise { // Load the collection with allocations const collection = await tenantPrisma.collection.findFirst({ where: { id: collectionId }, include: { allocations: true }, }); if (!collection) { throw new Error(`Collection not found: ${collectionId}`); } if (collection.status === CollectionStatus.VOIDED) { throw new Error(`Collection ${collectionId} is already voided.`); } if (!collection.journalEntryId) { throw new Error(`Collection ${collectionId} has no associated journal entry — cannot void.`); } // Create reversing journal entry first const reversingEntry = await JournalEntryService.reverseEntry({ tenantPrisma, tenantId, entryId: collection.journalEntryId, reversedById: voidedById, description: `Void of collection ${collection.id}`, }); // Reverse allocations and update invoice statuses in a transaction const updatedCollection = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => { // Recalculate each invoice's amountPaid minus this collection's allocation for (const alloc of collection.allocations as Array<{ invoiceId: string; amount: Prisma.Decimal; }>) { const invoice = await tx.invoice.findFirst({ where: { id: alloc.invoiceId, tenantId }, select: { id: true, totalAmount: true, amountPaid: true, status: true }, }); if (!invoice) continue; const currentAmountPaid = new Prisma.Decimal(invoice.amountPaid); const allocAmount = new Prisma.Decimal(alloc.amount); const newAmountPaid = currentAmountPaid.minus(allocAmount); const safeAmountPaid = newAmountPaid.lessThan(0) ? new Prisma.Decimal(0) : newAmountPaid; const total = new Prisma.Decimal(invoice.totalAmount); let newStatus: InvoiceStatus; if (safeAmountPaid.lessThanOrEqualTo(0)) { newStatus = InvoiceStatus.SENT; } else if (safeAmountPaid.greaterThanOrEqualTo(total)) { newStatus = InvoiceStatus.PAID; } else { newStatus = InvoiceStatus.PARTIAL; } await tx.invoice.update({ where: { id: alloc.invoiceId, tenantId }, data: { amountPaid: safeAmountPaid, status: newStatus, paidAt: newStatus === InvoiceStatus.PAID ? new Date() : null, }, }); } // Mark collection as VOIDED const updated = await tx.collection.update({ where: { id: collectionId, tenantId }, data: { status: CollectionStatus.VOIDED, voidedAt: new Date(), voidedById, voidJournalEntryId: reversingEntry.id, }, include: { allocations: true }, }); return updated; }); return { collection: updatedCollection, voidJournalEntryId: reversingEntry.id, }; } // --------------------------------------------------------------------------- // getCollectionHistory // --------------------------------------------------------------------------- /** * Get paginated collection history for a subscriber or collector, ordered by collectionDate desc. */ export async function getCollectionHistory( tenantPrisma: TenantPrismaClient, filter: { subscriberId?: string; collectorId?: string }, options: GetCollectionHistoryOptions = {} ): Promise { const { page = 1, pageSize = 20 } = options; const skip = (page - 1) * pageSize; const where: Record = {}; if (filter.subscriberId) where.subscriberId = filter.subscriberId; if (filter.collectorId) where.collectorId = filter.collectorId; const [collections, total] = await Promise.all([ tenantPrisma.collection.findMany({ where, include: { allocations: { include: { invoice: { select: { id: true, invoiceNumber: true, totalAmount: true, amountPaid: true, status: true, }, }, }, }, collector: { select: { id: true, firstName: true, lastName: true }, }, subscriber: { select: { id: true, accountNumber: true, firstName: true, lastName: true }, }, }, orderBy: { collectionDate: "desc" }, skip, take: pageSize, }), tenantPrisma.collection.count({ where }), ]); return { collections, total, page, pageSize }; }