import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; interface JournalLineInput { accountCode: string; debit?: number; credit?: number; } // Maps payment method to CoA code suffix const METHOD_SUFFIX: Record = { cash: 'Cash', gcash: 'GCash', maya: 'Maya', bank_transfer: 'Bank', }; const METHOD_LABEL: Record = { cash: 'Cash', gcash: 'GCash', maya: 'Maya', bank_transfer: 'Bank Transfer', }; // Company-level CoA codes per method const COMPANY_COA: Record = { cash: '1010', gcash: '1020', maya: '1030', bank_transfer: '1040', }; @Injectable() export class JournalService { private readonly logger = new Logger(JournalService.name); constructor(private readonly prisma: PrismaService) {} /** * Creates a journal entry with debit/credit lines. */ async createEntry( tenantId: string, description: string, lines: JournalLineInput[], options?: { reference?: string; sourceType?: string; sourceId?: string; createdById?: string }, ) { const totalDebit = lines.reduce((s, l) => s + (l.debit || 0), 0); const totalCredit = lines.reduce((s, l) => s + (l.credit || 0), 0); if (Math.abs(totalDebit - totalCredit) > 0.01) { this.logger.warn(`Unbalanced entry: DR ${totalDebit} != CR ${totalCredit} — ${description}`); return null; } const resolvedLines = []; for (const line of lines) { const account = await this.prisma.chartOfAccount.findFirst({ where: { tenantId, code: line.accountCode }, }); if (!account) { this.logger.warn(`CoA ${line.accountCode} not found for tenant ${tenantId}`); return null; } resolvedLines.push({ accountId: account.id, debit: line.debit || 0, credit: line.credit || 0 }); } return this.prisma.journalEntry.create({ data: { tenantId, description, reference: options?.reference, sourceType: options?.sourceType, sourceId: options?.sourceId, createdById: options?.createdById, lines: { create: resolvedLines }, }, include: { lines: true }, }); } /** * Create custodial CoA accounts for a user. * Pattern: "UserName - Cash" with code 15XX where XX = sequential. */ async createCustodialAccounts(tenantId: string, userId: string, userName: string) { // Find next available code block in 15XX range const existing = await this.prisma.chartOfAccount.findMany({ where: { tenantId, code: { startsWith: '15' } }, orderBy: { code: 'desc' }, take: 1, }); const baseCode = existing[0] ? parseInt(existing[0].code) + 10 : 1500; const methods = ['Cash', 'GCash', 'Maya', 'Bank']; const created = []; for (let i = 0; i < methods.length; i++) { const code = String(baseCode + i); const name = `${userName} - ${methods[i]}`; const account = await this.prisma.chartOfAccount.upsert({ where: { tenantId_code: { tenantId, code } }, create: { tenantId, code, name, type: 'asset', isSystem: false }, update: {}, }); created.push(account); } return created; } /** * Get the custodial CoA code for a user + method. * Looks up "UserName - Cash/GCash/Maya/Bank" in the CoA. */ async getCustodialCode(tenantId: string, collectorId: string, method: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: collectorId } }); if (!user) return null; const suffix = METHOD_SUFFIX[method] || 'Cash'; const searchName = `${user.firstName} ${user.lastName} - ${suffix}`; const account = await this.prisma.chartOfAccount.findFirst({ where: { tenantId, name: searchName }, }); return account?.code || null; } /** * Payment collected → DR collector's custodial CoA, CR Accounts Receivable. * Money sits in collector's custody until remittance is approved. */ async journalForPayment( tenantId: string, paymentId: string, amount: number, method: string, context: { invoiceNumber?: string; clientName?: string; collectorId?: string; collectorName?: string }, ) { let debitCode: string; if (context.collectorId) { let custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method); if (!custodialCode && context.collectorName) { await this.createCustodialAccounts(tenantId, context.collectorId, context.collectorName); custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method); } debitCode = custodialCode || COMPANY_COA[method] || '1010'; } else { debitCode = COMPANY_COA[method] || '1010'; } const description = [ `Payment collected via ${METHOD_LABEL[method] || method}`, context.collectorName ? `by ${context.collectorName}` : '', context.clientName ? `from ${context.clientName}` : '', context.invoiceNumber ? `for ${context.invoiceNumber}` : '', ].filter(Boolean).join(' '); return this.createEntry(tenantId, description, [ { accountCode: debitCode, debit: amount }, { accountCode: '1100', credit: amount }, ], { reference: context.invoiceNumber, sourceType: 'payment', sourceId: paymentId }); } /** * Remittance approved → DR company CoA, CR collector's custodial CoA. * Clears money from collector custody into company books. */ async journalForRemittance( tenantId: string, remittanceId: string, collectorId: string, collectorName: string, paymentsByMethod: { method: string; total: number }[], ) { const lines: JournalLineInput[] = []; for (const pm of paymentsByMethod) { let custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method); // Auto-create custodial accounts if they don't exist yet if (!custodialCode) { this.logger.warn(`Custodial account missing for ${collectorName} (${pm.method}) — auto-creating`); await this.createCustodialAccounts(tenantId, collectorId, collectorName); custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method); } const companyCode = COMPANY_COA[pm.method] || '1010'; if (custodialCode) { lines.push({ accountCode: companyCode, debit: pm.total }); lines.push({ accountCode: custodialCode, credit: pm.total }); } else { this.logger.error(`Still no custodial account for ${collectorName} (${pm.method}) after auto-create`); } } if (lines.length === 0) { this.logger.warn(`No journal lines for remittance ${remittanceId} — skipping`); return null; } const totalAmount = paymentsByMethod.reduce((s, p) => s + p.total, 0); return this.createEntry( tenantId, `Remittance approved — ${collectorName} cleared PHP ${totalAmount.toLocaleString()}`, lines, { reference: `REM-${remittanceId.slice(0, 8)}`, sourceType: 'remittance', sourceId: remittanceId }, ); } /** Invoice issued → DR Accounts Receivable (1100), CR Service Revenue (4010) */ async journalForInvoice(tenantId: string, invoiceId: string, invoiceNumber: string, amount: number) { return this.createEntry(tenantId, `Invoice ${invoiceNumber} issued`, [ { accountCode: '1100', debit: amount }, { accountCode: '4010', credit: amount }, ], { reference: invoiceNumber, sourceType: 'invoice', sourceId: invoiceId }); } /** * Update CompanyAccount.balance when a journal entry affects cash accounts. * Called after remittance confirmation (increment) and expense approval (decrement). */ async updateCompanyAccountBalance( tenantId: string, coaCode: string, amount: number, direction: 'increment' | 'decrement', ) { const coa = await this.prisma.chartOfAccount.findFirst({ where: { tenantId, code: coaCode }, }); if (!coa) return; const account = await this.prisma.companyAccount.findFirst({ where: { tenantId, chartOfAccountId: coa.id, isActive: true }, }); if (!account) return; await this.prisma.companyAccount.update({ where: { id: account.id }, data: { balance: { [direction]: amount } }, }); } /** Expense approved → DR Expense Category, CR Cash (1010) */ async journalForExpense(tenantId: string, expenseId: string, amount: number, category: string) { const expenseAccountCode: Record = { utilities: '5010', salary: '5020', maintenance: '5030', transport: '5040', supplies: '5050', equipment: '5060', other: '5000', }; return this.createEntry(tenantId, `Expense: ${category}`, [ { accountCode: expenseAccountCode[category] || '5000', debit: amount }, { accountCode: '1010', credit: amount }, ], { sourceType: 'expense', sourceId: expenseId }); } }