Initial commit: HOA Financial Intelligence Platform MVP
Multi-tenant financial management platform for homeowner associations featuring: - NestJS backend with 16 modules (auth, accounts, transactions, budgets, units, invoices, payments, vendors, reserves, investments, capital projects, reports) - React + Mantine frontend with dashboard, CRUD pages, and financial reports - Schema-per-tenant PostgreSQL isolation with JWT-based tenant resolution - Docker Compose infrastructure (nginx, backend, frontend, postgres, redis) - Comprehensive seed data for Sunrise Valley HOA demo - 39 API endpoints with Swagger documentation - Double-entry bookkeeping with journal entries - Budget vs actual reporting and Sankey cash flow visualization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
191
backend/src/modules/journal-entries/journal-entries.service.ts
Normal file
191
backend/src/modules/journal-entries/journal-entries.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.tenant.query(
|
||||
`UPDATE journal_entries SET is_posted = true, posted_by = $1, posted_at = NOW() WHERE id = $2 RETURNING *`,
|
||||
[userId, id],
|
||||
);
|
||||
return this.findOne(result[0].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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user