feat(03-02): Collector service, remittance service, report service, APIs, and tests
- 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
This commit is contained in:
475
src/lib/services/collector-service.ts
Normal file
475
src/lib/services/collector-service.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user