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:
46
backend/src/modules/accounts/accounts.controller.ts
Normal file
46
backend/src/modules/accounts/accounts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/accounts/accounts.module.ts
Normal file
10
backend/src/modules/accounts/accounts.module.ts
Normal 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 {}
|
||||
107
backend/src/modules/accounts/accounts.service.ts
Normal file
107
backend/src/modules/accounts/accounts.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
35
backend/src/modules/accounts/dto/create-account.dto.ts
Normal file
35
backend/src/modules/accounts/dto/create-account.dto.ts
Normal 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;
|
||||
}
|
||||
29
backend/src/modules/accounts/dto/update-account.dto.ts
Normal file
29
backend/src/modules/accounts/dto/update-account.dto.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user