Files
HOA_Financial_Platform/backend/src/modules/journal-entries/journal-entries.service.ts
olsch01 32af961173 Fix monthly actuals: allow negative values and fix save/reconcile error
- Remove min={0} from NumberInput to allow negative actuals (refunds/corrections)
- Fix post() in journal-entries service: use id param directly instead of
  RETURNING result which returns [rows, count] in TypeORM QueryRunner
- Handle negative amounts in saveActuals(): negative expense credits the
  expense account, negative income debits the income account

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:51:33 -05:00

192 lines
6.4 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.*,
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);
}
}