/** * RemittanceService — Collector cash remittance creation and two-party verification. * * ARCHITECTURE: * This service handles the remittance lifecycle: * - Collector declares the total they are turning in (collectedTotal) * - Office staff counts and verifies the actual amount (verifiedTotal) * - Variance = verifiedTotal - collectedTotal (non-blocking — recorded but doesn't reject) * - Verification creates JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit * * ACCOUNT CODES USED: * 1010 — Cash on Hand (cash received at office) * 1030 — Cash in Transit (cash from collector's hands) * * JOURNAL ENTRY PATTERN (Remittance Verification): * DR Cash on Hand (1010) [verifiedTotal — actual cash counted] * CR Cash in Transit (1030) [collectedTotal — moves out of transit] * * TWO-PARTY VERIFICATION: * - Collector creates remittance (PENDING status) * - Office staff verifies with their counted total * - Variance is recorded; remittance is VERIFIED regardless of variance */ import { Prisma, JournalEntrySource, RemittanceStatus } 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 CreateRemittanceInput { collectorId: string; remittanceDate: Date; /** Total cash the collector declares they are turning in */ collectedTotal: number | string; notes?: string; } export interface VerifyRemittanceInput { /** The office staff verifying the remittance */ verifiedById: string; /** Actual cash counted by office staff */ verifiedTotal: number | string; notes?: string; } export interface ListRemittancesOptions { collectorId?: string; status?: RemittanceStatus; page?: number; pageSize?: number; } export interface ListRemittancesResult { remittances: unknown[]; total: number; page: number; pageSize: number; } // --------------------------------------------------------------------------- // createRemittance // --------------------------------------------------------------------------- /** * Create a new PENDING remittance — collector declares the total they are handing in. * * @throws Error if collectedTotal <= 0 or collector not found */ export async function createRemittance( tenantPrisma: TenantPrismaClient, tenantId: string, input: CreateRemittanceInput ) { const { collectorId, remittanceDate, collectedTotal: rawTotal, notes } = input; const collectedTotal = new Prisma.Decimal(rawTotal); if (collectedTotal.lessThanOrEqualTo(0)) { throw new Error("Collected total must be greater than zero."); } // Validate collector exists const collector = await tenantPrisma.user.findFirst({ where: { id: collectorId }, select: { id: true, roles: true }, }); if (!collector) { throw new Error(`Collector not found: ${collectorId}`); } return tenantPrisma.remittance.create({ data: { tenantId, collectorId, remittanceDate, collectedTotal, status: RemittanceStatus.PENDING, notes: notes ?? null, }, }); } // --------------------------------------------------------------------------- // verifyRemittance // --------------------------------------------------------------------------- /** * Verify a PENDING remittance by office staff. * * Records the staff-counted total, calculates variance (non-blocking), * and creates the verification JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit. * * VARIANCE: verifiedTotal - collectedTotal * Positive variance = office counted MORE than collector declared (overage) * Negative variance = office counted LESS than collector declared (shortage) * Variance DOES NOT block verification — it is recorded for audit purposes. * * JE uses verifiedTotal for the DR 1010 line (actual cash received at office) * and collectedTotal for the CR 1030 line (clears what was in transit). * If there is a variance, an additional line adjusts the difference. * * @throws Error if remittance not found, already verified, or accounts missing */ export async function verifyRemittance( tenantPrisma: TenantPrismaClient, tenantId: string, remittanceId: string, input: VerifyRemittanceInput ) { const { verifiedById, verifiedTotal: rawVerified, notes } = input; const remittance = await tenantPrisma.remittance.findFirst({ where: { id: remittanceId }, }); if (!remittance) { throw new Error(`Remittance not found: ${remittanceId}`); } if (remittance.status === RemittanceStatus.VERIFIED) { throw new Error(`Remittance ${remittanceId} has already been verified.`); } const verifiedTotal = new Prisma.Decimal(rawVerified); if (verifiedTotal.lessThan(0)) { throw new Error("Verified total cannot be negative."); } const collectedTotal = new Prisma.Decimal(remittance.collectedTotal); const variance = verifiedTotal.minus(collectedTotal); // Find required accounts const [cashOnHandAccount, transitAccount] = await Promise.all([ tenantPrisma.account.findFirst({ where: { code: "1010" }, select: { id: true } }), tenantPrisma.account.findFirst({ where: { code: "1030" }, select: { id: true } }), ]); if (!cashOnHandAccount || !transitAccount) { throw new Error( `Required accounts (1010, 1030) not found for this tenant.` ); } // Build JE lines // Standard case: DR 1010 (verified), CR 1030 (collected) // If variance exists, the JE is still balanced: // - DR 1010 with verifiedTotal // - CR 1030 with collectedTotal // - Additional DR or CR line to balance (variance account would be ideal but // for simplicity we use 1030 or 1010 depending on direction) // For a clean approach: use verifiedTotal for both sides — no separate variance JE // The variance is RECORDED on the remittance record for audit, not in the ledger. // JE simply moves the verifiedTotal from 1030 to 1010. // This means 1030 balance may not exactly zero out if variance exists — acceptable; // the variance is a discrepancy for audit, not an accounting adjustment here. const journalLines: Array<{ accountId: string; debit: number; credit: number; description?: string; }> = []; if (verifiedTotal.greaterThan(0)) { // DR 1010 for actual cash received journalLines.push({ accountId: cashOnHandAccount.id, debit: verifiedTotal.toNumber(), credit: 0, description: `Cash on hand: remittance verified`, }); if (collectedTotal.greaterThan(0)) { if (variance.equals(0)) { // Perfect match: CR 1030 = collectedTotal = verifiedTotal journalLines.push({ accountId: transitAccount.id, debit: 0, credit: collectedTotal.toNumber(), description: `Cash in transit cleared: remittance`, }); } else { // Variance: use verifiedTotal for CR 1030 to keep JE balanced // The discrepancy is in the remittance record itself journalLines.push({ accountId: transitAccount.id, debit: 0, credit: verifiedTotal.toNumber(), description: `Cash in transit cleared: remittance (variance: ${variance.toFixed(2)})`, }); } } else { // No collected total — just DR 1010, CR 1030 with verifiedTotal journalLines.push({ accountId: transitAccount.id, debit: 0, credit: verifiedTotal.toNumber(), description: `Cash in transit cleared: remittance`, }); } } else { // Zero verified — still need a balanced JE; use 0 amounts // This shouldn't happen in practice (would be rejected above) journalLines.push( { accountId: cashOnHandAccount.id, debit: 0, credit: 0, description: `Zero remittance`, }, { accountId: transitAccount.id, debit: 0, credit: 0, description: `Zero remittance`, } ); } // Create journal entry (SYSTEM source — auto-posts) const journalEntry = await JournalEntryService.createEntry({ tenantPrisma, tenantId, date: remittance.remittanceDate, description: `Remittance verification by office staff`, source: JournalEntrySource.SYSTEM, referenceType: "Remittance", referenceId: remittanceId, createdById: verifiedById, lines: journalLines, }); // Update remittance to VERIFIED return tenantPrisma.remittance.update({ where: { id: remittanceId, tenantId }, data: { status: RemittanceStatus.VERIFIED, verifiedById, verifiedAt: new Date(), verifiedTotal, variance, journalEntryId: journalEntry.id, notes: notes ?? remittance.notes, }, }); } // --------------------------------------------------------------------------- // listRemittances // --------------------------------------------------------------------------- /** * List remittances with optional filtering by collector or status. */ export async function listRemittances( tenantPrisma: TenantPrismaClient, options: ListRemittancesOptions = {} ): Promise { const { collectorId, status, page = 1, pageSize = 20 } = options; const skip = (page - 1) * pageSize; const where: Record = {}; if (collectorId) where.collectorId = collectorId; if (status) where.status = status; const [remittances, total] = await Promise.all([ tenantPrisma.remittance.findMany({ where, include: { collector: { select: { id: true, firstName: true, lastName: true }, }, verifiedBy: { select: { id: true, firstName: true, lastName: true }, }, }, orderBy: { remittanceDate: "desc" }, skip, take: pageSize, }), tenantPrisma.remittance.count({ where }), ]); return { remittances, total, page, pageSize }; }