The previous aggregation used simple SUM(debit)/SUM(credit) which always produced equal values for balanced entries. This was misleading for entries with income/expense lines (e.g., monthly actuals). Now, when an entry has income/expense lines, the totals reflect only P&L account activity (expenses as debits, income as credits), excluding the cash offset. For balance-sheet-only entries (opening balances, adjustments), the full entry totals are shown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
202 lines
7.0 KiB
TypeScript
202 lines
7.0 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
import { TenantService } from '../../database/tenant.service';
|
|
import { FiscalPeriodsService } from '../fiscal-periods/fiscal-periods.service';
|
|
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
|
|
|
@Injectable()
|
|
export class JournalEntriesService {
|
|
constructor(
|
|
private tenant: TenantService,
|
|
private fiscalPeriodsService: FiscalPeriodsService,
|
|
) {}
|
|
|
|
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
|
let sql = `
|
|
SELECT je.*,
|
|
CASE
|
|
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
|
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
|
|
ELSE COALESCE(SUM(jel.debit), 0)
|
|
END as total_debit,
|
|
CASE
|
|
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
|
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
|
|
ELSE COALESCE(SUM(jel.credit), 0)
|
|
END as total_credit,
|
|
json_agg(json_build_object(
|
|
'id', jel.id, 'account_id', jel.account_id,
|
|
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
|
'account_name', a.name, 'account_number', a.account_number
|
|
)) as lines
|
|
FROM journal_entries je
|
|
LEFT JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id
|
|
LEFT JOIN accounts a ON a.id = jel.account_id
|
|
WHERE 1=1
|
|
`;
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
|
|
if (filters.from) {
|
|
sql += ` AND je.entry_date >= $${idx++}`;
|
|
params.push(filters.from);
|
|
}
|
|
if (filters.to) {
|
|
sql += ` AND je.entry_date <= $${idx++}`;
|
|
params.push(filters.to);
|
|
}
|
|
if (filters.accountId) {
|
|
sql += ` AND je.id IN (SELECT journal_entry_id FROM journal_entry_lines WHERE account_id = $${idx++})`;
|
|
params.push(filters.accountId);
|
|
}
|
|
if (filters.type) {
|
|
sql += ` AND je.entry_type = $${idx++}`;
|
|
params.push(filters.type);
|
|
}
|
|
|
|
sql += ' GROUP BY je.id ORDER BY je.entry_date DESC, je.created_at DESC';
|
|
return this.tenant.query(sql, params);
|
|
}
|
|
|
|
async findOne(id: string) {
|
|
const rows = await this.tenant.query(
|
|
`SELECT je.*,
|
|
json_agg(json_build_object(
|
|
'id', jel.id, 'account_id', jel.account_id,
|
|
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
|
'account_name', a.name, 'account_number', a.account_number
|
|
)) as lines
|
|
FROM journal_entries je
|
|
LEFT JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id
|
|
LEFT JOIN accounts a ON a.id = jel.account_id
|
|
WHERE je.id = $1
|
|
GROUP BY je.id`,
|
|
[id],
|
|
);
|
|
if (!rows.length) throw new NotFoundException('Journal entry not found');
|
|
return rows[0];
|
|
}
|
|
|
|
async create(dto: CreateJournalEntryDto, userId: string) {
|
|
// Validate debits = credits
|
|
const totalDebits = dto.lines.reduce((sum, l) => sum + (l.debit || 0), 0);
|
|
const totalCredits = dto.lines.reduce((sum, l) => sum + (l.credit || 0), 0);
|
|
|
|
if (Math.abs(totalDebits - totalCredits) > 0.001) {
|
|
throw new BadRequestException(
|
|
`Debits ($${totalDebits.toFixed(2)}) must equal credits ($${totalCredits.toFixed(2)})`,
|
|
);
|
|
}
|
|
|
|
if (dto.lines.length < 2) {
|
|
throw new BadRequestException('Journal entry must have at least 2 lines');
|
|
}
|
|
|
|
// Find or create fiscal period
|
|
const entryDate = new Date(dto.entryDate);
|
|
const fp = await this.fiscalPeriodsService.findOrCreate(
|
|
entryDate.getFullYear(),
|
|
entryDate.getMonth() + 1,
|
|
);
|
|
if (fp.status === 'locked') {
|
|
throw new BadRequestException('Cannot post to a locked fiscal period');
|
|
}
|
|
if (fp.status === 'closed') {
|
|
throw new BadRequestException('Cannot post to a closed fiscal period');
|
|
}
|
|
|
|
// Create journal entry
|
|
const jeRows = await this.tenant.query(
|
|
`INSERT INTO journal_entries (entry_date, description, reference_number, entry_type, fiscal_period_id, source_type, source_id, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
|
[
|
|
dto.entryDate,
|
|
dto.description,
|
|
dto.referenceNumber || null,
|
|
dto.entryType || 'manual',
|
|
fp.id,
|
|
dto.sourceType || null,
|
|
dto.sourceId || null,
|
|
userId,
|
|
],
|
|
);
|
|
const je = jeRows[0];
|
|
|
|
// Create lines
|
|
for (const line of dto.lines) {
|
|
await this.tenant.query(
|
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[je.id, line.accountId, line.debit || 0, line.credit || 0, line.memo || null],
|
|
);
|
|
}
|
|
|
|
return this.findOne(je.id);
|
|
}
|
|
|
|
async post(id: string, userId: string) {
|
|
const je = await this.findOne(id);
|
|
if (je.is_posted) throw new BadRequestException('Already posted');
|
|
if (je.is_void) throw new BadRequestException('Cannot post a voided entry');
|
|
|
|
// Update account balances
|
|
for (const line of je.lines) {
|
|
const debit = parseFloat(line.debit) || 0;
|
|
const credit = parseFloat(line.credit) || 0;
|
|
const netAmount = debit - credit;
|
|
|
|
await this.tenant.query(
|
|
`UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`,
|
|
[netAmount, line.account_id],
|
|
);
|
|
}
|
|
|
|
await this.tenant.query(
|
|
`UPDATE journal_entries SET is_posted = true, posted_by = $1, posted_at = NOW() WHERE id = $2`,
|
|
[userId, id],
|
|
);
|
|
return this.findOne(id);
|
|
}
|
|
|
|
async void(id: string, userId: string, reason: string) {
|
|
const je = await this.findOne(id);
|
|
if (!je.is_posted) throw new BadRequestException('Cannot void an unposted entry');
|
|
if (je.is_void) throw new BadRequestException('Already voided');
|
|
|
|
// Reverse account balances
|
|
for (const line of je.lines) {
|
|
const debit = parseFloat(line.debit) || 0;
|
|
const credit = parseFloat(line.credit) || 0;
|
|
const reverseAmount = credit - debit;
|
|
|
|
await this.tenant.query(
|
|
`UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`,
|
|
[reverseAmount, line.account_id],
|
|
);
|
|
}
|
|
|
|
await this.tenant.query(
|
|
`UPDATE journal_entries SET is_void = true, voided_by = $1, voided_at = NOW(), void_reason = $2 WHERE id = $3`,
|
|
[userId, reason, id],
|
|
);
|
|
|
|
// Create reversing entry
|
|
const reverseDto: CreateJournalEntryDto = {
|
|
entryDate: new Date().toISOString().split('T')[0],
|
|
description: `VOID: ${je.description}`,
|
|
referenceNumber: `VOID-${je.reference_number || je.id.slice(0, 8)}`,
|
|
entryType: 'adjustment',
|
|
lines: je.lines.map((l: any) => ({
|
|
accountId: l.account_id,
|
|
debit: parseFloat(l.credit) || 0,
|
|
credit: parseFloat(l.debit) || 0,
|
|
memo: `Reversal of voided entry`,
|
|
})),
|
|
};
|
|
|
|
const reversing = await this.create(reverseDto, userId);
|
|
await this.post(reversing.id, userId);
|
|
|
|
return this.findOne(id);
|
|
}
|
|
}
|