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