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:
373
backend/src/database/tenant-schema.service.ts
Normal file
373
backend/src/database/tenant-schema.service.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user