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