initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:55 +08:00
commit 4a8f1e9318
157 changed files with 9198 additions and 0 deletions

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