Files
fiberops-api/src/accounting/accounting.service.ts
2026-04-13 09:36:55 +08:00

210 lines
8.1 KiB
TypeScript

import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAccountDto } from './dto/create-coa.dto';
// Default Chart of Accounts for new tenants
const DEFAULT_COA = [
{ code: '1000', name: 'Assets', type: 'asset', isSystem: true },
{ code: '1010', name: 'Cash on Hand', type: 'asset', isSystem: true },
{ code: '1020', name: 'GCash Business', type: 'asset', isSystem: true },
{ code: '1030', name: 'Maya Business', type: 'asset', isSystem: true },
{ code: '1040', name: 'Bank Account', type: 'asset', isSystem: true },
{ code: '1100', name: 'Accounts Receivable', type: 'asset', isSystem: true },
{ code: '1200', name: 'Equipment', type: 'asset', isSystem: true },
{ code: '2000', name: 'Liabilities', type: 'liability', isSystem: true },
{ code: '2010', name: 'Accounts Payable', type: 'liability', isSystem: true },
{ code: '3000', name: 'Equity', type: 'equity', isSystem: true },
{ code: '3010', name: 'Owner\'s Equity', type: 'equity', isSystem: true },
{ code: '3020', name: 'Retained Earnings', type: 'equity', isSystem: true },
{ code: '4000', name: 'Revenue', type: 'revenue', isSystem: true },
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', isSystem: true },
{ code: '4020', name: 'Installation Fees', type: 'revenue', isSystem: true },
{ code: '5000', name: 'Expenses', type: 'expense', isSystem: true },
{ code: '5010', name: 'Utilities Expense', type: 'expense', isSystem: true },
{ code: '5020', name: 'Salaries Expense', type: 'expense', isSystem: true },
{ code: '5030', name: 'Maintenance Expense', type: 'expense', isSystem: true },
{ code: '5040', name: 'Transport Expense', type: 'expense', isSystem: true },
{ code: '5050', name: 'Supplies Expense', type: 'expense', isSystem: true },
{ code: '5060', name: 'Equipment Expense', type: 'expense', isSystem: true },
];
@Injectable()
export class AccountingService {
constructor(private readonly prisma: PrismaService) {}
async getChartOfAccounts(tenantId: string) {
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
orderBy: { code: 'asc' },
});
// Auto-seed if empty
if (accounts.length === 0) {
await this.seedDefaultAccounts(tenantId);
return this.prisma.chartOfAccount.findMany({ where: { tenantId }, orderBy: { code: 'asc' } });
}
return accounts;
}
async seedDefaultAccounts(tenantId: string) {
for (const acc of DEFAULT_COA) {
await this.prisma.chartOfAccount.upsert({
where: { tenantId_code: { tenantId, code: acc.code } },
create: { tenantId, ...acc },
update: {},
});
}
}
async createAccount(tenantId: string, dto: CreateAccountDto) {
const existing = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, code: dto.code },
});
if (existing) throw new ConflictException('Account code already exists');
return this.prisma.chartOfAccount.create({
data: { tenantId, code: dto.code, name: dto.name, type: dto.type, parentId: dto.parentId },
});
}
async deleteAccount(tenantId: string, id: string) {
const account = await this.prisma.chartOfAccount.findFirst({ where: { id, tenantId } });
if (!account) throw new NotFoundException('Account not found');
if (account.isSystem) throw new BadRequestException('Cannot delete system account');
const hasEntries = await this.prisma.journalLine.count({ where: { accountId: id } });
if (hasEntries > 0) throw new BadRequestException('Cannot delete account with journal entries');
await this.prisma.chartOfAccount.delete({ where: { id } });
return { deleted: true };
}
async getGeneralLedger(tenantId: string, accountId?: string) {
const where: any = {};
if (accountId) {
where.accountId = accountId;
} else {
where.journalEntry = { tenantId };
}
return this.prisma.journalLine.findMany({
where,
include: {
account: { select: { code: true, name: true, type: true } },
journalEntry: { select: { entryDate: true, description: true, reference: true } },
},
orderBy: { journalEntry: { entryDate: 'desc' } },
take: 100,
});
}
async getTrialBalance(tenantId: string) {
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
include: {
journalLines: { select: { debit: true, credit: true } },
},
orderBy: { code: 'asc' },
});
return accounts.map((a) => {
const totalDebit = a.journalLines.reduce((s, l) => s + Number(l.debit), 0);
const totalCredit = a.journalLines.reduce((s, l) => s + Number(l.credit), 0);
return {
id: a.id,
code: a.code,
name: a.name,
type: a.type,
debit: totalDebit,
credit: totalCredit,
balance: totalDebit - totalCredit,
};
}).filter((a) => a.debit > 0 || a.credit > 0);
}
async getAccountingOverview(tenantId: string) {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
// All accounts with their full journal lines
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
include: {
journalLines: {
select: { debit: true, credit: true, journalEntry: { select: { entryDate: true } } },
},
},
orderBy: { code: 'asc' },
});
// Compute balances by type (from all journal entries)
const byType: Record<string, { debit: number; credit: number; balance: number }> = {};
const cashAccounts: { code: string; name: string; balance: number }[] = [];
for (const acc of accounts) {
const totalDebit = acc.journalLines.reduce((s, l) => s + Number(l.debit), 0);
const totalCredit = acc.journalLines.reduce((s, l) => s + Number(l.credit), 0);
const balance = totalDebit - totalCredit;
if (!byType[acc.type]) byType[acc.type] = { debit: 0, credit: 0, balance: 0 };
byType[acc.type].debit += totalDebit;
byType[acc.type].credit += totalCredit;
byType[acc.type].balance += balance;
// Track individual cash accounts (codes 1010-1040)
if (['1010', '1020', '1030', '1040'].includes(acc.code)) {
cashAccounts.push({ code: acc.code, name: acc.name, balance });
}
}
// Monthly income (revenue credits this month)
const monthlyRevenue = accounts
.filter((a) => a.type === 'revenue')
.reduce((sum, acc) => {
const monthCredits = acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.credit), 0);
return sum + monthCredits;
}, 0);
// Monthly expenses (expense debits this month)
const monthlyExpenses = accounts
.filter((a) => a.type === 'expense')
.reduce((sum, acc) => {
const monthDebits = acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.debit), 0);
return sum + monthDebits;
}, 0);
// Expense breakdown by category this month
const expenseBreakdown = accounts
.filter((a) => a.type === 'expense')
.map((acc) => ({
code: acc.code,
name: acc.name,
amount: acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.debit), 0),
}))
.filter((e) => e.amount > 0)
.sort((a, b) => b.amount - a.amount);
return {
totalAssets: byType['asset']?.balance || 0,
totalLiabilities: byType['liability']?.balance || 0,
totalEquity: byType['equity']?.balance || 0,
totalRevenue: byType['revenue']?.credit || 0,
totalExpenses: byType['expense']?.debit || 0,
accountsReceivable: accounts.find((a) => a.code === '1100')?.journalLines.reduce((s, l) => s + Number(l.debit) - Number(l.credit), 0) || 0,
cashAccounts,
cashOnHand: cashAccounts.reduce((s, a) => s + a.balance, 0),
monthlyRevenue,
monthlyExpenses,
netIncome: monthlyRevenue - monthlyExpenses,
expenseBreakdown,
};
}
}