- collector-service.ts: recordCollection (FIFO, zone enforcement, DR 1030/CR 1100 JE), voidCollection (reversing JE), getCollectionHistory - remittance-service.ts: createRemittance, verifyRemittance (DR 1010/CR 1030, variance non-blocking), listRemittances - collection-report-service.ts: getDailyCollectionSummary, getCollectorCollectionDetail - 6 API routes: POST/GET /collections, GET /collections/[id], POST /collections/[id]/void, POST/GET /remittances, POST /remittances/[id]/verify, GET /reports/collections - 26 integration tests: 13 collector (FIFO, zone enforcement, JE verification, void, cross-tenant) + 13 remittance (variance, JE accounts, double-verify rejection) - All 26 tests pass
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
/**
|
|
* 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<RecordCollectionResult> {
|
|
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<VoidCollectionResult> {
|
|
// 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<GetCollectionHistoryResult> {
|
|
const { page = 1, pageSize = 20 } = options;
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const where: Record<string, unknown> = {};
|
|
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 };
|
|
}
|