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,46 @@
import {
Controller, Get, Post, Put, Body, Param, Query, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AccountsService } from './accounts.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
@ApiTags('accounts')
@Controller('accounts')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class AccountsController {
constructor(private accountsService: AccountsService) {}
@Get()
@ApiOperation({ summary: 'List all accounts' })
findAll(@Query('fundType') fundType?: string) {
return this.accountsService.findAll(fundType);
}
@Get('trial-balance')
@ApiOperation({ summary: 'Get trial balance' })
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
return this.accountsService.getTrialBalance(asOfDate);
}
@Get(':id')
@ApiOperation({ summary: 'Get account by ID' })
findOne(@Param('id') id: string) {
return this.accountsService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a new account' })
create(@Body() dto: CreateAccountDto) {
return this.accountsService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update an account' })
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
return this.accountsService.update(id, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AccountsController } from './accounts.controller';
import { AccountsService } from './accounts.service';
@Module({
controllers: [AccountsController],
providers: [AccountsService],
exports: [AccountsService],
})
export class AccountsModule {}

View File

@@ -0,0 +1,107 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
@Injectable()
export class AccountsService {
constructor(private tenant: TenantService) {}
async findAll(fundType?: string) {
let sql = 'SELECT * FROM accounts WHERE is_active = true';
const params: any[] = [];
if (fundType) {
sql += ' AND fund_type = $1';
params.push(fundType);
}
sql += ' ORDER BY account_number';
return this.tenant.query(sql, params);
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM accounts WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Account not found');
return rows[0];
}
async create(dto: CreateAccountDto) {
const existing = await this.tenant.query(
'SELECT id FROM accounts WHERE account_number = $1',
[dto.accountNumber],
);
if (existing.length) {
throw new BadRequestException(`Account number ${dto.accountNumber} already exists`);
}
const rows = await this.tenant.query(
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
dto.accountNumber,
dto.name,
dto.description || null,
dto.accountType,
dto.fundType,
dto.parentAccountId || null,
dto.is1099Reportable || false,
],
);
return rows[0];
}
async update(id: string, dto: UpdateAccountDto) {
const account = await this.findOne(id);
if (account.is_system && dto.accountType && dto.accountType !== account.account_type) {
throw new BadRequestException('Cannot change type of system account');
}
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
if (!sets.length) return account;
sets.push(`updated_at = NOW()`);
params.push(id);
const rows = await this.tenant.query(
`UPDATE accounts SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
return rows[0];
}
async getTrialBalance(asOfDate?: string) {
const dateFilter = asOfDate
? `AND je.entry_date <= $1`
: '';
const params = asOfDate ? [asOfDate] : [];
const sql = `
SELECT
a.id, a.account_number, a.name, a.account_type, a.fund_type,
COALESCE(SUM(jel.debit), 0) as total_debits,
COALESCE(SUM(jel.credit), 0) as total_credits,
CASE
WHEN a.account_type IN ('asset', 'expense')
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
${dateFilter}
WHERE a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
ORDER BY a.account_number
`;
return this.tenant.query(sql, params);
}
}

View File

@@ -0,0 +1,35 @@
import { IsString, IsInt, IsOptional, IsBoolean, IsIn, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateAccountDto {
@ApiProperty({ example: 6600 })
@IsInt()
accountNumber: number;
@ApiProperty({ example: 'Equipment Repairs' })
@IsString()
name: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ example: 'expense', enum: ['asset', 'liability', 'equity', 'income', 'expense'] })
@IsIn(['asset', 'liability', 'equity', 'income', 'expense'])
accountType: string;
@ApiProperty({ example: 'operating', enum: ['operating', 'reserve'] })
@IsIn(['operating', 'reserve'])
fundType: string;
@ApiProperty({ required: false })
@IsUUID()
@IsOptional()
parentAccountId?: string;
@ApiProperty({ required: false, default: false })
@IsBoolean()
@IsOptional()
is1099Reportable?: boolean;
}

View File

@@ -0,0 +1,29 @@
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateAccountDto {
@ApiProperty({ required: false })
@IsString()
@IsOptional()
name?: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ required: false })
@IsIn(['asset', 'liability', 'equity', 'income', 'expense'])
@IsOptional()
accountType?: string;
@ApiProperty({ required: false })
@IsBoolean()
@IsOptional()
is1099Reportable?: boolean;
@ApiProperty({ required: false })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}