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:
kevin-asprec
2026-03-05 07:54:39 +08:00
parent 0967fc23fd
commit a72aaa987d
11 changed files with 2412 additions and 0 deletions

View 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 };
}