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

View File

@@ -0,0 +1,50 @@
import {
Controller,
Post,
Body,
UseGuards,
Request,
Get,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { SwitchOrgDto } from './dto/switch-org.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
@UseGuards(AuthGuard('local'))
async login(@Request() req: any, @Body() _dto: LoginDto) {
return this.authService.login(req.user);
}
@Get('profile')
@ApiOperation({ summary: 'Get current user profile' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async getProfile(@Request() req: any) {
return this.authService.getProfile(req.user.sub);
}
@Post('switch-org')
@ApiOperation({ summary: 'Switch active organization' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
return this.authService.switchOrganization(req.user.sub, dto.organizationId);
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '24h' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, LocalStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,136 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async register(dto: RegisterDto) {
const existing = await this.usersService.findByEmail(dto.email);
if (existing) {
throw new ConflictException('Email already registered');
}
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = await this.usersService.create({
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
});
return this.generateTokenResponse(user);
}
async validateUser(email: string, password: string): Promise<User> {
const user = await this.usersService.findByEmail(email);
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid credentials');
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
async login(user: User) {
await this.usersService.updateLastLogin(user.id);
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
return this.generateTokenResponse(fullUser || user);
}
async getProfile(userId: string) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
throw new UnauthorizedException('User not found');
}
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
organizations: user.userOrganizations?.map((uo) => ({
id: uo.organization.id,
name: uo.organization.name,
role: uo.role,
})) || [],
};
}
async switchOrganization(userId: string, organizationId: string) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
throw new UnauthorizedException('User not found');
}
const membership = user.userOrganizations?.find(
(uo) => uo.organizationId === organizationId && uo.isActive,
);
if (!membership) {
throw new UnauthorizedException('Not a member of this organization');
}
const payload = {
sub: user.id,
email: user.email,
orgId: membership.organizationId,
orgSchema: membership.organization.schemaName,
role: membership.role,
};
return {
accessToken: this.jwtService.sign(payload),
organization: {
id: membership.organization.id,
name: membership.organization.name,
role: membership.role,
},
};
}
private generateTokenResponse(user: User) {
const orgs = user.userOrganizations || [];
const defaultOrg = orgs[0];
const payload: Record<string, any> = {
sub: user.id,
email: user.email,
};
if (defaultOrg) {
payload.orgId = defaultOrg.organizationId;
payload.orgSchema = defaultOrg.organization?.schemaName;
payload.role = defaultOrg.role;
}
return {
accessToken: this.jwtService.sign(payload),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
schemaName: uo.organization?.schemaName,
role: uo.role,
})),
};
}
}

View File

@@ -0,0 +1,12 @@
import { IsEmail, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'treasurer@sunrisevalley.org' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecurePass123!' })
@IsString()
password: string;
}

View File

@@ -0,0 +1,23 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'treasurer@sunrisevalley.org' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecurePass123!' })
@IsString()
@MinLength(8)
password: string;
@ApiProperty({ example: 'Jane', required: false })
@IsString()
@IsOptional()
firstName?: string;
@ApiProperty({ example: 'Doe', required: false })
@IsString()
@IsOptional()
lastName?: string;
}

View File

@@ -0,0 +1,8 @@
import { IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SwitchOrgDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
@IsUUID()
organizationId: string;
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
}
async validate(payload: any) {
return {
sub: payload.sub,
email: payload.email,
orgId: payload.orgId,
orgSchema: payload.orgSchema,
role: payload.role,
};
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: 'email' });
}
async validate(email: string, password: string) {
return this.authService.validateUser(email, password);
}
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, Put, Body, Param, Query, UseGuards, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { BudgetsService } from './budgets.service';
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
@ApiTags('budgets')
@Controller('budgets')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class BudgetsController {
constructor(private budgetsService: BudgetsService) {}
@Get(':year')
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
findByYear(@Param('year', ParseIntPipe) year: number) {
return this.budgetsService.findByYear(year);
}
@Put(':year')
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
upsert(
@Param('year', ParseIntPipe) year: number,
@Body() budgets: UpsertBudgetDto[],
) {
return this.budgetsService.upsert(year, budgets);
}
@Get(':year/vs-actual')
@ApiOperation({ summary: 'Budget vs actual comparison' })
budgetVsActual(
@Param('year', ParseIntPipe) year: number,
@Query('month') month?: string,
) {
return this.budgetsService.getBudgetVsActual(year, month ? parseInt(month) : undefined);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BudgetsController } from './budgets.controller';
import { BudgetsService } from './budgets.service';
@Module({
controllers: [BudgetsController],
providers: [BudgetsService],
exports: [BudgetsService],
})
export class BudgetsModule {}

View File

@@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
@Injectable()
export class BudgetsService {
constructor(private tenant: TenantService) {}
async findByYear(year: number) {
return this.tenant.query(
`SELECT b.*, a.account_number, a.name as account_name, a.account_type
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1
ORDER BY a.account_number`,
[year],
);
}
async upsert(year: number, budgets: UpsertBudgetDto[]) {
const results = [];
for (const b of budgets) {
const rows = await this.tenant.query(
`INSERT INTO budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (fiscal_year, account_id, fund_type)
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, notes=$16
RETURNING *`,
[
year, b.accountId, b.fundType,
b.jan || 0, b.feb || 0, b.mar || 0, b.apr || 0,
b.may || 0, b.jun || 0, b.jul || 0, b.aug || 0,
b.sep || 0, b.oct || 0, b.nov || 0, b.dec || 0,
b.notes || null,
],
);
results.push(rows[0]);
}
return results;
}
async getBudgetVsActual(year: number, month?: number) {
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
let budgetMonthCols: string;
let actualDateFilter: string;
const params: any[] = [year];
if (month) {
budgetMonthCols = `b.${monthNames[month - 1]} as budget_amount`;
actualDateFilter = `AND EXTRACT(MONTH FROM je.entry_date) = $2`;
params.push(month);
} else {
budgetMonthCols = `(b.jan+b.feb+b.mar+b.apr+b.may+b.jun+b.jul+b.aug+b.sep+b.oct+b.nov+b.dec_amt) as budget_amount`;
actualDateFilter = '';
}
const rows = await this.tenant.query(
`SELECT
a.id as account_id, a.account_number, a.name as account_name,
a.account_type, a.fund_type,
${budgetMonthCols},
COALESCE(SUM(
CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit
ELSE jel.credit - jel.debit END
), 0) as actual_amount
FROM accounts a
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
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
AND EXTRACT(YEAR FROM je.entry_date) = $1
${actualDateFilter}
WHERE a.is_active = true
AND a.account_type IN ('income', 'expense')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
ORDER BY a.account_number`,
params,
);
// Compute variance fields
const lines = rows.map((r: any) => {
const budget = parseFloat(r.budget_amount || '0');
const actual = parseFloat(r.actual_amount || '0');
const variance = actual - budget;
const variancePct = budget !== 0 ? (variance / budget) * 100 : 0;
return { ...r, budget_amount: budget, actual_amount: actual, variance, variance_pct: variancePct };
});
const incomeLines = lines.filter((l: any) => l.account_type === 'income');
const expenseLines = lines.filter((l: any) => l.account_type === 'expense');
return {
year,
lines,
total_income_budget: incomeLines.reduce((s: number, l: any) => s + l.budget_amount, 0),
total_income_actual: incomeLines.reduce((s: number, l: any) => s + l.actual_amount, 0),
total_expense_budget: expenseLines.reduce((s: number, l: any) => s + l.budget_amount, 0),
total_expense_actual: expenseLines.reduce((s: number, l: any) => s + l.actual_amount, 0),
};
}
}

View File

@@ -0,0 +1,30 @@
import { IsUUID, IsNumber, IsOptional, IsString, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpsertBudgetDto {
@ApiProperty()
@IsUUID()
accountId: string;
@ApiProperty({ enum: ['operating', 'reserve'] })
@IsIn(['operating', 'reserve'])
fundType: string;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() jan?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() feb?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() mar?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() apr?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() may?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() jun?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() jul?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() aug?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() sep?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() oct?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() nov?: number;
@ApiProperty({ required: false }) @IsNumber() @IsOptional() dec?: number;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
notes?: string;
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CapitalProjectsService } from './capital-projects.service';
@ApiTags('capital-projects')
@Controller('capital-projects')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class CapitalProjectsController {
constructor(private service: CapitalProjectsService) {}
@Get()
findAll() { return this.service.findAll(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CapitalProjectsController } from './capital-projects.controller';
import { CapitalProjectsService } from './capital-projects.service';
@Module({
controllers: [CapitalProjectsController],
providers: [CapitalProjectsService],
exports: [CapitalProjectsService],
})
export class CapitalProjectsModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class CapitalProjectsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query(`
SELECT cp.*, rc.name as reserve_component_name
FROM capital_projects cp
LEFT JOIN reserve_components rc ON rc.id = cp.reserve_component_id
ORDER BY cp.target_year, cp.target_month NULLS LAST, cp.priority
`);
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM capital_projects WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Capital project not found');
return rows[0];
}
async create(dto: any) {
const rows = await this.tenant.query(
`INSERT INTO capital_projects (name, description, estimated_cost, actual_cost, target_year, target_month,
status, reserve_component_id, fund_source, priority, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[dto.name, dto.description, dto.estimated_cost, dto.actual_cost || null, dto.target_year,
dto.target_month || null, dto.status || 'planned', dto.reserve_component_id || null,
dto.fund_source || 'reserve', dto.priority || 3, dto.notes],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const rows = await this.tenant.query(
`UPDATE capital_projects SET name = COALESCE($2, name), description = COALESCE($3, description),
estimated_cost = COALESCE($4, estimated_cost), actual_cost = COALESCE($5, actual_cost),
target_year = COALESCE($6, target_year), target_month = COALESCE($7, target_month),
status = COALESCE($8, status), reserve_component_id = COALESCE($9, reserve_component_id),
fund_source = COALESCE($10, fund_source), priority = COALESCE($11, priority),
notes = COALESCE($12, notes), updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.name, dto.description, dto.estimated_cost, dto.actual_cost, dto.target_year,
dto.target_month, dto.status, dto.reserve_component_id, dto.fund_source, dto.priority, dto.notes],
);
return rows[0];
}
}

View File

@@ -0,0 +1,30 @@
import { Controller, Get, Post, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { FiscalPeriodsService } from './fiscal-periods.service';
@ApiTags('fiscal-periods')
@Controller('fiscal-periods')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class FiscalPeriodsController {
constructor(private fpService: FiscalPeriodsService) {}
@Get()
@ApiOperation({ summary: 'List all fiscal periods' })
findAll() {
return this.fpService.findAll();
}
@Post(':id/close')
@ApiOperation({ summary: 'Close a fiscal period' })
close(@Param('id') id: string, @Request() req: any) {
return this.fpService.close(id, req.user.sub);
}
@Post(':id/lock')
@ApiOperation({ summary: 'Lock a fiscal period (audit lock)' })
lock(@Param('id') id: string, @Request() req: any) {
return this.fpService.lock(id, req.user.sub);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FiscalPeriodsController } from './fiscal-periods.controller';
import { FiscalPeriodsService } from './fiscal-periods.service';
@Module({
controllers: [FiscalPeriodsController],
providers: [FiscalPeriodsService],
exports: [FiscalPeriodsService],
})
export class FiscalPeriodsModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class FiscalPeriodsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query('SELECT * FROM fiscal_periods ORDER BY year DESC, month DESC');
}
async findByDate(date: string) {
const d = new Date(date);
const rows = await this.tenant.query(
'SELECT * FROM fiscal_periods WHERE year = $1 AND month = $2',
[d.getFullYear(), d.getMonth() + 1],
);
if (!rows.length) {
throw new NotFoundException(`No fiscal period for ${date}`);
}
return rows[0];
}
async findOrCreate(year: number, month: number) {
let rows = await this.tenant.query(
'SELECT * FROM fiscal_periods WHERE year = $1 AND month = $2',
[year, month],
);
if (rows.length) return rows[0];
rows = await this.tenant.query(
`INSERT INTO fiscal_periods (year, month, status) VALUES ($1, $2, 'open') RETURNING *`,
[year, month],
);
return rows[0];
}
async close(id: string, userId: string) {
const rows = await this.tenant.query('SELECT * FROM fiscal_periods WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Period not found');
if (rows[0].status !== 'open') throw new BadRequestException('Period is not open');
const result = await this.tenant.query(
`UPDATE fiscal_periods SET status = 'closed', closed_by = $1, closed_at = NOW() WHERE id = $2 RETURNING *`,
[userId, id],
);
return result[0];
}
async lock(id: string, userId: string) {
const rows = await this.tenant.query('SELECT * FROM fiscal_periods WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Period not found');
if (rows[0].status === 'locked') throw new BadRequestException('Period is already locked');
const result = await this.tenant.query(
`UPDATE fiscal_periods SET status = 'locked', locked_by = $1, locked_at = NOW() WHERE id = $2 RETURNING *`,
[userId, id],
);
return result[0];
}
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { InvestmentsService } from './investments.service';
@ApiTags('investments')
@Controller('investment-accounts')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class InvestmentsController {
constructor(private service: InvestmentsService) {}
@Get()
findAll() { return this.service.findAll(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { InvestmentsController } from './investments.controller';
import { InvestmentsService } from './investments.service';
@Module({
controllers: [InvestmentsController],
providers: [InvestmentsService],
exports: [InvestmentsService],
})
export class InvestmentsModule {}

View File

@@ -0,0 +1,46 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class InvestmentsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query('SELECT * FROM investment_accounts WHERE is_active = true ORDER BY name');
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM investment_accounts WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Investment account not found');
return rows[0];
}
async create(dto: any) {
const rows = await this.tenant.query(
`INSERT INTO investment_accounts (name, institution, account_number_last4, investment_type,
fund_type, principal, interest_rate, maturity_date, purchase_date, current_value, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[dto.name, dto.institution, dto.account_number_last4, dto.investment_type || 'cd',
dto.fund_type || 'reserve', dto.principal, dto.interest_rate || 0,
dto.maturity_date || null, dto.purchase_date || null, dto.current_value || dto.principal, dto.notes],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const rows = await this.tenant.query(
`UPDATE investment_accounts SET name = COALESCE($2, name), institution = COALESCE($3, institution),
account_number_last4 = COALESCE($4, account_number_last4), investment_type = COALESCE($5, investment_type),
fund_type = COALESCE($6, fund_type), principal = COALESCE($7, principal),
interest_rate = COALESCE($8, interest_rate), maturity_date = COALESCE($9, maturity_date),
purchase_date = COALESCE($10, purchase_date), current_value = COALESCE($11, current_value),
notes = COALESCE($12, notes), updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.name, dto.institution, dto.account_number_last4, dto.investment_type,
dto.fund_type, dto.principal, dto.interest_rate, dto.maturity_date, dto.purchase_date,
dto.current_value, dto.notes],
);
return rows[0];
}
}

View File

@@ -0,0 +1,28 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { InvoicesService } from './invoices.service';
@ApiTags('invoices')
@Controller('invoices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class InvoicesController {
constructor(private invoicesService: InvoicesService) {}
@Get()
findAll() { return this.invoicesService.findAll(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-bulk')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub);
}
@Post('apply-late-fees')
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
return this.invoicesService.applyLateFees(dto, req.user.sub);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
@Module({
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
})
export class InvoicesModule {}

View File

@@ -0,0 +1,120 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class InvoicesService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query(`
SELECT i.*, u.unit_number,
(i.amount - i.amount_paid) as balance_due
FROM invoices i
JOIN units u ON u.id = i.unit_id
ORDER BY i.invoice_date DESC, i.invoice_number DESC
`);
}
async findOne(id: string) {
const rows = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i
JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]);
if (!rows.length) throw new NotFoundException('Invoice not found');
return rows[0];
}
async generateBulk(dto: { month: number; year: number }, userId: string) {
const units = await this.tenant.query(
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 0`,
);
if (!units.length) throw new BadRequestException('No active units with assessments found');
// Get or create fiscal period
let fp = await this.tenant.query(
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2', [dto.year, dto.month],
);
if (!fp.length) {
fp = await this.tenant.query(
`INSERT INTO fiscal_periods (year, month, status) VALUES ($1, $2, 'open') RETURNING id`,
[dto.year, dto.month],
);
}
const fiscalPeriodId = fp[0].id;
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
const dueDate = new Date(dto.year, dto.month - 1, 15);
let created = 0;
for (const unit of units) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
// Check if already generated
const existing = await this.tenant.query(
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
);
if (existing.length) continue;
// Create the invoice
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
unit.monthly_assessment],
);
// Create journal entry: DR Accounts Receivable, CR Assessment Income
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1200`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 4000`);
if (arAccount.length && incomeAccount.length) {
const je = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
);
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unit.monthly_assessment, incomeAccount[0].id],
);
await this.tenant.query(
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
);
}
created++;
}
return { created, month: dto.month, year: dto.year };
}
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - dto.grace_period_days);
const cutoffStr = cutoff.toISOString().split('T')[0];
const overdue = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i
JOIN units u ON u.id = i.unit_id
WHERE i.status IN ('sent', 'partial') AND i.due_date < $1
AND NOT EXISTS (
SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id
AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%'
)
`, [cutoffStr]);
let applied = 0;
for (const inv of overdue) {
await this.tenant.query(`UPDATE invoices SET status = 'overdue' WHERE id = $1`, [inv.id]);
const lfNum = `LF-${inv.invoice_number}`;
await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'sent')`,
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
);
applied++;
}
return { applied };
}
}

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

View File

@@ -0,0 +1,45 @@
import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateOrganizationDto {
@ApiProperty({ example: 'Sunrise Valley HOA' })
@IsString()
name: string;
@ApiProperty({ example: '123 Main St', required: false })
@IsString()
@IsOptional()
addressLine1?: string;
@ApiProperty({ example: 'Springfield', required: false })
@IsString()
@IsOptional()
city?: string;
@ApiProperty({ example: 'IL', required: false })
@IsString()
@IsOptional()
state?: string;
@ApiProperty({ example: '62701', required: false })
@IsString()
@IsOptional()
zipCode?: string;
@ApiProperty({ example: '555-123-4567', required: false })
@IsString()
@IsOptional()
phone?: string;
@ApiProperty({ example: 'board@sunrisevalley.org', required: false })
@IsString()
@IsOptional()
email?: string;
@ApiProperty({ example: 1, required: false })
@IsInt()
@Min(1)
@Max(12)
@IsOptional()
fiscalYearStartMonth?: number;
}

View File

@@ -0,0 +1,66 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { UserOrganization } from './user-organization.entity';
@Entity({ schema: 'shared', name: 'organizations' })
export class Organization {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ name: 'schema_name', unique: true })
schemaName: string;
@Column({ nullable: true })
subdomain: string;
@Column({ default: 'active' })
status: string;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
@Column({ name: 'address_line1', nullable: true })
addressLine1: string;
@Column({ name: 'address_line2', nullable: true })
addressLine2: string;
@Column({ nullable: true })
city: string;
@Column({ nullable: true })
state: string;
@Column({ name: 'zip_code', nullable: true })
zipCode: string;
@Column({ nullable: true })
phone: string;
@Column({ nullable: true })
email: string;
@Column({ name: 'tax_id', nullable: true })
taxId: string;
@Column({ name: 'fiscal_year_start_month', default: 1 })
fiscalYearStartMonth: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@OneToMany(() => UserOrganization, (uo) => uo.organization)
userOrganizations: UserOrganization[];
}

View File

@@ -0,0 +1,41 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Unique,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Organization } from './organization.entity';
@Entity({ schema: 'shared', name: 'user_organizations' })
@Unique(['userId', 'organizationId'])
export class UserOrganization {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ name: 'organization_id' })
organizationId: string;
@Column()
role: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'joined_at', type: 'timestamptz' })
joinedAt: Date;
@ManyToOne(() => User, (user) => user.userOrganizations)
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => Organization, (org) => org.userOrganizations)
@JoinColumn({ name: 'organization_id' })
organization: Organization;
}

View File

@@ -0,0 +1,25 @@
import { Controller, Post, Get, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('organizations')
@Controller('organizations')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class OrganizationsController {
constructor(private orgService: OrganizationsService) {}
@Post()
@ApiOperation({ summary: 'Create a new HOA organization' })
async create(@Body() dto: CreateOrganizationDto, @Request() req: any) {
return this.orgService.create(dto, req.user.sub);
}
@Get()
@ApiOperation({ summary: 'List organizations for current user' })
async findMine(@Request() req: any) {
return this.orgService.findByUser(req.user.sub);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Organization } from './entities/organization.entity';
import { UserOrganization } from './entities/user-organization.entity';
import { OrganizationsController } from './organizations.controller';
import { OrganizationsService } from './organizations.service';
import { TenantSchemaService } from '../../database/tenant-schema.service';
@Module({
imports: [TypeOrmModule.forFeature([Organization, UserOrganization])],
controllers: [OrganizationsController],
providers: [OrganizationsService, TenantSchemaService],
exports: [OrganizationsService, TenantSchemaService],
})
export class OrganizationsModule {}

View File

@@ -0,0 +1,80 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Organization } from './entities/organization.entity';
import { UserOrganization } from './entities/user-organization.entity';
import { TenantSchemaService } from '../../database/tenant-schema.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
@Injectable()
export class OrganizationsService {
constructor(
@InjectRepository(Organization)
private orgRepository: Repository<Organization>,
@InjectRepository(UserOrganization)
private userOrgRepository: Repository<UserOrganization>,
private tenantSchemaService: TenantSchemaService,
) {}
async create(dto: CreateOrganizationDto, userId: string) {
const schemaName = this.generateSchemaName(dto.name);
const existing = await this.orgRepository.findOne({
where: { schemaName },
});
if (existing) {
throw new ConflictException('Organization name too similar to existing one');
}
const org = this.orgRepository.create({
name: dto.name,
schemaName,
addressLine1: dto.addressLine1,
city: dto.city,
state: dto.state,
zipCode: dto.zipCode,
phone: dto.phone,
email: dto.email,
fiscalYearStartMonth: dto.fiscalYearStartMonth || 1,
});
const savedOrg = await this.orgRepository.save(org);
await this.tenantSchemaService.createTenantSchema(schemaName);
const membership = this.userOrgRepository.create({
userId,
organizationId: savedOrg.id,
role: 'president',
});
await this.userOrgRepository.save(membership);
return savedOrg;
}
async findByUser(userId: string) {
const memberships = await this.userOrgRepository.find({
where: { userId, isActive: true },
relations: ['organization'],
});
return memberships.map((m) => ({
...m.organization,
role: m.role,
}));
}
async findById(id: string) {
return this.orgRepository.findOne({ where: { id } });
}
private generateSchemaName(name: string): string {
const clean = name
.toLowerCase()
.replace(/[^a-z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.substring(0, 40);
const suffix = Date.now().toString(36).slice(-4);
return `tenant_${clean}_${suffix}`;
}
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PaymentsService } from './payments.service';
@ApiTags('payments')
@Controller('payments')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Get()
findAll() { return this.paymentsService.findAll(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
@Post()
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
@Module({
controllers: [PaymentsController],
providers: [PaymentsService],
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,90 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class PaymentsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query(`
SELECT p.*, u.unit_number, i.invoice_number
FROM payments p
JOIN units u ON u.id = p.unit_id
LEFT JOIN invoices i ON i.id = p.invoice_id
ORDER BY p.payment_date DESC, p.created_at DESC
`);
}
async findOne(id: string) {
const rows = await this.tenant.query(`
SELECT p.*, u.unit_number, i.invoice_number FROM payments p
JOIN units u ON u.id = p.unit_id
LEFT JOIN invoices i ON i.id = p.invoice_id
WHERE p.id = $1`, [id]);
if (!rows.length) throw new NotFoundException('Payment not found');
return rows[0];
}
async create(dto: any, userId: string) {
// Validate invoice exists and get details
let invoice: any = null;
if (dto.invoice_id) {
const rows = await this.tenant.query('SELECT * FROM invoices WHERE id = $1', [dto.invoice_id]);
if (!rows.length) throw new NotFoundException('Invoice not found');
invoice = rows[0];
if (invoice.status === 'paid') throw new BadRequestException('Invoice is already paid');
if (invoice.status === 'void') throw new BadRequestException('Cannot pay void invoice');
}
// Get fiscal period
const payDate = new Date(dto.payment_date);
let fp = await this.tenant.query(
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
[payDate.getFullYear(), payDate.getMonth() + 1],
);
if (!fp.length) {
fp = await this.tenant.query(
`INSERT INTO fiscal_periods (year, month, status) VALUES ($1, $2, 'open') RETURNING id`,
[payDate.getFullYear(), payDate.getMonth() + 1],
);
}
// Create payment record
const payment = await this.tenant.query(
`INSERT INTO payments (unit_id, invoice_id, payment_date, amount, payment_method, reference_number, notes, received_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[dto.unit_id, dto.invoice_id || null, dto.payment_date, dto.amount, dto.payment_method || 'check',
dto.reference_number || null, dto.notes || null, userId],
);
// Create journal entry: DR Cash, CR Accounts Receivable
const cashAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1000`);
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1200`);
if (cashAccount.length && arAccount.length) {
const je = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'payment', $3, 'payment', $4, true, NOW(), $5) RETURNING id`,
[dto.payment_date, `Payment received - ${dto.reference_number || 'N/A'}`, fp[0].id, payment[0].id, userId],
);
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, cashAccount[0].id, dto.amount, arAccount[0].id],
);
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
}
// Update invoice if linked
if (invoice) {
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
const invoiceAmt = parseFloat(invoice.amount);
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $3`,
[newPaid, newStatus, invoice.id],
);
}
return payment[0];
}
}

View File

@@ -0,0 +1,35 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ReportsService } from './reports.service';
@ApiTags('reports')
@Controller('reports')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class ReportsController {
constructor(private reportsService: ReportsService) {}
@Get('balance-sheet')
getBalanceSheet(@Query('as_of') asOf?: string) {
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
}
@Get('income-statement')
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
const now = new Date();
const defaultFrom = `${now.getFullYear()}-01-01`;
const defaultTo = now.toISOString().split('T')[0];
return this.reportsService.getIncomeStatement(from || defaultFrom, to || defaultTo);
}
@Get('cash-flow-sankey')
getCashFlowSankey(@Query('year') year?: string) {
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
}
@Get('dashboard')
getDashboardKPIs() {
return this.reportsService.getDashboardKPIs();
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
controllers: [ReportsController],
providers: [ReportsService],
exports: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,227 @@
import { Injectable } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class ReportsService {
constructor(private tenant: TenantService) {}
async getBalanceSheet(asOf: string) {
const sql = `
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type,
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
AND je.entry_date <= $1
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
HAVING CASE
WHEN a.account_type IN ('asset') THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END <> 0 OR a.is_system = true
ORDER BY a.account_number
`;
const rows = await this.tenant.query(sql, [asOf]);
const assets = rows.filter((r: any) => r.account_type === 'asset');
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
const equity = rows.filter((r: any) => r.account_type === 'equity');
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
return {
as_of: asOf,
assets, liabilities, equity,
total_assets: totalAssets.toFixed(2),
total_liabilities: totalLiabilities.toFixed(2),
total_equity: totalEquity.toFixed(2),
};
}
async getIncomeStatement(from: string, to: string) {
const sql = `
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
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
AND je.entry_date BETWEEN $1 AND $2
WHERE a.is_active = true AND a.account_type IN ('income', 'expense')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
HAVING CASE
WHEN a.account_type = 'income' THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END <> 0 OR a.is_system = true
ORDER BY a.account_number
`;
const rows = await this.tenant.query(sql, [from, to]);
const income = rows.filter((r: any) => r.account_type === 'income');
const expenses = rows.filter((r: any) => r.account_type === 'expense');
const totalIncome = income.reduce((s: number, r: any) => s + parseFloat(r.amount), 0);
const totalExpenses = expenses.reduce((s: number, r: any) => s + parseFloat(r.amount), 0);
return {
from, to,
income, expenses,
total_income: totalIncome.toFixed(2),
total_expenses: totalExpenses.toFixed(2),
net_income: (totalIncome - totalExpenses).toFixed(2),
};
}
async getCashFlowSankey(year: number) {
// Get income accounts with amounts
const income = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, [year]);
const expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'expense' AND a.is_active = true
GROUP BY a.id, a.name, a.fund_type
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
ORDER BY amount DESC
`, [year]);
if (!income.length && !expenses.length) {
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
}
// Build Sankey nodes and links
// Structure: Income Sources → HOA Fund → Expense Categories
const nodes: { name: string; category: string }[] = [];
const links: { source: number; target: number; value: number }[] = [];
// Income source nodes
income.forEach((i: any) => nodes.push({ name: i.name, category: 'income' }));
// Central HOA Fund node
const fundIdx = nodes.length;
nodes.push({ name: 'HOA Fund', category: 'operating' });
// Operating expense nodes
const opExpenses = expenses.filter((e: any) => e.fund_type === 'operating');
const resExpenses = expenses.filter((e: any) => e.fund_type === 'reserve');
if (opExpenses.length) {
const opIdx = nodes.length;
nodes.push({ name: 'Operating Expenses', category: 'expense' });
opExpenses.forEach((e: any) => nodes.push({ name: e.name, category: 'expense' }));
// Link fund → operating
const opTotal = opExpenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
links.push({ source: fundIdx, target: opIdx, value: opTotal });
// Link operating → each expense
opExpenses.forEach((e: any, i: number) => {
links.push({ source: opIdx, target: opIdx + 1 + i, value: parseFloat(e.amount) });
});
}
if (resExpenses.length) {
const resIdx = nodes.length;
nodes.push({ name: 'Reserve Expenses', category: 'reserve' });
resExpenses.forEach((e: any) => nodes.push({ name: e.name, category: 'reserve' }));
const resTotal = resExpenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
links.push({ source: fundIdx, target: resIdx, value: resTotal });
resExpenses.forEach((e: any, i: number) => {
links.push({ source: resIdx, target: resIdx + 1 + i, value: parseFloat(e.amount) });
});
}
// Link income sources → fund
income.forEach((i: any, idx: number) => {
links.push({ source: idx, target: fundIdx, value: parseFloat(i.amount) });
});
// Net surplus node
const totalIncome = income.reduce((s: number, i: any) => s + parseFloat(i.amount), 0);
const totalExpenses = expenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
const netFlow = totalIncome - totalExpenses;
if (netFlow > 0) {
const surplusIdx = nodes.length;
nodes.push({ name: 'Surplus / Savings', category: 'net' });
links.push({ source: fundIdx, target: surplusIdx, value: netFlow });
}
return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow };
}
async getDashboardKPIs() {
// Total cash (all asset accounts with 'Cash' in name)
const cash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) 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
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
GROUP BY a.id
) sub
`);
const totalCash = parseFloat(cash[0]?.total || '0');
// Receivables
const ar = await this.tenant.query(`
SELECT COALESCE(SUM(amount - amount_paid), 0) as total
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
`);
// Reserve fund balance
const reserves = await this.tenant.query(`
SELECT COALESCE(SUM(current_fund_balance), 0) as total FROM reserve_components
`);
// Delinquent count (overdue invoices)
const delinquent = await this.tenant.query(`
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
`);
// Recent transactions
const recentTx = await this.tenant.query(`
SELECT je.id, je.entry_date, je.description, je.entry_type,
(SELECT COALESCE(SUM(debit), 0) FROM journal_entry_lines WHERE journal_entry_id = je.id) as amount
FROM journal_entries je WHERE je.is_posted = true AND je.is_void = false
ORDER BY je.entry_date DESC, je.created_at DESC LIMIT 10
`);
return {
total_cash: totalCash.toFixed(2),
total_receivables: ar[0]?.total || '0.00',
reserve_fund_balance: reserves[0]?.total || '0.00',
delinquent_units: parseInt(delinquent[0]?.count || '0'),
recent_transactions: recentTx,
};
}
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ReserveComponentsService } from './reserve-components.service';
@ApiTags('reserve-components')
@Controller('reserve-components')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class ReserveComponentsController {
constructor(private service: ReserveComponentsService) {}
@Get()
findAll() { return this.service.findAll(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ReserveComponentsController } from './reserve-components.controller';
import { ReserveComponentsService } from './reserve-components.service';
@Module({
controllers: [ReserveComponentsController],
providers: [ReserveComponentsService],
exports: [ReserveComponentsService],
})
export class ReserveComponentsModule {}

View File

@@ -0,0 +1,47 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class ReserveComponentsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query('SELECT * FROM reserve_components ORDER BY name');
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM reserve_components WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Reserve component not found');
return rows[0];
}
async create(dto: any) {
const rows = await this.tenant.query(
`INSERT INTO reserve_components (name, category, description, useful_life_years, remaining_life_years,
replacement_cost, current_fund_balance, annual_contribution, condition_rating,
last_replacement_date, next_replacement_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[dto.name, dto.category, dto.description, dto.useful_life_years, dto.remaining_life_years || 0,
dto.replacement_cost, dto.current_fund_balance || 0, dto.annual_contribution || 0,
dto.condition_rating || 5, dto.last_replacement_date || null, dto.next_replacement_date || null],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const rows = await this.tenant.query(
`UPDATE reserve_components SET name = COALESCE($2, name), category = COALESCE($3, category),
description = COALESCE($4, description), useful_life_years = COALESCE($5, useful_life_years),
remaining_life_years = COALESCE($6, remaining_life_years), replacement_cost = COALESCE($7, replacement_cost),
current_fund_balance = COALESCE($8, current_fund_balance), annual_contribution = COALESCE($9, annual_contribution),
condition_rating = COALESCE($10, condition_rating), last_replacement_date = COALESCE($11, last_replacement_date),
next_replacement_date = COALESCE($12, next_replacement_date), updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.name, dto.category, dto.description, dto.useful_life_years, dto.remaining_life_years,
dto.replacement_cost, dto.current_fund_balance, dto.annual_contribution, dto.condition_rating,
dto.last_replacement_date, dto.next_replacement_date],
);
return rows[0];
}
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UnitsService } from './units.service';
@ApiTags('units')
@Controller('units')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class UnitsController {
constructor(private unitsService: UnitsService) {}
@Get()
findAll() { return this.unitsService.findAll(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
@Post()
create(@Body() dto: any) { return this.unitsService.create(dto); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UnitsController } from './units.controller';
import { UnitsService } from './units.service';
@Module({
controllers: [UnitsController],
providers: [UnitsService],
exports: [UnitsService],
})
export class UnitsModule {}

View File

@@ -0,0 +1,51 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class UnitsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query(`
SELECT u.*,
COALESCE((
SELECT SUM(i.amount - i.amount_paid)
FROM invoices i
WHERE i.unit_id = u.id AND i.status NOT IN ('paid', 'void', 'written_off')
), 0) as balance_due
FROM units u ORDER BY u.unit_number
`);
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM units WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Unit not found');
return rows[0];
}
async create(dto: any) {
const existing = await this.tenant.query('SELECT id FROM units WHERE unit_number = $1', [dto.unit_number]);
if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
const rows = await this.tenant.query(
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const rows = await this.tenant.query(
`UPDATE units SET unit_number = COALESCE($2, unit_number), address_line1 = COALESCE($3, address_line1),
city = COALESCE($4, city), state = COALESCE($5, state), zip_code = COALESCE($6, zip_code),
owner_name = COALESCE($7, owner_name), owner_email = COALESCE($8, owner_email),
owner_phone = COALESCE($9, owner_phone), monthly_assessment = COALESCE($10, monthly_assessment),
status = COALESCE($11, status), updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment, dto.status],
);
return rows[0];
}
}

View File

@@ -0,0 +1,23 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecurePass123!' })
@IsString()
@MinLength(8)
password: string;
@ApiProperty({ example: 'John', required: false })
@IsString()
@IsOptional()
firstName?: string;
@ApiProperty({ example: 'Smith', required: false })
@IsString()
@IsOptional()
lastName?: string;
}

View File

@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { UserOrganization } from '../../organizations/entities/user-organization.entity';
@Entity({ schema: 'shared', name: 'users' })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column({ name: 'password_hash', nullable: true })
passwordHash: string;
@Column({ name: 'first_name', nullable: true })
firstName: string;
@Column({ name: 'last_name', nullable: true })
lastName: string;
@Column({ nullable: true })
phone: string;
@Column({ name: 'is_email_verified', default: false })
isEmailVerified: boolean;
@Column({ name: 'mfa_enabled', default: false })
mfaEnabled: boolean;
@Column({ name: 'mfa_secret', nullable: true })
mfaSecret: string;
@Column({ name: 'oauth_provider', nullable: true })
oauthProvider: string;
@Column({ name: 'oauth_provider_id', nullable: true })
oauthProviderId: string;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@OneToMany(() => UserOrganization, (uo) => uo.user)
userOrganizations: UserOrganization[];
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async findByEmail(email: string): Promise<User | null> {
return this.usersRepository.findOne({
where: { email: email.toLowerCase() },
});
}
async findById(id: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { id } });
}
async findByIdWithOrgs(id: string): Promise<User | null> {
return this.usersRepository.findOne({
where: { id },
relations: ['userOrganizations', 'userOrganizations.organization'],
});
}
async create(data: Partial<User>): Promise<User> {
const user = this.usersRepository.create({
...data,
email: data.email?.toLowerCase(),
});
return this.usersRepository.save(user);
}
async updateLastLogin(id: string): Promise<void> {
await this.usersRepository.update(id, { lastLoginAt: new Date() });
}
}

View File

@@ -0,0 +1,29 @@
import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VendorsService } from './vendors.service';
@ApiTags('vendors')
@Controller('vendors')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class VendorsController {
constructor(private vendorsService: VendorsService) {}
@Get()
findAll() { return this.vendorsService.findAll(); }
@Get('1099-data')
get1099Data(@Query('year') year: string) {
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
}
@Get(':id')
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
@Post()
create(@Body() dto: any) { return this.vendorsService.create(dto); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { VendorsController } from './vendors.controller';
import { VendorsService } from './vendors.service';
@Module({
controllers: [VendorsController],
providers: [VendorsService],
exports: [VendorsService],
})
export class VendorsModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class VendorsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query('SELECT * FROM vendors WHERE is_active = true ORDER BY name');
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM vendors WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Vendor not found');
return rows[0];
}
async create(dto: any) {
const rows = await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code,
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const rows = await this.tenant.query(
`UPDATE vendors SET name = COALESCE($2, name), contact_name = COALESCE($3, contact_name),
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code),
tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible),
default_account_id = COALESCE($12, default_account_id), updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id],
);
return rows[0];
}
async get1099Data(year: number) {
return this.tenant.query(`
SELECT v.*, COALESCE(SUM(p_amounts.amount), 0) as total_paid
FROM vendors v
LEFT JOIN (
SELECT jel.account_id, jel.debit as amount, je.entry_date
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
WHERE je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
AND jel.debit > 0
) p_amounts ON p_amounts.account_id = v.default_account_id
WHERE v.is_1099_eligible = true
GROUP BY v.id
HAVING COALESCE(SUM(p_amounts.amount), 0) >= 600
ORDER BY v.name
`, [year]);
}
}