initial: standalone repo from monorepo split
This commit is contained in:
259
src/accounting/journal.service.ts
Normal file
259
src/accounting/journal.service.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user