import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; @Injectable() export class TenantSchemaService { constructor(private dataSource: DataSource) {} async createTenantSchema(schemaName: string): Promise { 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.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 VARCHAR(50) 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, is_primary BOOLEAN DEFAULT FALSE, interest_rate DECIMAL(6,4), 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', 'monthly_actual' )), 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, is_reconciled 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)) )`, // Assessment Groups `CREATE TABLE "${s}".assessment_groups ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(255) NOT NULL, description TEXT, regular_assessment DECIMAL(10,2) NOT NULL DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00, unit_count INTEGER DEFAULT 0, frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')), due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}', due_day INTEGER DEFAULT 1, is_default BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, // 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), assessment_group_id UUID REFERENCES "${s}".assessment_groups(id), 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' )), period_start DATE, period_end DATE, assessment_group_id UUID REFERENCES "${s}".assessment_groups(id), 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, last_negotiated DATE, 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() )`, // Unified Projects (replaces reserve_components + capital_projects for new features) `CREATE TABLE "${s}".projects ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(255) NOT NULL, description TEXT, category VARCHAR(100), estimated_cost DECIMAL(15,2) NOT NULL DEFAULT 0, actual_cost DECIMAL(15,2), current_fund_balance DECIMAL(15,2) DEFAULT 0.00, annual_contribution DECIMAL(12,2) DEFAULT 0.00, fund_source VARCHAR(20) CHECK (fund_source IN ('operating', 'reserve', 'special_assessment')), funded_percentage DECIMAL(5,2) DEFAULT 0, useful_life_years INTEGER, remaining_life_years DECIMAL(5,1), condition_rating INTEGER CHECK (condition_rating BETWEEN 1 AND 10), last_replacement_date DATE, next_replacement_date DATE, planned_date DATE, target_year INTEGER, 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' )), priority INTEGER DEFAULT 3 CHECK (priority BETWEEN 1 AND 5), account_id UUID REFERENCES "${s}".accounts(id), notes TEXT, is_active BOOLEAN DEFAULT TRUE, is_funding_locked BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, // AI Investment Recommendations (saved per tenant) `CREATE TABLE "${s}".ai_recommendations ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), recommendations_json JSONB NOT NULL, overall_assessment TEXT, risk_notes JSONB, requested_by UUID, response_time_ms INTEGER, status VARCHAR(20) DEFAULT 'complete', error_message TEXT, created_at TIMESTAMPTZ DEFAULT NOW() )`, // Health Scores (AI-derived operating / reserve fund health) `CREATE TABLE "${s}".health_scores ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')), score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100), previous_score INTEGER, trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')), label VARCHAR(30), summary TEXT, factors JSONB, recommendations JSONB, missing_data JSONB, status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')), response_time_ms INTEGER, calculated_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`, // Attachments (file storage for receipts/invoices) `CREATE TABLE "${s}".attachments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), journal_entry_id UUID NOT NULL REFERENCES "${s}".journal_entries(id) ON DELETE CASCADE, filename VARCHAR(255) NOT NULL, mime_type VARCHAR(100) NOT NULL, file_size INTEGER NOT NULL, file_data BYTEA NOT NULL, uploaded_by UUID NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() )`, // Indexes `CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`, `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 { 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 { 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], ); } } }