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:
2026-02-17 19:58:04 -05:00
commit 243770cea5
118 changed files with 8569 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import {
IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsUUID, IsIn, IsDateString,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class JournalEntryLineDto {
@ApiProperty()
@IsUUID()
accountId: string;
@ApiProperty({ example: 350.00 })
@IsNumber()
@IsOptional()
debit?: number;
@ApiProperty({ example: 0 })
@IsNumber()
@IsOptional()
credit?: number;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
memo?: string;
}
export class CreateJournalEntryDto {
@ApiProperty({ example: '2026-02-15' })
@IsDateString()
entryDate: string;
@ApiProperty({ example: 'Monthly landscaping payment' })
@IsString()
description: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
referenceNumber?: string;
@ApiProperty({ example: 'manual', required: false })
@IsIn(['manual', 'assessment', 'payment', 'late_fee', 'transfer', 'adjustment', 'closing', 'opening_balance'])
@IsOptional()
entryType?: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
sourceType?: string;
@ApiProperty({ required: false })
@IsUUID()
@IsOptional()
sourceId?: string;
@ApiProperty({ type: [JournalEntryLineDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => JournalEntryLineDto)
lines: JournalEntryLineDto[];
}

View File

@@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class VoidJournalEntryDto {
@ApiProperty({ example: 'Duplicate entry' })
@IsString()
reason: string;
}

View File

@@ -0,0 +1,51 @@
import {
Controller, Get, Post, Body, Param, Query, UseGuards, Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JournalEntriesService } from './journal-entries.service';
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
@ApiTags('journal-entries')
@Controller('journal-entries')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class JournalEntriesController {
constructor(private jeService: JournalEntriesService) {}
@Get()
@ApiOperation({ summary: 'List journal entries' })
findAll(
@Query('from') from?: string,
@Query('to') to?: string,
@Query('accountId') accountId?: string,
@Query('type') type?: string,
) {
return this.jeService.findAll({ from, to, accountId, type });
}
@Get(':id')
@ApiOperation({ summary: 'Get journal entry by ID' })
findOne(@Param('id') id: string) {
return this.jeService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a journal entry' })
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
return this.jeService.create(dto, req.user.sub);
}
@Post(':id/post')
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
post(@Param('id') id: string, @Request() req: any) {
return this.jeService.post(id, req.user.sub);
}
@Post(':id/void')
@ApiOperation({ summary: 'Void a journal entry' })
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
return this.jeService.void(id, req.user.sub, dto.reason);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { JournalEntriesController } from './journal-entries.controller';
import { JournalEntriesService } from './journal-entries.service';
import { FiscalPeriodsModule } from '../fiscal-periods/fiscal-periods.module';
@Module({
imports: [FiscalPeriodsModule],
controllers: [JournalEntriesController],
providers: [JournalEntriesService],
exports: [JournalEntriesService],
})
export class JournalEntriesModule {}

View 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);
}
}