initial: standalone repo from monorepo split
This commit is contained in:
45
src/accounting/accounting.controller.ts
Normal file
45
src/accounting/accounting.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Post, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AccountingService } from './accounting.service';
|
||||
import { CreateAccountDto } from './dto/create-coa.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('accounting')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('tenant_admin')
|
||||
export class AccountingController {
|
||||
constructor(private readonly accountingService: AccountingService) {}
|
||||
|
||||
@Get('chart-of-accounts')
|
||||
async getCoA(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getChartOfAccounts(user.tenantId);
|
||||
}
|
||||
|
||||
@Post('chart-of-accounts')
|
||||
async createAccount(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
|
||||
return this.accountingService.createAccount(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Delete('chart-of-accounts/:id')
|
||||
async deleteAccount(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.accountingService.deleteAccount(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('general-ledger')
|
||||
async getLedger(@CurrentUser() user: CurrentUserPayload, @Query('accountId') accountId?: string) {
|
||||
return this.accountingService.getGeneralLedger(user.tenantId, accountId);
|
||||
}
|
||||
|
||||
@Get('trial-balance')
|
||||
async getTrialBalance(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getTrialBalance(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
async getOverview(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getAccountingOverview(user.tenantId);
|
||||
}
|
||||
}
|
||||
11
src/accounting/accounting.module.ts
Normal file
11
src/accounting/accounting.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccountingController } from './accounting.controller';
|
||||
import { AccountingService } from './accounting.service';
|
||||
import { JournalService } from './journal.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountingController],
|
||||
providers: [AccountingService, JournalService],
|
||||
exports: [AccountingService, JournalService],
|
||||
})
|
||||
export class AccountingModule {}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
8
src/accounting/dto/create-coa.dto.ts
Normal file
8
src/accounting/dto/create-coa.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, MinLength, IsOptional, IsIn, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString() @MinLength(3) code: string;
|
||||
@IsString() @MinLength(2) name: string;
|
||||
@IsString() @IsIn(['asset', 'liability', 'equity', 'revenue', 'expense']) type: string;
|
||||
@IsOptional() @IsUUID() parentId?: string;
|
||||
}
|
||||
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