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

12
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "start:dev"]

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

68
backend/package.json Normal file
View File

@@ -0,0 +1,68 @@
{
"name": "hoa-financial-platform-backend",
"version": "0.1.0",
"description": "HOA Financial Intelligence Platform - Backend API",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed": "ts-node -r tsconfig-paths/register src/database/seeds/seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.4.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.13.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"uuid": "^9.0.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.12",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/uuid": "^9.0.8",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": { "^@/(.*)$": "<rootDir>/$1" }
}
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get('health')
getHealth() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'hoa-financial-platform',
};
}
}

62
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,62 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module';
import { AccountsModule } from './modules/accounts/accounts.module';
import { FiscalPeriodsModule } from './modules/fiscal-periods/fiscal-periods.module';
import { JournalEntriesModule } from './modules/journal-entries/journal-entries.module';
import { BudgetsModule } from './modules/budgets/budgets.module';
import { UnitsModule } from './modules/units/units.module';
import { InvoicesModule } from './modules/invoices/invoices.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { VendorsModule } from './modules/vendors/vendors.module';
import { ReserveComponentsModule } from './modules/reserve-components/reserve-components.module';
import { InvestmentsModule } from './modules/investments/investments.module';
import { CapitalProjectsModule } from './modules/capital-projects/capital-projects.module';
import { ReportsModule } from './modules/reports/reports.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
url: configService.get<string>('DATABASE_URL'),
autoLoadEntities: true,
synchronize: false,
logging: false,
}),
}),
DatabaseModule,
AuthModule,
OrganizationsModule,
UsersModule,
AccountsModule,
FiscalPeriodsModule,
JournalEntriesModule,
BudgetsModule,
UnitsModule,
InvoicesModule,
PaymentsModule,
VendorsModule,
ReserveComponentsModule,
InvestmentsModule,
CapitalProjectsModule,
ReportsModule,
],
controllers: [AppController],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,20 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user.role);
}
}

View File

@@ -0,0 +1,10 @@
import { Module, Global } from '@nestjs/common';
import { TenantService } from './tenant.service';
import { TenantSchemaService } from './tenant-schema.service';
@Global()
@Module({
providers: [TenantService, TenantSchemaService],
exports: [TenantService, TenantSchemaService],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,373 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class TenantSchemaService {
constructor(private dataSource: DataSource) {}
async createTenantSchema(schemaName: string): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
await queryRunner.startTransaction();
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
const sql = this.getTenantSchemaDDL(schemaName);
for (const statement of sql) {
await queryRunner.query(statement);
}
await this.seedDefaultChartOfAccounts(queryRunner, schemaName);
await this.seedDefaultFiscalPeriods(queryRunner, schemaName);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
private getTenantSchemaDDL(s: string): string[] {
return [
// Accounts (Chart of Accounts)
`CREATE TABLE "${s}".accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
account_number INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
account_type VARCHAR(50) NOT NULL CHECK (account_type IN ('asset', 'liability', 'equity', 'income', 'expense')),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
parent_account_id UUID REFERENCES "${s}".accounts(id),
is_1099_reportable BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
balance DECIMAL(15,2) DEFAULT 0.00,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(account_number)
)`,
// Fiscal Periods
`CREATE TABLE "${s}".fiscal_periods (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
year INTEGER NOT NULL,
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'closed', 'locked')),
closed_by UUID,
closed_at TIMESTAMPTZ,
locked_by UUID,
locked_at TIMESTAMPTZ,
UNIQUE(year, month)
)`,
// Journal Entries
`CREATE TABLE "${s}".journal_entries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
entry_date DATE NOT NULL,
description TEXT NOT NULL,
reference_number VARCHAR(100),
entry_type VARCHAR(50) NOT NULL CHECK (entry_type IN (
'manual', 'assessment', 'payment', 'late_fee', 'transfer',
'adjustment', 'closing', 'opening_balance'
)),
fiscal_period_id UUID NOT NULL REFERENCES "${s}".fiscal_periods(id),
source_type VARCHAR(50),
source_id UUID,
is_posted BOOLEAN DEFAULT FALSE,
posted_by UUID,
posted_at TIMESTAMPTZ,
is_void BOOLEAN DEFAULT FALSE,
voided_by UUID,
voided_at TIMESTAMPTZ,
void_reason TEXT,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Journal Entry Lines
`CREATE TABLE "${s}".journal_entry_lines (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
journal_entry_id UUID NOT NULL REFERENCES "${s}".journal_entries(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES "${s}".accounts(id),
debit DECIMAL(15,2) DEFAULT 0.00,
credit DECIMAL(15,2) DEFAULT 0.00,
memo TEXT,
CHECK (debit >= 0 AND credit >= 0),
CHECK (NOT (debit > 0 AND credit > 0))
)`,
// Units (homeowners/lots)
`CREATE TABLE "${s}".units (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
unit_number VARCHAR(50) NOT NULL UNIQUE,
address_line1 VARCHAR(255),
address_line2 VARCHAR(255),
city VARCHAR(100),
state VARCHAR(2),
zip_code VARCHAR(10),
square_footage INTEGER,
lot_size DECIMAL(10,2),
owner_user_id UUID,
owner_name VARCHAR(255),
owner_email VARCHAR(255),
owner_phone VARCHAR(20),
is_rented BOOLEAN DEFAULT FALSE,
monthly_assessment DECIMAL(10,2),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'sold')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Invoices
`CREATE TABLE "${s}".invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_number VARCHAR(50) NOT NULL UNIQUE,
unit_id UUID NOT NULL REFERENCES "${s}".units(id),
invoice_date DATE NOT NULL,
due_date DATE NOT NULL,
invoice_type VARCHAR(50) NOT NULL CHECK (invoice_type IN (
'regular_assessment', 'special_assessment', 'late_fee', 'other'
)),
description TEXT,
amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
)),
journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Payments
`CREATE TABLE "${s}".payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
unit_id UUID NOT NULL REFERENCES "${s}".units(id),
invoice_id UUID REFERENCES "${s}".invoices(id),
payment_date DATE NOT NULL,
amount DECIMAL(10,2) NOT NULL,
payment_method VARCHAR(50) CHECK (payment_method IN (
'check', 'ach', 'credit_card', 'cash', 'wire', 'other'
)),
reference_number VARCHAR(100),
stripe_payment_id VARCHAR(255),
status VARCHAR(20) DEFAULT 'completed' CHECK (status IN (
'pending', 'completed', 'failed', 'refunded'
)),
journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
received_by UUID,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Vendors
`CREATE TABLE "${s}".vendors (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
contact_name VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(20),
address_line1 VARCHAR(255),
address_line2 VARCHAR(255),
city VARCHAR(100),
state VARCHAR(2),
zip_code VARCHAR(10),
tax_id VARCHAR(20),
is_1099_eligible BOOLEAN DEFAULT FALSE,
default_account_id UUID REFERENCES "${s}".accounts(id),
is_active BOOLEAN DEFAULT TRUE,
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Budgets
`CREATE TABLE "${s}".budgets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fiscal_year INTEGER NOT NULL,
account_id UUID NOT NULL REFERENCES "${s}".accounts(id),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
notes TEXT,
UNIQUE(fiscal_year, account_id, fund_type)
)`,
// Reserve Components
`CREATE TABLE "${s}".reserve_components (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
category VARCHAR(100),
description TEXT,
useful_life_years INTEGER NOT NULL,
remaining_life_years DECIMAL(5,1),
replacement_cost DECIMAL(15,2) NOT NULL,
current_fund_balance DECIMAL(15,2) DEFAULT 0.00,
annual_contribution DECIMAL(12,2) DEFAULT 0.00,
last_replacement_date DATE,
next_replacement_date DATE,
condition_rating INTEGER CHECK (condition_rating BETWEEN 1 AND 10),
account_id UUID REFERENCES "${s}".accounts(id),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Investment Accounts
`CREATE TABLE "${s}".investment_accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
institution VARCHAR(255),
account_number_last4 VARCHAR(4),
investment_type VARCHAR(50) CHECK (investment_type IN (
'cd', 'money_market', 'treasury', 'savings', 'other'
)),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
principal DECIMAL(15,2) NOT NULL,
interest_rate DECIMAL(6,4),
maturity_date DATE,
purchase_date DATE,
current_value DECIMAL(15,2),
account_id UUID REFERENCES "${s}".accounts(id),
is_active BOOLEAN DEFAULT TRUE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Capital Projects
`CREATE TABLE "${s}".capital_projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
estimated_cost DECIMAL(15,2) NOT NULL,
actual_cost DECIMAL(15,2),
target_year INTEGER NOT NULL,
target_month INTEGER CHECK (target_month BETWEEN 1 AND 12),
status VARCHAR(20) DEFAULT 'planned' CHECK (status IN (
'planned', 'approved', 'in_progress', 'completed', 'deferred', 'cancelled'
)),
reserve_component_id UUID REFERENCES "${s}".reserve_components(id),
fund_source VARCHAR(20) CHECK (fund_source IN ('operating', 'reserve', 'special_assessment')),
priority INTEGER DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Indexes
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
`CREATE INDEX "idx_${s}_je_fiscal" ON "${s}".journal_entries(fiscal_period_id)`,
`CREATE INDEX "idx_${s}_jel_entry" ON "${s}".journal_entry_lines(journal_entry_id)`,
`CREATE INDEX "idx_${s}_jel_account" ON "${s}".journal_entry_lines(account_id)`,
`CREATE INDEX "idx_${s}_inv_unit" ON "${s}".invoices(unit_id)`,
`CREATE INDEX "idx_${s}_inv_status" ON "${s}".invoices(status)`,
`CREATE INDEX "idx_${s}_inv_due" ON "${s}".invoices(due_date)`,
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
];
}
private async seedDefaultChartOfAccounts(queryRunner: any, s: string): Promise<void> {
const accounts = [
// Assets
[1000, 'Operating Cash - Checking', 'asset', 'operating', false, true],
[1010, 'Operating Cash - Savings', 'asset', 'operating', false, true],
[1020, 'Operating Cash - Money Market', 'asset', 'operating', false, true],
[1100, 'Reserve Cash - Checking', 'asset', 'reserve', false, true],
[1110, 'Reserve Cash - Savings', 'asset', 'reserve', false, true],
[1120, 'Reserve Cash - Money Market', 'asset', 'reserve', false, true],
[1130, 'Reserve Cash - CDs', 'asset', 'reserve', false, true],
[1140, 'Reserve Cash - Treasuries', 'asset', 'reserve', false, true],
[1200, 'Accounts Receivable - Assessments', 'asset', 'operating', false, true],
[1210, 'Accounts Receivable - Late Fees', 'asset', 'operating', false, true],
[1300, 'Prepaid Insurance', 'asset', 'operating', false, true],
[1400, 'Other Current Assets', 'asset', 'operating', false, false],
// Liabilities
[2000, 'Accounts Payable', 'liability', 'operating', false, true],
[2100, 'Accrued Expenses', 'liability', 'operating', false, true],
[2200, 'Prepaid Assessments', 'liability', 'operating', false, true],
[2300, 'Security Deposits Held', 'liability', 'operating', false, false],
[2400, 'Loan Payable', 'liability', 'operating', false, false],
// Equity
[3000, 'Operating Fund Balance', 'equity', 'operating', false, true],
[3100, 'Reserve Fund Balance', 'equity', 'reserve', false, true],
[3200, 'Retained Earnings', 'equity', 'operating', false, true],
// Income
[4000, 'Regular Assessments', 'income', 'operating', false, true],
[4010, 'Special Assessments', 'income', 'operating', false, true],
[4100, 'Late Fees', 'income', 'operating', false, true],
[4200, 'Interest Income - Operating', 'income', 'operating', false, true],
[4210, 'Interest Income - Reserve', 'income', 'reserve', false, true],
[4300, 'Transfer Fees', 'income', 'operating', false, false],
[4400, 'Clubhouse Rental Income', 'income', 'operating', false, false],
[4500, 'Other Income', 'income', 'operating', false, false],
[4600, 'Reserve Contributions', 'income', 'reserve', false, true],
// Expenses
[5000, 'Management Fees', 'expense', 'operating', true, true],
[5100, 'Insurance - Property', 'expense', 'operating', false, true],
[5110, 'Insurance - D&O', 'expense', 'operating', false, true],
[5120, 'Insurance - Liability', 'expense', 'operating', false, true],
[5200, 'Utilities - Water/Sewer', 'expense', 'operating', false, true],
[5210, 'Utilities - Electric (Common)', 'expense', 'operating', false, true],
[5220, 'Utilities - Gas', 'expense', 'operating', false, true],
[5230, 'Utilities - Trash/Recycling', 'expense', 'operating', false, true],
[5300, 'Landscape Maintenance', 'expense', 'operating', true, true],
[5310, 'Landscape - Irrigation', 'expense', 'operating', false, true],
[5400, 'Pool Maintenance', 'expense', 'operating', true, true],
[5500, 'Building Maintenance', 'expense', 'operating', false, true],
[5510, 'Janitorial', 'expense', 'operating', true, true],
[5600, 'Pest Control', 'expense', 'operating', true, true],
[5700, 'Legal Fees', 'expense', 'operating', true, true],
[5800, 'Accounting/Audit Fees', 'expense', 'operating', true, true],
[5900, 'Office & Admin Expenses', 'expense', 'operating', false, true],
[5910, 'Postage & Mailing', 'expense', 'operating', false, true],
[5920, 'Bank Fees', 'expense', 'operating', false, true],
[6000, 'Repairs & Maintenance - General', 'expense', 'operating', false, true],
[6100, 'Snow Removal', 'expense', 'operating', true, true],
[6200, 'Security/Gate', 'expense', 'operating', false, true],
[6300, 'Cable/Internet (Common)', 'expense', 'operating', false, false],
[6400, 'Social Events/Activities', 'expense', 'operating', false, false],
[6500, 'Contingency/Miscellaneous', 'expense', 'operating', false, true],
// Reserve Expenses
[7000, 'Reserve - Roof Replacement', 'expense', 'reserve', false, true],
[7100, 'Reserve - Paving/Asphalt', 'expense', 'reserve', false, true],
[7200, 'Reserve - Pool Renovation', 'expense', 'reserve', false, true],
[7300, 'Reserve - HVAC Replacement', 'expense', 'reserve', false, true],
[7400, 'Reserve - Painting/Exterior', 'expense', 'reserve', false, true],
[7500, 'Reserve - Fencing', 'expense', 'reserve', false, true],
[7600, 'Reserve - Elevator', 'expense', 'reserve', false, false],
[7700, 'Reserve - Other', 'expense', 'reserve', false, true],
];
for (const [num, name, type, fund, is1099, isSys] of accounts) {
await queryRunner.query(
`INSERT INTO "${s}".accounts (account_number, name, account_type, fund_type, is_1099_reportable, is_system)
VALUES ($1, $2, $3, $4, $5, $6)`,
[num, name, type, fund, is1099, isSys],
);
}
}
private async seedDefaultFiscalPeriods(queryRunner: any, s: string): Promise<void> {
const currentYear = new Date().getFullYear();
for (let month = 1; month <= 12; month++) {
await queryRunner.query(
`INSERT INTO "${s}".fiscal_periods (year, month, status) VALUES ($1, $2, 'open')`,
[currentYear, month],
);
}
}
}

View File

@@ -0,0 +1,37 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request, Response, NextFunction } from 'express';
import * as jwt from 'jsonwebtoken';
export interface TenantRequest extends Request {
tenantSchema?: string;
orgId?: string;
userId?: string;
userRole?: string;
}
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private configService: ConfigService) {}
use(req: TenantRequest, _res: Response, next: NextFunction) {
// Try to extract tenant info from Authorization header JWT
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
const token = authHeader.substring(7);
const secret = this.configService.get<string>('JWT_SECRET');
const decoded = jwt.verify(token, secret!) as any;
if (decoded?.orgSchema) {
req.tenantSchema = decoded.orgSchema;
req.orgId = decoded.orgId;
req.userId = decoded.sub;
req.userRole = decoded.role;
}
} catch {
// Token invalid or expired - let Passport handle the auth error
}
}
next();
}
}

View File

@@ -0,0 +1,35 @@
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { TenantRequest } from './tenant.middleware';
@Injectable({ scope: Scope.REQUEST })
export class TenantService {
private schema: string;
constructor(
@Inject(REQUEST) private request: TenantRequest,
private dataSource: DataSource,
) {
this.schema = this.request.tenantSchema || '';
}
getSchema(): string {
return this.schema;
}
async query(sql: string, params?: any[]): Promise<any> {
if (!this.schema) {
throw new Error('No tenant schema set. Ensure user has selected an organization.');
}
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.query(`SET search_path TO "${this.schema}", shared, public`);
const result = await queryRunner.query(sql, params);
return result;
} finally {
await queryRunner.release();
}
}
}

42
backend/src/main.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
// Request logging
app.use((req: any, _res: any, next: any) => {
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
next();
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: false,
forbidNonWhitelisted: false,
transform: true,
}),
);
app.enableCors({
origin: ['http://localhost', 'http://localhost:5173'],
credentials: true,
});
const config = new DocumentBuilder()
.setTitle('HOA Financial Platform API')
.setDescription('API for the HOA Financial Intelligence Platform')
.setVersion('0.1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
await app.listen(3000);
console.log('Backend running on port 3000');
}
bootstrap();

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

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

24
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
}
}