260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
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<string, string> = {
|
|
cash: 'Cash',
|
|
gcash: 'GCash',
|
|
maya: 'Maya',
|
|
bank_transfer: 'Bank',
|
|
};
|
|
|
|
const METHOD_LABEL: Record<string, string> = {
|
|
cash: 'Cash',
|
|
gcash: 'GCash',
|
|
maya: 'Maya',
|
|
bank_transfer: 'Bank Transfer',
|
|
};
|
|
|
|
// Company-level CoA codes per method
|
|
const COMPANY_COA: Record<string, string> = {
|
|
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<string | null> {
|
|
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<string, string> = {
|
|
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 });
|
|
}
|
|
}
|