From 243770cea5a4d92483676704fdfbb8cd9d3224e1 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Tue, 17 Feb 2026 19:58:04 -0500 Subject: [PATCH] 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 --- .env.example | 7 + .gitignore | 41 + ...inancial_Platform_Part1_Market_Analysis.md | 123 +++ ...ial_Platform_Part2_Competitive_Analysis.md | 278 +++++++ HOA_Financial_Platform_Part3_MVP_Features.md | 194 +++++ ...l_Platform_Part4_Technical_Architecture.md | 228 +++++ backend/Dockerfile.dev | 12 + backend/nest-cli.json | 8 + backend/package.json | 68 ++ backend/src/app.controller.ts | 13 + backend/src/app.module.ts | 62 ++ .../decorators/current-user.decorator.ts | 9 + .../src/common/decorators/roles.decorator.ts | 4 + backend/src/common/guards/roles.guard.ts | 20 + backend/src/database/database.module.ts | 10 + backend/src/database/tenant-schema.service.ts | 373 +++++++++ backend/src/database/tenant.middleware.ts | 37 + backend/src/database/tenant.service.ts | 35 + backend/src/main.ts | 42 + .../modules/accounts/accounts.controller.ts | 46 + .../src/modules/accounts/accounts.module.ts | 10 + .../src/modules/accounts/accounts.service.ts | 107 +++ .../accounts/dto/create-account.dto.ts | 35 + .../accounts/dto/update-account.dto.ts | 29 + backend/src/modules/auth/auth.controller.ts | 50 ++ backend/src/modules/auth/auth.module.ts | 28 + backend/src/modules/auth/auth.service.ts | 136 +++ backend/src/modules/auth/dto/login.dto.ts | 12 + backend/src/modules/auth/dto/register.dto.ts | 23 + .../src/modules/auth/dto/switch-org.dto.ts | 8 + .../src/modules/auth/guards/jwt-auth.guard.ts | 5 + .../modules/auth/strategies/jwt.strategy.ts | 25 + .../modules/auth/strategies/local.strategy.ts | 15 + .../src/modules/budgets/budgets.controller.ts | 37 + backend/src/modules/budgets/budgets.module.ts | 10 + .../src/modules/budgets/budgets.service.ts | 103 +++ .../modules/budgets/dto/upsert-budget.dto.ts | 30 + .../capital-projects.controller.ts | 24 + .../capital-projects.module.ts | 10 + .../capital-projects.service.ts | 50 ++ .../fiscal-periods.controller.ts | 30 + .../fiscal-periods/fiscal-periods.module.ts | 10 + .../fiscal-periods/fiscal-periods.service.ts | 61 ++ .../investments/investments.controller.ts | 24 + .../modules/investments/investments.module.ts | 10 + .../investments/investments.service.ts | 46 + .../modules/invoices/invoices.controller.ts | 28 + .../src/modules/invoices/invoices.module.ts | 10 + .../src/modules/invoices/invoices.service.ts | 120 +++ .../dto/create-journal-entry.dto.ts | 62 ++ .../dto/void-journal-entry.dto.ts | 8 + .../journal-entries.controller.ts | 51 ++ .../journal-entries/journal-entries.module.ts | 12 + .../journal-entries.service.ts | 191 +++++ .../dto/create-organization.dto.ts | 45 + .../entities/organization.entity.ts | 66 ++ .../entities/user-organization.entity.ts | 41 + .../organizations/organizations.controller.ts | 25 + .../organizations/organizations.module.ts | 15 + .../organizations/organizations.service.ts | 80 ++ .../modules/payments/payments.controller.ts | 21 + .../src/modules/payments/payments.module.ts | 10 + .../src/modules/payments/payments.service.ts | 90 ++ .../src/modules/reports/reports.controller.ts | 35 + backend/src/modules/reports/reports.module.ts | 10 + .../src/modules/reports/reports.service.ts | 227 +++++ .../reserve-components.controller.ts | 24 + .../reserve-components.module.ts | 10 + .../reserve-components.service.ts | 47 ++ backend/src/modules/units/units.controller.ts | 24 + backend/src/modules/units/units.module.ts | 10 + backend/src/modules/units/units.service.ts | 51 ++ .../src/modules/users/dto/create-user.dto.ts | 23 + .../src/modules/users/entities/user.entity.ts | 57 ++ backend/src/modules/users/users.module.ts | 11 + backend/src/modules/users/users.service.ts | 41 + .../src/modules/vendors/vendors.controller.ts | 29 + backend/src/modules/vendors/vendors.module.ts | 10 + .../src/modules/vendors/vendors.service.ts | 61 ++ backend/tsconfig.build.json | 4 + backend/tsconfig.json | 24 + db/init/00-init.sql | 79 ++ db/seed/seed.sql | 784 ++++++++++++++++++ docker-compose.yml | 95 +++ frontend/Dockerfile.dev | 12 + frontend/index.html | 13 + frontend/package.json | 41 + frontend/postcss.config.cjs | 14 + frontend/src/App.tsx | 104 +++ frontend/src/components/layout/AppLayout.tsx | 81 ++ frontend/src/components/layout/Sidebar.tsx | 86 ++ frontend/src/main.tsx | 37 + frontend/src/pages/PlaceholderPage.tsx | 13 + frontend/src/pages/accounts/AccountsPage.tsx | 271 ++++++ frontend/src/pages/auth/LoginPage.tsx | 93 +++ frontend/src/pages/auth/RegisterPage.tsx | 102 +++ frontend/src/pages/auth/SelectOrgPage.tsx | 166 ++++ frontend/src/pages/budgets/BudgetsPage.tsx | 175 ++++ .../capital-projects/CapitalProjectsPage.tsx | 139 ++++ .../src/pages/dashboard/DashboardPage.tsx | 147 ++++ .../src/pages/investments/InvestmentsPage.tsx | 146 ++++ frontend/src/pages/invoices/InvoicesPage.tsx | 115 +++ frontend/src/pages/payments/PaymentsPage.tsx | 125 +++ .../src/pages/reports/BalanceSheetPage.tsx | 102 +++ .../src/pages/reports/BudgetVsActualPage.tsx | 187 +++++ .../src/pages/reports/IncomeStatementPage.tsx | 91 ++ frontend/src/pages/reports/SankeyPage.tsx | 247 ++++++ frontend/src/pages/reserves/ReservesPage.tsx | 175 ++++ .../pages/transactions/TransactionsPage.tsx | 381 +++++++++ frontend/src/pages/units/UnitsPage.tsx | 120 +++ frontend/src/pages/vendors/VendorsPage.tsx | 118 +++ frontend/src/services/api.ts | 28 + frontend/src/stores/authStore.ts | 67 ++ frontend/src/theme/theme.ts | 10 + frontend/tsconfig.json | 25 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 22 + nginx/default.conf | 37 + 118 files changed, 8569 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 HOA_Financial_Platform_Part1_Market_Analysis.md create mode 100644 HOA_Financial_Platform_Part2_Competitive_Analysis.md create mode 100644 HOA_Financial_Platform_Part3_MVP_Features.md create mode 100644 HOA_Financial_Platform_Part4_Technical_Architecture.md create mode 100644 backend/Dockerfile.dev create mode 100644 backend/nest-cli.json create mode 100644 backend/package.json create mode 100644 backend/src/app.controller.ts create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/common/decorators/current-user.decorator.ts create mode 100644 backend/src/common/decorators/roles.decorator.ts create mode 100644 backend/src/common/guards/roles.guard.ts create mode 100644 backend/src/database/database.module.ts create mode 100644 backend/src/database/tenant-schema.service.ts create mode 100644 backend/src/database/tenant.middleware.ts create mode 100644 backend/src/database/tenant.service.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/modules/accounts/accounts.controller.ts create mode 100644 backend/src/modules/accounts/accounts.module.ts create mode 100644 backend/src/modules/accounts/accounts.service.ts create mode 100644 backend/src/modules/accounts/dto/create-account.dto.ts create mode 100644 backend/src/modules/accounts/dto/update-account.dto.ts create mode 100644 backend/src/modules/auth/auth.controller.ts create mode 100644 backend/src/modules/auth/auth.module.ts create mode 100644 backend/src/modules/auth/auth.service.ts create mode 100644 backend/src/modules/auth/dto/login.dto.ts create mode 100644 backend/src/modules/auth/dto/register.dto.ts create mode 100644 backend/src/modules/auth/dto/switch-org.dto.ts create mode 100644 backend/src/modules/auth/guards/jwt-auth.guard.ts create mode 100644 backend/src/modules/auth/strategies/jwt.strategy.ts create mode 100644 backend/src/modules/auth/strategies/local.strategy.ts create mode 100644 backend/src/modules/budgets/budgets.controller.ts create mode 100644 backend/src/modules/budgets/budgets.module.ts create mode 100644 backend/src/modules/budgets/budgets.service.ts create mode 100644 backend/src/modules/budgets/dto/upsert-budget.dto.ts create mode 100644 backend/src/modules/capital-projects/capital-projects.controller.ts create mode 100644 backend/src/modules/capital-projects/capital-projects.module.ts create mode 100644 backend/src/modules/capital-projects/capital-projects.service.ts create mode 100644 backend/src/modules/fiscal-periods/fiscal-periods.controller.ts create mode 100644 backend/src/modules/fiscal-periods/fiscal-periods.module.ts create mode 100644 backend/src/modules/fiscal-periods/fiscal-periods.service.ts create mode 100644 backend/src/modules/investments/investments.controller.ts create mode 100644 backend/src/modules/investments/investments.module.ts create mode 100644 backend/src/modules/investments/investments.service.ts create mode 100644 backend/src/modules/invoices/invoices.controller.ts create mode 100644 backend/src/modules/invoices/invoices.module.ts create mode 100644 backend/src/modules/invoices/invoices.service.ts create mode 100644 backend/src/modules/journal-entries/dto/create-journal-entry.dto.ts create mode 100644 backend/src/modules/journal-entries/dto/void-journal-entry.dto.ts create mode 100644 backend/src/modules/journal-entries/journal-entries.controller.ts create mode 100644 backend/src/modules/journal-entries/journal-entries.module.ts create mode 100644 backend/src/modules/journal-entries/journal-entries.service.ts create mode 100644 backend/src/modules/organizations/dto/create-organization.dto.ts create mode 100644 backend/src/modules/organizations/entities/organization.entity.ts create mode 100644 backend/src/modules/organizations/entities/user-organization.entity.ts create mode 100644 backend/src/modules/organizations/organizations.controller.ts create mode 100644 backend/src/modules/organizations/organizations.module.ts create mode 100644 backend/src/modules/organizations/organizations.service.ts create mode 100644 backend/src/modules/payments/payments.controller.ts create mode 100644 backend/src/modules/payments/payments.module.ts create mode 100644 backend/src/modules/payments/payments.service.ts create mode 100644 backend/src/modules/reports/reports.controller.ts create mode 100644 backend/src/modules/reports/reports.module.ts create mode 100644 backend/src/modules/reports/reports.service.ts create mode 100644 backend/src/modules/reserve-components/reserve-components.controller.ts create mode 100644 backend/src/modules/reserve-components/reserve-components.module.ts create mode 100644 backend/src/modules/reserve-components/reserve-components.service.ts create mode 100644 backend/src/modules/units/units.controller.ts create mode 100644 backend/src/modules/units/units.module.ts create mode 100644 backend/src/modules/units/units.service.ts create mode 100644 backend/src/modules/users/dto/create-user.dto.ts create mode 100644 backend/src/modules/users/entities/user.entity.ts create mode 100644 backend/src/modules/users/users.module.ts create mode 100644 backend/src/modules/users/users.service.ts create mode 100644 backend/src/modules/vendors/vendors.controller.ts create mode 100644 backend/src/modules/vendors/vendors.module.ts create mode 100644 backend/src/modules/vendors/vendors.service.ts create mode 100644 backend/tsconfig.build.json create mode 100644 backend/tsconfig.json create mode 100644 db/init/00-init.sql create mode 100644 db/seed/seed.sql create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile.dev create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.cjs create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/PlaceholderPage.tsx create mode 100644 frontend/src/pages/accounts/AccountsPage.tsx create mode 100644 frontend/src/pages/auth/LoginPage.tsx create mode 100644 frontend/src/pages/auth/RegisterPage.tsx create mode 100644 frontend/src/pages/auth/SelectOrgPage.tsx create mode 100644 frontend/src/pages/budgets/BudgetsPage.tsx create mode 100644 frontend/src/pages/capital-projects/CapitalProjectsPage.tsx create mode 100644 frontend/src/pages/dashboard/DashboardPage.tsx create mode 100644 frontend/src/pages/investments/InvestmentsPage.tsx create mode 100644 frontend/src/pages/invoices/InvoicesPage.tsx create mode 100644 frontend/src/pages/payments/PaymentsPage.tsx create mode 100644 frontend/src/pages/reports/BalanceSheetPage.tsx create mode 100644 frontend/src/pages/reports/BudgetVsActualPage.tsx create mode 100644 frontend/src/pages/reports/IncomeStatementPage.tsx create mode 100644 frontend/src/pages/reports/SankeyPage.tsx create mode 100644 frontend/src/pages/reserves/ReservesPage.tsx create mode 100644 frontend/src/pages/transactions/TransactionsPage.tsx create mode 100644 frontend/src/pages/units/UnitsPage.tsx create mode 100644 frontend/src/pages/vendors/VendorsPage.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/stores/authStore.ts create mode 100644 frontend/src/theme/theme.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx/default.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3232c6c --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +POSTGRES_USER=hoafinance +POSTGRES_PASSWORD=change_me +POSTGRES_DB=hoafinance +DATABASE_URL=postgresql://hoafinance:change_me@postgres:5432/hoafinance +REDIS_URL=redis://redis:6379 +JWT_SECRET=change_me_to_random_string +NODE_ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0397805 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ + +# Environment +.env +.env.local +.env.*.local +secrets/ + +# Logs +*.log +npm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# Docker volumes +postgres_data/ +redis_data/ +pgdata/ + +# SSL +letsencrypt/ + +# Coverage +coverage/ +.nyc_output/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# TypeScript +*.tsbuildinfo diff --git a/HOA_Financial_Platform_Part1_Market_Analysis.md b/HOA_Financial_Platform_Part1_Market_Analysis.md new file mode 100644 index 0000000..f742c81 --- /dev/null +++ b/HOA_Financial_Platform_Part1_Market_Analysis.md @@ -0,0 +1,123 @@ +# HOA Financial Intelligence Platform +## Part 1: Market Analysis & Opportunity + +--- + +## Executive Summary + +The homeowners association (HOA) financial management market represents a **$38.5 billion industry** with remarkably low digitization. Over 355,000 HOAs currently manage community finances, yet the vast majority rely on spreadsheets, manual processes, and volunteer treasurers with limited financial expertise. + +**The Core Problem:** Existing solutions fall into two inadequate categories: (1) generic accounting software that's painful for HOA-specific workflows like reserve fund management, and (2) ultra-basic HOA management tools that ignore sophisticated financial planning. No existing platform treats HOA treasury management as a strategic discipline. Volunteer treasurers desperately need tools that not only track finances but optimize them. + +**The Solution:** A Financial Intelligence Platform purpose-built for HOAs that combines automated accounting with AI-powered treasury management. This isn't just bookkeeping software—it's a strategic financial partner that helps volunteer treasurers make smarter decisions, optimize reserve fund investments, and demonstrate fiscal responsibility. + +--- + +## Market Landscape + +### Industry Overview + +| Metric | Value | +|--------|-------| +| **Total Market Size** | $38.5 Billion (2024) | +| **Annual Growth Rate** | 1.1% Year-over-Year | +| **Number of HOAs** | 355,000+ (United States) | +| **Homes in HOAs** | 74 Million+ | +| **Percent of New Homes in HOAs** | 62% | +| **Digitization Level** | < 15% use dedicated software | + +### 2025 Market Trends + +1. **Increased Financial Planning Sophistication** - HOAs moving beyond basic budgeting toward multi-year capital planning +2. **Transparency Expectations** - Homeowners demanding visibility into assessment spending +3. **Stronger Financial Health Focus** - Post-pandemic focus on robust financial planning +4. **Digital Transformation Acceleration** - Shift toward cloud-based solutions accelerating + +### The Digitization Gap + +- **85%+ of HOAs** rely on spreadsheets or paper-based accounting +- **90%+ of reserve funds** sit in low-yield savings accounts earning near-zero interest +- **Most treasurers** are volunteers with limited financial training +- **Reserve studies** are expensive ($500-$2,000+) and often outdated + +--- + +## Competitive Analysis + +| Platform | Focus Area | Pricing Model | Key Gap Analysis | +|----------|-----------|---------------|------------------| +| **MoneyMinder** | Basic treasurer/accounting | ~$159-299/year | Too simple; lacks investment tracking, forecasting, or strategic tools | +| **PayHOA** | All-in-one HOA management | Per-unit (~$0.80/unit/month) | Broad but shallow on financial depth; no AI forecasting | +| **EasyHOA** | Self-managed HOAs | ~$49/month | Budgeting and basic tracking only | +| **HOA Start** | Small HOAs | $39/month | Entry-level accounting without strategic capabilities | +| **RunHOA** | Full HOA management | $399/year flat fee | No investment recommendations, no AI features | +| **QuickBooks** | General business accounting | $30-200/month | Painfully difficult for HOA-specific needs | + +### Key Insight: The White Space + +**No existing competitor offers investment optimization, cash flow forecasting, or treasury-level strategy.** + +Existing solutions view financial management as a checkbox feature rather than a core competency. This is a financial intelligence layer that doesn't exist in the market today. + +--- + +## Product Differentiation + +### Core Differentiators + +1. **Reserve Fund Investment Management** + - Automated yield optimization across FDIC-insured instruments + - Cash flow-aware laddering strategies + - Value: 100-unit HOA with $200K reserves can earn $4,000+ additional annual interest + +2. **Cash Flow Forecasting with AI** + - ML models trained on HOA-specific patterns + - 6-24 month forward projections with confidence intervals + - Scenario modeling for major expenditures + +3. **Automated Reserve Studies** + - Component-based tracking of reserve-eligible assets + - Smart depreciation scheduling + - Continuous updates vs. expensive annual reports + +4. **AI Investment Recommendations (Phase 2)** + - Yield optimization based on liquidity requirements + - Risk-adjusted return analysis for community reserves + +5. **End-of-Year Accounting Validation** + - Automated reconciliation checks + - Audit-ready packages + +--- + +## Pricing Models + +| Tier | Monthly Price | Target Segment | Core Features | +|------|---------------|----------------|---------------| +| **Starter** | $49/mo | Small HOAs (<50 units) | Basic accounting, invoicing, reporting | +| **Pro** | $99/mo | Mid-size HOAs (50-150 units) | + Reserve tracking, investment integration, forecasting | +| **Enterprise** | $199/mo | Large HOAs (150+ units) | + AI recommendations, API access, custom integrations | +| **Advisory** | 0.25-0.50% AUM | High-reserve HOAs | Managed reserve fund investments | + +--- + +## Why This Works + +1. **Large Underserved Market** - 351,000+ HOAs in the US alone +2. **High Willingness to Pay** - Volunteers want professional tools +3. **Real AI Differentiation** - Legitimate financial forecasting +4. **Recurring Revenue Moat** - Mission-critical system of record +5. **Expansion Opportunities** - Bank partnerships, RIA relationships + +--- + +## MVP Features (First 90 Days) + +1. HOA-specific chart of accounts, with differentiaed tracking of Operating Accounts and Reserve Accounts +2. Month by month income/expense tracking with customizble budget codes - support recurring fees along with a reconciliation feature to do monthly bill reconsiliation +3. Reserve fund tracking with interest rate optimization +4. Investment account tracking for both Operating Investment Accounts and Reserve Accounts(ie CDs, Money Markets, Treasuries) +5. Basic cash flow dashboard with forecasting ability (ie Pre-load budget, then track actuals) +6. Feature to track multi-year Capital Project Plan (IE a 5 year capital project plan with names, estimated costs, and estimated target date) - work into the Reserve Funding Forecast. +7. Support Both Budget and Actuals Sankey Diagrams. +8. End-of-year package generation and accounting reconciliation. diff --git a/HOA_Financial_Platform_Part2_Competitive_Analysis.md b/HOA_Financial_Platform_Part2_Competitive_Analysis.md new file mode 100644 index 0000000..b06c87d --- /dev/null +++ b/HOA_Financial_Platform_Part2_Competitive_Analysis.md @@ -0,0 +1,278 @@ +# HOA Financial Intelligence Platform +## Part 2: Detailed Competitive Analysis + +--- + +## Executive Summary + +The competitive landscape for HOA financial management reveals significant gaps in treasury-level financial intelligence. While numerous HOA management platforms exist, none provide sophisticated financial optimization tools, investment management for reserve funds, or AI-powered forecasting capabilities. This analysis examines 7 key competitors and identifies the strategic white space that our platform will occupy. + +--- + +## Competitive Landscape Overview + +### Market Segments + +**Segment 1: Dedicated HOA Accounting Platforms** +- Target: Small to medium HOAs focused on basic accounting +- Players: MoneyMinder, EasyHOA, HOA Start +- Pricing: $39-99/month +- Gap: No investment features or advanced forecasting + +**Segment 2: All-in-One HOA Management Platforms** +- Target: Mid to large HOAs wanting comprehensive management +- Players: PayHOA, RunHOA +- Pricing: $0.80/unit/month or $399/yr flat +- Gap: Financial tools are shallow and not treasury-focused + +**Segment 3: General Business Accounting** +- Target: HOAs using QuickBooks or similar +- Players: QuickBooks, Xero +- Pricing: $30-200/month +- Gap: HOA-specific workflows are painful, no reserve optimization + +**Segment 4: Property Management Platforms** +- Target: Large HOAs managed by professional property managers +- Players: Buildium, AppFolio +- Pricing: $150-300+/month +- Gap: HOA features are afterthought, designed for rentals + +--- + +## Detailed Competitor Analysis + +### 1. MoneyMinder + +**Product Depth: Basic** +- **Core Features**: Checkbook accounting, invoicing, basic reporting +- **Strengths**: Simple interface, affordable, established +- **Weaknesses**: No investment tracking, no forecasting, limited reporting +- **Target Segment**: Small HOAs (under 50 units) +- **Pricing Strategy**: $159-299/year +- **Technical Approach**: Simple web application with mobile apps +- **Customer Reviews**: "Good for basic tracking but lacks financial intelligence" + +**Competitive Gap Analysis** +- **Missing**: Reserve fund optimization tools +- **Missing**: Cash flow forecasting +- **Missing**: AI-powered recommendations +- **Missing**: End-of-year accounting automation + +**Strategic Positioning**: Entry-level player with limited financial sophistication + +### 2. PayHOA + +**Product Depth: Broad but Shallow** +- **Core Features**: All-in-one management including financials, communications, voting +- **Strengths**: Comprehensive coverage, per-unit pricing scales well +- **Weaknesses**: Financial layer is underdeveloped, no treasury focus +- **Target Segment**: Mid-size to large HOAs wanting full management +- **Pricing Strategy**: ~$0.80 per unit per month +- **Technical Approach**: Single application with multiple modules +- **Customer Reviews**: "Good for management but financial tools are basic" + +**Competitive Gap Analysis** +- **Missing**: Investment optimization for reserve funds +- **Missing**: Advanced financial forecasting +- **Missing**: Automated reserve studies +- **Missing**: AI-powered financial insights + +**Strategic Positioning**: Generalist platform with weak financial specialization + +### 3. EasyHOA + +**Product Depth: Minimal** +- **Core Features**: Basic budgeting, dues tracking, reporting +- **Strengths**: Very affordable, simple interface +- **Weaknesses**: Extremely limited feature set, no advanced tools +- **Target Segment**: Self-managed HOAs with minimal needs +- **Pricing Strategy**: $49/month flat +- **Technical Approach**: Lightweight web application +- **Customer Reviews**: "Good for very basic needs, but outgrows quickly" + +**Competitive Gap Analysis** +- **Missing**: All investment features +- **Missing**: Cash flow planning +- **Missing**: Reserve fund management +- **Missing**: Strategic financial tools + +**Strategic Positioning**: Entry-level player with limited scalability + +### 4. HOA Start + +**Product Depth: Foundation** +- **Core Features**: Accounting, document management, basic reporting +- **Strengths**: Affordable, focused on small HOA needs +- **Weaknesses**: No advanced financial features, limited scalability +- **Target Segment**: Small HOAs (under 50 units) +- **Pricing Strategy**: $39/month +- **Technical Approach**: Modern web application with good UX +- **Customer Reviews**: "Good accounting tools but lacks financial intelligence" + +**Competitive Gap Analysis** +- **Missing**: Reserve fund optimization +- **Missing**: Financial forecasting +- **Missing**: AI recommendations +- **Missing**: Advanced reporting + +**Strategic Positioning**: Affordable accounting platform with limited financial sophistication + +### 5. RunHOA + +**Product Depth: Comprehensive but Financially Weak** +- **Core Features**: Full HOA management including financials, maintenance, communications +- **Strengths**: Complete coverage, flat pricing model +- **Weaknesses**: Financial tools are rudimentary, no AI features +- **Target Segment**: Medium to large HOAs wanting full management +- **Pricing Strategy**: $399/year flat fee +- **Technical Approach**: Comprehensive but dated web application +- **Customer Reviews**: "Good overall but financial tools need improvement" + +**Competitive Gap Analysis** +- **Missing**: Investment management capabilities +- **Missing**: Cash flow forecasting +- **Missing**: Automated reserve studies +- **Missing**: AI-powered financial insights + +**Strategic Positioning**: Generalist platform with inadequate financial layer + +### 6. QuickBooks Online + +**Product Depth: Sophisticated but Wrong Domain** +- **Core Features**: Full accounting with advanced reporting, integrations +- **Strengths**: Powerful financial tools, extensive integrations +- **Weaknesses**: HOA workflows are painful, no reserve fund optimization +- **Target Segment**: HOAs using general business accounting +- **Pricing Strategy**: $30-200/month +- **Technical Approach**: Comprehensive accounting platform +- **Customer Reviews**: "Good for business but awkward for HOA finances" + +**Competative Gap Analysis** +- **Missing**: HOA-specific chart of accounts +- **Missing**: Reserve fund management +- **Missing**: Automated reserve studies +- **Missing**: AI-powered HOA financial insights + +**Strategic Positioning**: Powerful but inappropriate for HOA-specific needs + +### 7. Buildium/AppFolio + +**Product Depth: Enterprise Property Management** +- **Core Features**: Comprehensive rental property management with HOA modules +- **Strengths**: Enterprise-grade features, strong integrations +- **Weaknesses**: HOA features are afterthought, expensive for small communities +- **Target Segment**: Large HOAs managed by professional property managers +- **Pricing Strategy**: $150-300+/month with volume discounts +- **Technical Approach**: Enterprise SaaS platform +- **Customer Reviews**: "Good for property management, HOA features are limited" + +**Competitive Gap Analysis** +- **Missing**: Purpose-built HOA financial tools +- **Missing**: Reserve fund optimization +- **Missing**: AI-powered forecasting +- **Missing**: Affordable pricing for small HOAs + +**Strategic Positioning**: Enterprise platform with HOA as secondary focus + +--- + +## Side-by-Side Comparison Matrix + +| Feature | MoneyMinder | PayHOA | EasyHOA | HOA Start | RunHOA | QuickBooks | Buildium | +|---------|-------------|--------|---------|-----------|--------|------------|----------| +| **Reserve Fund Management** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Investment Optimization** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Cash Flow Forecasting** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Automated Reserve Studies** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **AI Financial Insights** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **HOA-Specific Accounting** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| **Advanced Reporting** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | +| **Mobile Access** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **API Access** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | +| **Price Point** | Low | Medium | Very Low | Low | Medium | Medium-High | High | + +--- + +## Strategic Positioning Analysis + +### Market Gaps We Can Exploit + +1. **Treasury-Grade Financial Intelligence** + - All competitors focus on accounting, not treasury management + - None provide investment optimization for reserve funds + - Cash flow forecasting is completely absent + +2. **AI-Powered Financial Insights** + - No competitor uses AI for financial forecasting + - No automated recommendation engines + - No predictive analytics for HOA finances + +3. **Strategic Reserve Management** + - Automated reserve studies are non-existent + - No dynamic reserve funding models + - No integration with condition assessments + +4. **End-to-End Financial Automation** + - Year-end accounting is manual everywhere + - No audit-ready package generation + - No automated compliance checking + +### Competitive Advantages + +1. **First-Mover Advantage** in AI-powered HOA financial intelligence +2. **Specialization** over generalist solutions +3. **Real ROI** through reserve fund optimization +4. **Solving real pain points** that competitors ignore +5. **Scalable pricing** from small to large HOAs + +### Target Market Prioritization + +**Primary Targets**: Medium HOAs (50-150 units) that have outgrown basic tools +- These customers have complex finances but can't afford enterprise solutions +- They need sophisticated tools but have budget constraints +- They're actively seeking better financial management + +**Secondary Targets**: Large HOAs (150+ units) that need AI-powered insights +- These have significant reserve funds that need optimization +- They can afford premium pricing +- They need advanced financial planning capabilities + +**Long-Term Targets**: Small HOAs (<50 units) that want to grow financially +- These need to move beyond spreadsheets +- They represent the largest addressable market +- They need simple but powerful financial tools + +--- + +## Go-to-Market Strategy + +### Competitive Differentiation Message + +**Instead of**: "HOA management software with basic financial tools" +**We say**: "AI-powered financial intelligence platform for community associations" + +**Key Differentiators**: +- Reserve fund optimization creating 2-4% additional returns +- AI-powered cash flow forecasting preventing financial crises +- Automated reserve studies reducing costs by 80% +- Strategic financial insights for volunteer treasurers + +### Pricing Strategy Advantages + +1. **Tiered Approach**: Capture different market segments +2. **Value-Based Pricing**: Justified by measurable ROI +3. **Advisory Revenue**: Additional revenue from high-value customers +4. **Partner Integration**: Revenue sharing with banks and financial institutions + +### Success Metrics + +1. **Market Share**: 15-20% of medium HOAs within 3 years +2. **Customer Satisfaction**: NPS > 50 vs. industry average of 20-30 +3. **Revenue Growth**: 3x year-over-year for first 3 years +4. **Market Education**: Establish category leadership in HOA financial intelligence + +--- + +## Conclusion + +The competitive landscape reveals a clear opportunity for a specialized, AI-powered financial intelligence platform for HOAs. While competitors exist, none provide the sophisticated treasury management, investment optimization, or AI-powered forecasting that our platform will deliver. By focusing exclusively on financial intelligence rather than general management, we can deliver 10x better value than existing alternatives and capture significant market share in underserved segments. diff --git a/HOA_Financial_Platform_Part3_MVP_Features.md b/HOA_Financial_Platform_Part3_MVP_Features.md new file mode 100644 index 0000000..e431392 --- /dev/null +++ b/HOA_Financial_Platform_Part3_MVP_Features.md @@ -0,0 +1,194 @@ +# HOA Financial Intelligence Platform +## Part 3: MVP Feature Specification Document + +**Version:** 1.0 | **Date:** February 15, 2026 + +--- + +## Executive Summary + +The MVP includes 7 core feature modules spanning user management, accounting fundamentals, payment processing, reserve tracking, and year-end compliance. + +--- + +## Core Features + +### 1. Authentication & User Management + +| Attribute | Value | +|-----------|-------| +| **Priority** | P0 (Must Have) | +| **Effort** | Large (L) | +| **Dependencies** | None | + +**User Stories:** +- As an HOA president, I want to create and configure my HOA organization +- As a board treasurer, I want to invite board members with appropriate roles +- As a homeowner, I want to view my assessment balance and payment history + +**Acceptance Criteria:** +- Multi-tenant architecture with isolated HOAs +- Role-based access (President, Treasurer, Secretary, Member-at-Large, Manager) +- MFA support via TOTP/SMS +- Should support local account authentication as well as whatever standards based OAUTH would allow for authentication from Google, Microsoft, etc + +--- + +### 2. Chart of Accounts Management + +| Attribute | Value | +|-----------|-------| +| **Priority** | P0 (Must Have) | +| **Effort** | Large (L) | +| **Dependencies** | Authentication (1) | + +**Features:** +- Pre-configured HOA chart of accounts template +- Fund accounting (Operating vs. Reserve) +- 1099-reportable account marking +- Period closing and audit locking +- CSV import for historical data + +**Account Structure:** +- Assets (1000-1999) +- Liabilities (2000-2999) +- Equity (3000-3999) +- Income (4000-4999) +- Expenses (5000-9999) + +--- + +### 3. Dues & Assessment Management + +| Attribute | Value | +|-----------|-------| +| **Priority** | P0 (Must Have) | +| **Effort** | Large (L) | +| **Dependencies** | Chart of Accounts (2) | + +**Features:** +- Automated monthly/annual assessment invoicing +- Special assessment creation and tracking +- Late fee calculation and posting +- Payment plan setup for delinquent accounts +- Bulk invoice generation + +**User Stories:** +- As a treasurer, I want to automatically generate monthly invoices for all 100 homeowners +- As a treasurer, I want to create a one-time special assessment for roof repairs + +--- + +### 4. Payment Processing Integration + +| Attribute | Value | +|-----------|-------| +| **Priority** | P0 (Must Have) | +| **Effort** | Medium (M) | +| **Dependencies** | Assessment Management (3) | + +**Integrations:** +- Stripe Connect (credit/debit card processing) +- Plaid (bank transfer/ACH) +- Auto-pay setup with admin consent. This would be not for resident fee collection, but rather for SaaS app subscription. +- Payment confirmation and receipt emails + +**Fees:** ACH $0.25, Cards 2.9% + $0.30 + +--- + +### 5. Reserve Fund Tracking + +| Attribute | Value | +|-----------|-------| +| **Priority** | P1 (Should Have) | +| **Effort** | Large (L) | +| **Dependencies** | Chart of Accounts (2) | + +**Features:** +- Component-based reserve tracking (roof, pool, HVAC, etc.) +- Depreciation scheduling +- Remaining useful life calculations +- Annual contribution vs. actual funded reporting + +--- + +### 6. Financial Reporting & Dashboards + +| Attribute | Value | +|-----------|-------| +| **Priority** | P0 (Must Have) | +| **Effort** | Medium (M) | +| **Dependencies** | Chart of Accounts (2) | + +**Reports:** +- Balance Sheet (real-time) +- Income Statement (current YTD) +- Cash Flow Statement +- Reserve Status Report +- Budget vs. Actual + +**Dashboards:** +- Executive Summary (for board meetings) +- Treasurer Dashboard (detailed transaction view) +- Budget Performance (SANKEY CHART) + +--- + +### 7. End-of-Year Accounting Package + +| Attribute | Value | +|-----------|-------| +| **Priority** | P1 (Should Have) | +| **Effort** | Medium (M) | +| **Dependencies** | All accounting features | + +**Features:** +- 1099-NEC generation for vendors >$600 +- Audit-ready transaction reports +- Year-end closing with reconciliation checklist +- CPA packet export (PDF + Excel) +- Archived prior-year access + +--- + +## 90-Day Development Roadmap + +### Month 1 (Weeks 1-4) +**Sprint 1:** Authentication, User Management, HOA Onboarding +**Sprint 2:** Chart of Accounts, Basic Transaction Entry + +### Month 2 (Weeks 5-8) +**Sprint 3:** Assessment Management, Invoice Generation +**Sprint 4:** Payment Processing (Stripe/Plaid integration) + +### Month 3 (Weeks 9-12) +**Sprint 5:** Reserve Fund Tracking, Basic Reporting +**Sprint 6:** Year-End Package, Dashboard Polish, Beta Launch + +--- + +## Success Criteria + +| Metric | Target | +|--------|--------| +| **Time to First Invoice** | <15 minutes for new HOA setup | +| **Payment Success Rate** | >99% for online payments | +| **User Adoption** | At least weekly usage in the tenant | +| **Interest Performance** | Increase interest income by >25% within 6 months | +| **Treasurer Satisfaction** | NPS >40 from beta users | +| **Uptime** | 99.9% availability | + +--- + +## Feature Priority Summary + +| Feature | Priority | Effort | Sprint | +|---------|----------|--------|--------| +| Authentication | P0 | L | 1 | +| Chart of Accounts | P0 | L | 1-2 | +| Assessment Management | P0 | L | 3 | +| Payment Processing | P0 | M | 4 | +| Financial Reporting | P0 | M | 5-6 | +| Reserve Tracking | P1 | L | 5 | +| Year-End Package | P1 | M | 6 | diff --git a/HOA_Financial_Platform_Part4_Technical_Architecture.md b/HOA_Financial_Platform_Part4_Technical_Architecture.md new file mode 100644 index 0000000..fc9e3fc --- /dev/null +++ b/HOA_Financial_Platform_Part4_Technical_Architecture.md @@ -0,0 +1,228 @@ +# HOA Financial Intelligence Platform +## Part 4: Technical Architecture Document + +**Version:** 2.0 | **Date:** February 15, 2026 | **Deployment:** Self-Hosted (Pilot) → Cloud (Scale) + +--- + +## Executive Summary + +This document defines a **vendor-agnostic, self-hosted architecture** for the HOA Financial Intelligence Platform pilot phase, with a clear migration path to managed cloud services post-product-market fit. + +**Pilot Philosophy:** Start lean on a single VPS (~$20-40/month), prove the concept with 10-50 HOAs, then migrate to managed services for scale. + +--- + +## Architecture Evolution Strategy + +| Phase | Scale | Infrastructure | Cost/Month | Trigger | +|-------|-------|----------------|------------|---------| +| **Pilot** | 1-50 HOAs | Single VPS + Docker | $20-40 | Launch | +| **Growth** | 50-500 HOAs | Multi-server + Load balancer | $100-300 | 50 paying customers | +| **Scale** | 500+ HOAs | Kubernetes + Managed DB | $500+ | Need for auto-scaling | +| **Enterprise** | 5000+ HOAs | Multi-cloud + Microservices | $2000+ | Regional requirements | + +--- + +## 1. Pilot Infrastructure Stack + +### 1.1 Single VPS Architecture ( +``` +┌─────────────────────────────────────────────────────────────────┐ +│ VPS (4-8 vCPU, 8-16GB RAM) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ NGINX (Reverse Proxy + SSL) │ │ +│ │ Port 80/443 → Portainer/Apps │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────┴─────────────────────────────────┐ │ +│ │ Docker Compose Stack │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ App │ │ App │ │ Redis │ │ Postgres │ │ │ +│ │ │Container │ │Container │ │ Cache │ │ DB │ │ │ +│ │ │ :3000 │ │ :3000 │ │ :6379 │ │ :5432 │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ ↑ ↑ │ │ +│ │ ┌────┴────────────┴──────┐ │ │ +│ │ │ Nginx (Static Assets, Load Balancing) │ │ +│ │ └─────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Local/B2 Storage (Backups, Exports, 1099s) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 Technology Stack (Pilot) + +| Layer | Technology | Version | Purpose | +|-------|-----------|---------|---------| +| **Host OS** | MacOS | 15.7.3 | VPS operating system | +| **Container** | Docker + Compose | Latest | Application packaging | +| **Reverse Proxy** | NGINX | v3.x | SSL, routing, rate limiting | +| **Web Server** | Nginx | Latest | Static assets, load balancing | +| **Backend** | Node.js + NestJS | 20 LTS | API server | +| **Frontend** | React + Vite | 18.x | Web application | +| **Database** | PostgreSQL | 15+ | Primary datastore | +| **Cache/Queue** | Redis | 7.x | Sessions, cache, job queue | +| **Monitoring** | Prometheus + Grafana | Latest | Metrics, dashboards | +| **Logs** | Loki + Grafana | Latest | Log aggregation | +| **Backups** | Restic or B2 CLI | Latest | Encrypted offsite backups | + +### 1.3 Docker Compose Structure + +```yaml +# docker-compose.yml (Pilot) +version: '3.8' + +services: + traefik: + image: traefik:v3.0 + command: + - "--api.dashboard=true" + - "--providers.docker=true" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" + - "--certificatesresolvers.letsencrypt.acme.email=treasurer@hoafinance.com" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./letsencrypt:/letsencrypt + + app: + build: ./app + labels: + - "traefik.enable=true" + - "traefik.http.routers.app.rule=Host(`app.hoafinance.com`)" + - "traefik.http.routers.app.tls.certresolver=letsencrypt" + environment: + - DATABASE_URL=postgresql://user:pass@postgres:5432/hoafinance + - REDIS_URL=redis://redis:6379 + - NODE_ENV=production + depends_on: + - postgres + - redis + deploy: + replicas: 2 # 2 app containers for zero-downtime deploys + + postgres: + image: postgres:15-alpine + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backups:/backups + environment: + - POSTGRES_USER=hoafinance + - POSTGRES_PASSWORD_FILE=/run/secrets/db_password + - POSTGRES_DB=hoafinance + secrets: + - db_password + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + command: redis-server --appendonly yes --maxmemory 256mb + + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus:/etc/prometheus + - prometheus_data:/prometheus + + grafana: + image: grafana/grafana:latest + labels: + - "traefik.enable=true" + - "traefik.http.routers.grafana.rule=Host(`grafana.hoafinance.com`)" + volumes: + - grafana_data:/var/lib/grafana + +volumes: + postgres_data: + redis_data: + prometheus_data: + grafana_data: + +secrets: + db_password: + file: ./secrets/db_password.txt +``` + +--- + +## 2. Data Architecture + +### 2.1 Multi-Tenancy Strategy: Schema-per-Tenant (Pilot) + +For pilot phase, use **schema-per-tenant** within single PostgreSQL instance: + +```sql +-- Main database +hoafinance/ +├── shared/ +│ ├── users (cross-tenant auth, limited data) +│ └── signup_queue (pending registrations) +├── tenant_hoa_001/ +│ ├── accounts +│ ├── transactions +│ ├── reserve_components +│ └── ... +├── tenant_hoa_002/ +│ ├── accounts +│ ├── transactions +│ └── ... +└── analytics/ + └── aggregated_stats (anonymized metrics) +``` + +**Why schema-per-tenant for pilot:** +- Easier backup/restore of single HOA +- Ready migration path to database-per-tenant later +- Cost-effective for small scale + +### 2.2 Migration Path to Cloud Scale + +| Phase | Strategy | Migration | +|-------|----------|-----------| +| **Pilot** | Schema-per-tenant, single PG instance | - | +| **Growth** | Database-per-tenant, same PG server | `pg_dump` + `pg_restore` | +| **Scale** | Database-per-tenant, managed PG (RDS/Cloud SQL) | Logical replication | +| **Enterprise** | Sharded by region, managed K8s | Database migration service | + +--- + +## 3. Security Architecture + +### 3.1 Pilot Security Model + +| Layer | Implementation | Cost | +|-------|---------------|------| +| **Network** | Firewall (ufw), fail2ban, SSH keys only | Free | +| **TLS** | Let's Encrypt (Traefik auto-renew) | Free | +| **Secrets** | Docker secrets, mounted files (not env vars) | Free | +| **Database** | Encrypted connections, row-level security | Free | +| **Backups** | Restic → Backblaze B2 (encrypted) | ~$5/TB | + +### 3.2 Secrets Management + +``` +/secrets/ +├── db_password.txt (chmod 600) +├── stripe_webhook_secret.txt +├── plaid_client_secret.txt +└── jwt_secret.txt +``` + +**Never commit secrets to Git. Use `.gitignore` + Docker secrets mount.** + +### 3.3 Backup Strategy + +| Type | Frequency | Tool | Destination | +|------|-----------|------|---------------| +| **Database** | Every 6 hours | `pg_dump` + Restic | B2 + Local | +| **App Config** | On change | Git | GitHub + B2 | \ No newline at end of file diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..eb830df --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "start:dev"] diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..8c28bba --- /dev/null +++ b/backend/package.json @@ -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": { "^@/(.*)$": "/$1" } + } +} diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 0000000..22f2b73 --- /dev/null +++ b/backend/src/app.controller.ts @@ -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', + }; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..e6125fb --- /dev/null +++ b/backend/src/app.module.ts @@ -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('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('*'); + } +} diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..45c5b85 --- /dev/null +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/backend/src/common/decorators/roles.decorator.ts b/backend/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/backend/src/common/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/common/guards/roles.guard.ts b/backend/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..71a3b98 --- /dev/null +++ b/backend/src/common/guards/roles.guard.ts @@ -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(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles) { + return true; + } + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.includes(user.role); + } +} diff --git a/backend/src/database/database.module.ts b/backend/src/database/database.module.ts new file mode 100644 index 0000000..d0c85e1 --- /dev/null +++ b/backend/src/database/database.module.ts @@ -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 {} diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts new file mode 100644 index 0000000..771f3eb --- /dev/null +++ b/backend/src/database/tenant-schema.service.ts @@ -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 { + 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 { + 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], + ); + } + } +} diff --git a/backend/src/database/tenant.middleware.ts b/backend/src/database/tenant.middleware.ts new file mode 100644 index 0000000..e91ae1a --- /dev/null +++ b/backend/src/database/tenant.middleware.ts @@ -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('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(); + } +} diff --git a/backend/src/database/tenant.service.ts b/backend/src/database/tenant.service.ts new file mode 100644 index 0000000..8014e82 --- /dev/null +++ b/backend/src/database/tenant.service.ts @@ -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 { + 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(); + } + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..3c9f213 --- /dev/null +++ b/backend/src/main.ts @@ -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(); diff --git a/backend/src/modules/accounts/accounts.controller.ts b/backend/src/modules/accounts/accounts.controller.ts new file mode 100644 index 0000000..a4c81d2 --- /dev/null +++ b/backend/src/modules/accounts/accounts.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/accounts/accounts.module.ts b/backend/src/modules/accounts/accounts.module.ts new file mode 100644 index 0000000..bbda7f4 --- /dev/null +++ b/backend/src/modules/accounts/accounts.module.ts @@ -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 {} diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts new file mode 100644 index 0000000..a3852e9 --- /dev/null +++ b/backend/src/modules/accounts/accounts.service.ts @@ -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); + } +} diff --git a/backend/src/modules/accounts/dto/create-account.dto.ts b/backend/src/modules/accounts/dto/create-account.dto.ts new file mode 100644 index 0000000..51ab11d --- /dev/null +++ b/backend/src/modules/accounts/dto/create-account.dto.ts @@ -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; +} diff --git a/backend/src/modules/accounts/dto/update-account.dto.ts b/backend/src/modules/accounts/dto/update-account.dto.ts new file mode 100644 index 0000000..d23cffb --- /dev/null +++ b/backend/src/modules/accounts/dto/update-account.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..6ae3caf --- /dev/null +++ b/backend/src/modules/auth/auth.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..f2bf466 --- /dev/null +++ b/backend/src/modules/auth/auth.module.ts @@ -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('JWT_SECRET'), + signOptions: { expiresIn: '24h' }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy, LocalStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..19ec7b7 --- /dev/null +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + 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 = { + 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, + })), + }; + } +} diff --git a/backend/src/modules/auth/dto/login.dto.ts b/backend/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..5a558f2 --- /dev/null +++ b/backend/src/modules/auth/dto/login.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/dto/register.dto.ts b/backend/src/modules/auth/dto/register.dto.ts new file mode 100644 index 0000000..df54183 --- /dev/null +++ b/backend/src/modules/auth/dto/register.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/dto/switch-org.dto.ts b/backend/src/modules/auth/dto/switch-org.dto.ts new file mode 100644 index 0000000..c514b21 --- /dev/null +++ b/backend/src/modules/auth/dto/switch-org.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/guards/jwt-auth.guard.ts b/backend/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/backend/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..7146e6e --- /dev/null +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -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('JWT_SECRET'), + }); + } + + async validate(payload: any) { + return { + sub: payload.sub, + email: payload.email, + orgId: payload.orgId, + orgSchema: payload.orgSchema, + role: payload.role, + }; + } +} diff --git a/backend/src/modules/auth/strategies/local.strategy.ts b/backend/src/modules/auth/strategies/local.strategy.ts new file mode 100644 index 0000000..19002dc --- /dev/null +++ b/backend/src/modules/auth/strategies/local.strategy.ts @@ -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); + } +} diff --git a/backend/src/modules/budgets/budgets.controller.ts b/backend/src/modules/budgets/budgets.controller.ts new file mode 100644 index 0000000..b023c4b --- /dev/null +++ b/backend/src/modules/budgets/budgets.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/budgets/budgets.module.ts b/backend/src/modules/budgets/budgets.module.ts new file mode 100644 index 0000000..70c6a0d --- /dev/null +++ b/backend/src/modules/budgets/budgets.module.ts @@ -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 {} diff --git a/backend/src/modules/budgets/budgets.service.ts b/backend/src/modules/budgets/budgets.service.ts new file mode 100644 index 0000000..65b26ee --- /dev/null +++ b/backend/src/modules/budgets/budgets.service.ts @@ -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), + }; + } +} diff --git a/backend/src/modules/budgets/dto/upsert-budget.dto.ts b/backend/src/modules/budgets/dto/upsert-budget.dto.ts new file mode 100644 index 0000000..375707d --- /dev/null +++ b/backend/src/modules/budgets/dto/upsert-budget.dto.ts @@ -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; +} diff --git a/backend/src/modules/capital-projects/capital-projects.controller.ts b/backend/src/modules/capital-projects/capital-projects.controller.ts new file mode 100644 index 0000000..4f2b864 --- /dev/null +++ b/backend/src/modules/capital-projects/capital-projects.controller.ts @@ -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); } +} diff --git a/backend/src/modules/capital-projects/capital-projects.module.ts b/backend/src/modules/capital-projects/capital-projects.module.ts new file mode 100644 index 0000000..56d52dd --- /dev/null +++ b/backend/src/modules/capital-projects/capital-projects.module.ts @@ -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 {} diff --git a/backend/src/modules/capital-projects/capital-projects.service.ts b/backend/src/modules/capital-projects/capital-projects.service.ts new file mode 100644 index 0000000..bb9477e --- /dev/null +++ b/backend/src/modules/capital-projects/capital-projects.service.ts @@ -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]; + } +} diff --git a/backend/src/modules/fiscal-periods/fiscal-periods.controller.ts b/backend/src/modules/fiscal-periods/fiscal-periods.controller.ts new file mode 100644 index 0000000..6b0f828 --- /dev/null +++ b/backend/src/modules/fiscal-periods/fiscal-periods.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/fiscal-periods/fiscal-periods.module.ts b/backend/src/modules/fiscal-periods/fiscal-periods.module.ts new file mode 100644 index 0000000..4e20484 --- /dev/null +++ b/backend/src/modules/fiscal-periods/fiscal-periods.module.ts @@ -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 {} diff --git a/backend/src/modules/fiscal-periods/fiscal-periods.service.ts b/backend/src/modules/fiscal-periods/fiscal-periods.service.ts new file mode 100644 index 0000000..f27bf1b --- /dev/null +++ b/backend/src/modules/fiscal-periods/fiscal-periods.service.ts @@ -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]; + } +} diff --git a/backend/src/modules/investments/investments.controller.ts b/backend/src/modules/investments/investments.controller.ts new file mode 100644 index 0000000..ac00c3e --- /dev/null +++ b/backend/src/modules/investments/investments.controller.ts @@ -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); } +} diff --git a/backend/src/modules/investments/investments.module.ts b/backend/src/modules/investments/investments.module.ts new file mode 100644 index 0000000..1f695b4 --- /dev/null +++ b/backend/src/modules/investments/investments.module.ts @@ -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 {} diff --git a/backend/src/modules/investments/investments.service.ts b/backend/src/modules/investments/investments.service.ts new file mode 100644 index 0000000..66381ee --- /dev/null +++ b/backend/src/modules/investments/investments.service.ts @@ -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]; + } +} diff --git a/backend/src/modules/invoices/invoices.controller.ts b/backend/src/modules/invoices/invoices.controller.ts new file mode 100644 index 0000000..672dc6f --- /dev/null +++ b/backend/src/modules/invoices/invoices.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/invoices/invoices.module.ts b/backend/src/modules/invoices/invoices.module.ts new file mode 100644 index 0000000..058d12f --- /dev/null +++ b/backend/src/modules/invoices/invoices.module.ts @@ -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 {} diff --git a/backend/src/modules/invoices/invoices.service.ts b/backend/src/modules/invoices/invoices.service.ts new file mode 100644 index 0000000..def3b97 --- /dev/null +++ b/backend/src/modules/invoices/invoices.service.ts @@ -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 }; + } +} diff --git a/backend/src/modules/journal-entries/dto/create-journal-entry.dto.ts b/backend/src/modules/journal-entries/dto/create-journal-entry.dto.ts new file mode 100644 index 0000000..6a6aa7c --- /dev/null +++ b/backend/src/modules/journal-entries/dto/create-journal-entry.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/journal-entries/dto/void-journal-entry.dto.ts b/backend/src/modules/journal-entries/dto/void-journal-entry.dto.ts new file mode 100644 index 0000000..f67b718 --- /dev/null +++ b/backend/src/modules/journal-entries/dto/void-journal-entry.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class VoidJournalEntryDto { + @ApiProperty({ example: 'Duplicate entry' }) + @IsString() + reason: string; +} diff --git a/backend/src/modules/journal-entries/journal-entries.controller.ts b/backend/src/modules/journal-entries/journal-entries.controller.ts new file mode 100644 index 0000000..c007a6f --- /dev/null +++ b/backend/src/modules/journal-entries/journal-entries.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/journal-entries/journal-entries.module.ts b/backend/src/modules/journal-entries/journal-entries.module.ts new file mode 100644 index 0000000..739838a --- /dev/null +++ b/backend/src/modules/journal-entries/journal-entries.module.ts @@ -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 {} diff --git a/backend/src/modules/journal-entries/journal-entries.service.ts b/backend/src/modules/journal-entries/journal-entries.service.ts new file mode 100644 index 0000000..a206adb --- /dev/null +++ b/backend/src/modules/journal-entries/journal-entries.service.ts @@ -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); + } +} diff --git a/backend/src/modules/organizations/dto/create-organization.dto.ts b/backend/src/modules/organizations/dto/create-organization.dto.ts new file mode 100644 index 0000000..4756bdb --- /dev/null +++ b/backend/src/modules/organizations/dto/create-organization.dto.ts @@ -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; +} diff --git a/backend/src/modules/organizations/entities/organization.entity.ts b/backend/src/modules/organizations/entities/organization.entity.ts new file mode 100644 index 0000000..c56ea05 --- /dev/null +++ b/backend/src/modules/organizations/entities/organization.entity.ts @@ -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; + + @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[]; +} diff --git a/backend/src/modules/organizations/entities/user-organization.entity.ts b/backend/src/modules/organizations/entities/user-organization.entity.ts new file mode 100644 index 0000000..7737cb9 --- /dev/null +++ b/backend/src/modules/organizations/entities/user-organization.entity.ts @@ -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; +} diff --git a/backend/src/modules/organizations/organizations.controller.ts b/backend/src/modules/organizations/organizations.controller.ts new file mode 100644 index 0000000..16e5f68 --- /dev/null +++ b/backend/src/modules/organizations/organizations.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/organizations/organizations.module.ts b/backend/src/modules/organizations/organizations.module.ts new file mode 100644 index 0000000..602629e --- /dev/null +++ b/backend/src/modules/organizations/organizations.module.ts @@ -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 {} diff --git a/backend/src/modules/organizations/organizations.service.ts b/backend/src/modules/organizations/organizations.service.ts new file mode 100644 index 0000000..7a0465e --- /dev/null +++ b/backend/src/modules/organizations/organizations.service.ts @@ -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, + @InjectRepository(UserOrganization) + private userOrgRepository: Repository, + 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}`; + } +} diff --git a/backend/src/modules/payments/payments.controller.ts b/backend/src/modules/payments/payments.controller.ts new file mode 100644 index 0000000..9f8b9ed --- /dev/null +++ b/backend/src/modules/payments/payments.controller.ts @@ -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); } +} diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..9d889fd --- /dev/null +++ b/backend/src/modules/payments/payments.module.ts @@ -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 {} diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts new file mode 100644 index 0000000..fafe670 --- /dev/null +++ b/backend/src/modules/payments/payments.service.ts @@ -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]; + } +} diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts new file mode 100644 index 0000000..c54f05d --- /dev/null +++ b/backend/src/modules/reports/reports.controller.ts @@ -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(); + } +} diff --git a/backend/src/modules/reports/reports.module.ts b/backend/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..00595b5 --- /dev/null +++ b/backend/src/modules/reports/reports.module.ts @@ -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 {} diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts new file mode 100644 index 0000000..da8d2e5 --- /dev/null +++ b/backend/src/modules/reports/reports.service.ts @@ -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, + }; + } +} diff --git a/backend/src/modules/reserve-components/reserve-components.controller.ts b/backend/src/modules/reserve-components/reserve-components.controller.ts new file mode 100644 index 0000000..d138b62 --- /dev/null +++ b/backend/src/modules/reserve-components/reserve-components.controller.ts @@ -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); } +} diff --git a/backend/src/modules/reserve-components/reserve-components.module.ts b/backend/src/modules/reserve-components/reserve-components.module.ts new file mode 100644 index 0000000..01576e8 --- /dev/null +++ b/backend/src/modules/reserve-components/reserve-components.module.ts @@ -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 {} diff --git a/backend/src/modules/reserve-components/reserve-components.service.ts b/backend/src/modules/reserve-components/reserve-components.service.ts new file mode 100644 index 0000000..1a149b0 --- /dev/null +++ b/backend/src/modules/reserve-components/reserve-components.service.ts @@ -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]; + } +} diff --git a/backend/src/modules/units/units.controller.ts b/backend/src/modules/units/units.controller.ts new file mode 100644 index 0000000..c759194 --- /dev/null +++ b/backend/src/modules/units/units.controller.ts @@ -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); } +} diff --git a/backend/src/modules/units/units.module.ts b/backend/src/modules/units/units.module.ts new file mode 100644 index 0000000..2c9905c --- /dev/null +++ b/backend/src/modules/units/units.module.ts @@ -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 {} diff --git a/backend/src/modules/units/units.service.ts b/backend/src/modules/units/units.service.ts new file mode 100644 index 0000000..5e46294 --- /dev/null +++ b/backend/src/modules/units/units.service.ts @@ -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]; + } +} diff --git a/backend/src/modules/users/dto/create-user.dto.ts b/backend/src/modules/users/dto/create-user.dto.ts new file mode 100644 index 0000000..908f078 --- /dev/null +++ b/backend/src/modules/users/dto/create-user.dto.ts @@ -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; +} diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts new file mode 100644 index 0000000..975fd9a --- /dev/null +++ b/backend/src/modules/users/entities/user.entity.ts @@ -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[]; +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts new file mode 100644 index 0000000..f8cc930 --- /dev/null +++ b/backend/src/modules/users/users.module.ts @@ -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 {} diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts new file mode 100644 index 0000000..9ee97a1 --- /dev/null +++ b/backend/src/modules/users/users.service.ts @@ -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, + ) {} + + async findByEmail(email: string): Promise { + return this.usersRepository.findOne({ + where: { email: email.toLowerCase() }, + }); + } + + async findById(id: string): Promise { + return this.usersRepository.findOne({ where: { id } }); + } + + async findByIdWithOrgs(id: string): Promise { + return this.usersRepository.findOne({ + where: { id }, + relations: ['userOrganizations', 'userOrganizations.organization'], + }); + } + + async create(data: Partial): Promise { + const user = this.usersRepository.create({ + ...data, + email: data.email?.toLowerCase(), + }); + return this.usersRepository.save(user); + } + + async updateLastLogin(id: string): Promise { + await this.usersRepository.update(id, { lastLoginAt: new Date() }); + } +} diff --git a/backend/src/modules/vendors/vendors.controller.ts b/backend/src/modules/vendors/vendors.controller.ts new file mode 100644 index 0000000..42fd1dd --- /dev/null +++ b/backend/src/modules/vendors/vendors.controller.ts @@ -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); } +} diff --git a/backend/src/modules/vendors/vendors.module.ts b/backend/src/modules/vendors/vendors.module.ts new file mode 100644 index 0000000..53d1bea --- /dev/null +++ b/backend/src/modules/vendors/vendors.module.ts @@ -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 {} diff --git a/backend/src/modules/vendors/vendors.service.ts b/backend/src/modules/vendors/vendors.service.ts new file mode 100644 index 0000000..84ff8da --- /dev/null +++ b/backend/src/modules/vendors/vendors.service.ts @@ -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]); + } +} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..bd3c394 --- /dev/null +++ b/backend/tsconfig.json @@ -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/*"] + } + } +} diff --git a/db/init/00-init.sql b/db/init/00-init.sql new file mode 100644 index 0000000..72c23d4 --- /dev/null +++ b/db/init/00-init.sql @@ -0,0 +1,79 @@ +-- HOA Financial Platform - Database Initialization +-- Creates shared schema and base tables for multi-tenant architecture + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Shared schema for cross-tenant data +CREATE SCHEMA IF NOT EXISTS shared; + +-- Organizations (HOAs) +CREATE TABLE shared.organizations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + schema_name VARCHAR(63) NOT NULL UNIQUE, + subdomain VARCHAR(63) UNIQUE, + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'trial')), + settings JSONB DEFAULT '{}', + address_line1 VARCHAR(255), + address_line2 VARCHAR(255), + city VARCHAR(100), + state VARCHAR(2), + zip_code VARCHAR(10), + phone VARCHAR(20), + email VARCHAR(255), + tax_id VARCHAR(20), + fiscal_year_start_month INTEGER DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Users (global, cross-tenant) +CREATE TABLE shared.users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255), + first_name VARCHAR(100), + last_name VARCHAR(100), + phone VARCHAR(20), + is_email_verified BOOLEAN DEFAULT FALSE, + mfa_enabled BOOLEAN DEFAULT FALSE, + mfa_secret VARCHAR(255), + oauth_provider VARCHAR(50), + oauth_provider_id VARCHAR(255), + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- User-Organization memberships with roles +CREATE TABLE shared.user_organizations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner')), + is_active BOOLEAN DEFAULT TRUE, + joined_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, organization_id) +); + +-- Invitations +CREATE TABLE shared.invitations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, + invited_by UUID NOT NULL REFERENCES shared.users(id), + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id); +CREATE INDEX idx_user_orgs_org ON shared.user_organizations(organization_id); +CREATE INDEX idx_users_email ON shared.users(email); +CREATE INDEX idx_orgs_schema ON shared.organizations(schema_name); +CREATE INDEX idx_invitations_token ON shared.invitations(token); +CREATE INDEX idx_invitations_email ON shared.invitations(email); diff --git a/db/seed/seed.sql b/db/seed/seed.sql new file mode 100644 index 0000000..3b9e90c --- /dev/null +++ b/db/seed/seed.sql @@ -0,0 +1,784 @@ +-- ============================================================ +-- HOA Financial Platform - Comprehensive Seed Data +-- "Sunrise Valley HOA" - 50 units, full year of financial data +-- ============================================================ +-- This script: +-- 1. Creates a test user + organization +-- 2. Provisions the tenant schema +-- 3. Seeds 50 units with homeowner data +-- 4. Creates 12 months of assessment invoices +-- 5. Creates payments (some units are delinquent) +-- 6. Creates vendor records and expense transactions +-- 7. Creates budgets for current year +-- 8. Creates reserve components, investments, capital projects +-- ============================================================ + +-- Enable UUID generation +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ============================================================ +-- 1. Create test user and organization +-- ============================================================ +DO $$ +DECLARE + v_user_id UUID; + v_org_id UUID; + v_schema TEXT := 'tenant_sunrise_valley'; + v_year INT := EXTRACT(YEAR FROM CURRENT_DATE)::INT; + v_month INT; + v_unit_id UUID; + v_invoice_id UUID; + v_je_id UUID; + v_fp_id UUID; + v_ar_id UUID; + v_cash_id UUID; + v_income_id UUID; + v_expense_account_id UUID; + v_unit_rec RECORD; + v_vendor_rec RECORD; + v_acct_rec RECORD; + v_inv_num TEXT; + v_amount NUMERIC; + v_inv_date DATE; + v_due_date DATE; + v_pay_date DATE; + v_pay_amount NUMERIC; +BEGIN + +-- Check if user exists +SELECT id INTO v_user_id FROM shared.users WHERE email = 'admin@sunrisevalley.org'; +IF v_user_id IS NULL THEN + INSERT INTO shared.users (id, email, password_hash, first_name, last_name) + VALUES ( + uuid_generate_v4(), + 'admin@sunrisevalley.org', + -- bcrypt hash of 'password123' + '$2b$10$1mtM00QBNQpAsyopajk3BeFY5DdxksvRYuM1E8qB.ePjCIYkfHMHO', + 'Sarah', + 'Johnson' + ) RETURNING id INTO v_user_id; +END IF; + +-- Check if org exists +SELECT id INTO v_org_id FROM shared.organizations WHERE schema_name = v_schema; +IF v_org_id IS NULL THEN + INSERT INTO shared.organizations (id, name, subdomain, address_line1, city, state, zip_code, schema_name) + VALUES ( + uuid_generate_v4(), + 'Sunrise Valley HOA', + 'sunrise-valley', + '100 Sunrise Valley Drive', + 'Scottsdale', + 'AZ', + '85255', + v_schema + ) RETURNING id INTO v_org_id; + + INSERT INTO shared.user_organizations (user_id, organization_id, role) + VALUES (v_user_id, v_org_id, 'president'); +END IF; + +-- ============================================================ +-- 2. Create tenant schema (if not exists) +-- ============================================================ +EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', v_schema); + +-- Create tables in tenant schema +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.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, + fund_type VARCHAR(20) NOT NULL, + parent_account_id UUID, + 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) +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.fiscal_periods ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + year INTEGER NOT NULL, + month INTEGER NOT NULL, + status VARCHAR(20) DEFAULT ''open'', + closed_by UUID, + closed_at TIMESTAMPTZ, + locked_by UUID, + locked_at TIMESTAMPTZ, + UNIQUE(year, month) +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.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, + fiscal_period_id UUID NOT NULL, + 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() +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.journal_entry_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + journal_entry_id UUID NOT NULL, + account_id UUID NOT NULL, + debit DECIMAL(15,2) DEFAULT 0.00, + credit DECIMAL(15,2) DEFAULT 0.00, + memo TEXT +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.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'', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.invoices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + invoice_number VARCHAR(50) NOT NULL UNIQUE, + unit_id UUID NOT NULL, + invoice_date DATE NOT NULL, + due_date DATE NOT NULL, + invoice_type VARCHAR(50) NOT NULL, + description TEXT, + amount DECIMAL(10,2) NOT NULL, + amount_paid DECIMAL(10,2) DEFAULT 0.00, + status VARCHAR(20) DEFAULT ''draft'', + journal_entry_id UUID, + sent_at TIMESTAMPTZ, + paid_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.payments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + unit_id UUID NOT NULL, + invoice_id UUID, + payment_date DATE NOT NULL, + amount DECIMAL(10,2) NOT NULL, + payment_method VARCHAR(50), + reference_number VARCHAR(100), + stripe_payment_id VARCHAR(255), + status VARCHAR(20) DEFAULT ''completed'', + journal_entry_id UUID, + received_by UUID, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.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, + is_active BOOLEAN DEFAULT TRUE, + ytd_payments DECIMAL(15,2) DEFAULT 0.00, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.budgets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + fiscal_year INTEGER NOT NULL, + account_id UUID NOT NULL, + fund_type VARCHAR(20) NOT NULL, + 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) +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.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, + account_id UUID, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.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), + fund_type VARCHAR(20) NOT NULL, + 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, + is_active BOOLEAN DEFAULT TRUE, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +)', v_schema); + +EXECUTE format(' +CREATE TABLE IF NOT EXISTS %I.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, + status VARCHAR(20) DEFAULT ''planned'', + reserve_component_id UUID, + fund_source VARCHAR(20), + priority INTEGER DEFAULT 3, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +)', v_schema); + +-- ============================================================ +-- 3. Seed Chart of Accounts +-- ============================================================ +-- Clear existing data if re-running +EXECUTE format('DELETE FROM %I.journal_entry_lines', v_schema); +EXECUTE format('DELETE FROM %I.journal_entries', v_schema); +EXECUTE format('DELETE FROM %I.payments', v_schema); +EXECUTE format('DELETE FROM %I.invoices', v_schema); +EXECUTE format('DELETE FROM %I.budgets', v_schema); +EXECUTE format('DELETE FROM %I.capital_projects', v_schema); +EXECUTE format('DELETE FROM %I.investment_accounts', v_schema); +EXECUTE format('DELETE FROM %I.reserve_components', v_schema); +EXECUTE format('DELETE FROM %I.vendors', v_schema); +EXECUTE format('DELETE FROM %I.units', v_schema); +EXECUTE format('DELETE FROM %I.fiscal_periods', v_schema); +EXECUTE format('DELETE FROM %I.accounts', v_schema); + +-- Insert chart of accounts +EXECUTE format('INSERT INTO %I.accounts (account_number, name, account_type, fund_type, is_1099_reportable, is_system) VALUES + (1000, ''Operating Cash - Checking'', ''asset'', ''operating'', false, true), + (1010, ''Operating Cash - Savings'', ''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), + (1200, ''Accounts Receivable - Assessments'', ''asset'', ''operating'', false, true), + (1210, ''Accounts Receivable - Late Fees'', ''asset'', ''operating'', false, true), + (1300, ''Prepaid Insurance'', ''asset'', ''operating'', false, true), + (2000, ''Accounts Payable'', ''liability'', ''operating'', false, true), + (2100, ''Accrued Expenses'', ''liability'', ''operating'', false, true), + (2200, ''Prepaid Assessments'', ''liability'', ''operating'', false, true), + (3000, ''Operating Fund Balance'', ''equity'', ''operating'', false, true), + (3100, ''Reserve Fund Balance'', ''equity'', ''reserve'', false, true), + (3200, ''Retained Earnings'', ''equity'', ''operating'', false, true), + (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), + (4500, ''Other Income'', ''income'', ''operating'', false, false), + (4600, ''Reserve Contributions'', ''income'', ''reserve'', false, true), + (5000, ''Management Fees'', ''expense'', ''operating'', true, true), + (5100, ''Insurance - Property'', ''expense'', ''operating'', false, true), + (5110, ''Insurance - D&O'', ''expense'', ''operating'', false, true), + (5200, ''Utilities - Water/Sewer'', ''expense'', ''operating'', false, true), + (5210, ''Utilities - Electric (Common)'', ''expense'', ''operating'', false, true), + (5230, ''Utilities - Trash/Recycling'', ''expense'', ''operating'', false, true), + (5300, ''Landscape Maintenance'', ''expense'', ''operating'', true, 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), + (5920, ''Bank Fees'', ''expense'', ''operating'', false, true), + (6000, ''Repairs & Maintenance'', ''expense'', ''operating'', false, true), + (6500, ''Contingency/Miscellaneous'', ''expense'', ''operating'', false, true), + (7000, ''Reserve - Roof Replacement'', ''expense'', ''reserve'', false, true), + (7100, ''Reserve - Paving/Asphalt'', ''expense'', ''reserve'', false, true), + (7200, ''Reserve - Pool Renovation'', ''expense'', ''reserve'', false, true), + (7400, ''Reserve - Painting/Exterior'', ''expense'', ''reserve'', false, true), + (7500, ''Reserve - Fencing'', ''expense'', ''reserve'', false, true) +', v_schema); + +-- ============================================================ +-- 4. Create fiscal periods (current year + previous year) +-- ============================================================ +FOR v_month IN 1..12 LOOP + EXECUTE format('INSERT INTO %I.fiscal_periods (year, month, status) VALUES ($1, $2, $3)', v_schema) + USING v_year, v_month, CASE WHEN v_month <= EXTRACT(MONTH FROM CURRENT_DATE)::INT THEN 'open' ELSE 'open' END; + EXECUTE format('INSERT INTO %I.fiscal_periods (year, month, status) VALUES ($1, $2, $3)', v_schema) + USING v_year - 1, v_month, 'closed'; +END LOOP; + +-- ============================================================ +-- 5. Seed 50 units +-- ============================================================ +DECLARE + v_first_names TEXT[] := ARRAY['James','Mary','Robert','Patricia','John','Jennifer','Michael','Linda', + 'David','Elizabeth','William','Barbara','Richard','Susan','Joseph','Jessica','Thomas','Sarah', + 'Christopher','Karen','Charles','Lisa','Daniel','Nancy','Matthew','Betty','Anthony','Margaret', + 'Mark','Sandra','Donald','Ashley','Steven','Dorothy','Paul','Kimberly','Andrew','Emily', + 'Joshua','Donna','Kenneth','Michelle','Kevin','Carol','Brian','Amanda','George','Melissa', + 'Timothy','Deborah']; + v_last_names TEXT[] := ARRAY['Smith','Johnson','Williams','Brown','Jones','Garcia','Miller','Davis', + 'Rodriguez','Martinez','Hernandez','Lopez','Gonzalez','Wilson','Anderson','Thomas','Taylor', + 'Moore','Jackson','Martin','Lee','Perez','Thompson','White','Harris','Sanchez','Clark', + 'Ramirez','Lewis','Robinson','Walker','Young','Allen','King','Wright','Scott','Torres', + 'Nguyen','Hill','Flores','Green','Adams','Nelson','Baker','Hall','Rivera','Campbell', + 'Mitchell','Carter','Roberts']; + v_unit_num INT; + v_assess NUMERIC; +BEGIN + FOR v_unit_num IN 1..50 LOOP + -- Vary assessment based on unit size + v_assess := CASE + WHEN v_unit_num <= 20 THEN 350.00 -- standard + WHEN v_unit_num <= 35 THEN 425.00 -- medium + ELSE 500.00 -- large + END; + + EXECUTE format('INSERT INTO %I.units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, square_footage) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)', v_schema) + USING + LPAD(v_unit_num::TEXT, 3, '0'), + (100 + v_unit_num * 2)::TEXT || ' Sunrise Valley Drive', + 'Scottsdale', 'AZ', '85255', + v_first_names[v_unit_num] || ' ' || v_last_names[v_unit_num], + LOWER(v_first_names[v_unit_num]) || '.' || LOWER(v_last_names[v_unit_num]) || '@email.com', + '(480) 555-' || LPAD((1000 + v_unit_num)::TEXT, 4, '0'), + v_assess, + CASE WHEN v_unit_num <= 20 THEN 1200 WHEN v_unit_num <= 35 THEN 1600 ELSE 2000 END; + END LOOP; +END; + +-- ============================================================ +-- 6. Get account IDs for transactions +-- ============================================================ +EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 1200', v_schema) INTO v_ar_id; +EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 1000', v_schema) INTO v_cash_id; +EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 4000', v_schema) INTO v_income_id; + +-- ============================================================ +-- 7. Create 12 months of invoices + payments +-- ============================================================ +FOR v_month IN 1..12 LOOP + -- Get fiscal period + EXECUTE format('SELECT id FROM %I.fiscal_periods WHERE year = $1 AND month = $2', v_schema) + INTO v_fp_id USING v_year, v_month; + + v_inv_date := make_date(v_year, v_month, 1); + v_due_date := make_date(v_year, v_month, 15); + + -- Create invoice and payment for each unit + FOR v_unit_rec IN EXECUTE format('SELECT id, unit_number, monthly_assessment FROM %I.units ORDER BY unit_number', v_schema) LOOP + v_inv_num := 'INV-' || v_year || LPAD(v_month::TEXT, 2, '0') || '-' || v_unit_rec.unit_number; + v_amount := v_unit_rec.monthly_assessment; + + -- Skip future months + IF v_inv_date > CURRENT_DATE THEN + CONTINUE; + END IF; + + -- Create invoice + EXECUTE format('INSERT INTO %I.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', v_schema) + INTO v_invoice_id + USING v_inv_num, v_unit_rec.id, v_inv_date, v_due_date, + 'Monthly Assessment - ' || TO_CHAR(v_inv_date, 'Month YYYY'), v_amount; + + -- Create assessment journal entry (DR AR, CR Income) + EXECUTE format('INSERT INTO %I.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', v_schema) + INTO v_je_id + USING v_inv_date, 'Assessment - Unit ' || v_unit_rec.unit_number, v_fp_id, v_invoice_id, v_user_id; + + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0)', v_schema) + USING v_je_id, v_ar_id, v_amount; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, $3)', v_schema) + USING v_je_id, v_income_id, v_amount; + + EXECUTE format('UPDATE %I.invoices SET journal_entry_id = $1 WHERE id = $2', v_schema) USING v_je_id, v_invoice_id; + + -- Create payment (most units pay, some are delinquent) + -- Units 45-50 are delinquent for the last 3 months + -- Units 40-44 are late payers (paid partial) + IF v_unit_rec.unit_number::INT >= 45 AND v_month >= (EXTRACT(MONTH FROM CURRENT_DATE)::INT - 2) THEN + -- Delinquent - mark overdue, no payment + EXECUTE format('UPDATE %I.invoices SET status = ''overdue'' WHERE id = $1', v_schema) USING v_invoice_id; + ELSIF v_unit_rec.unit_number::INT >= 40 AND v_month >= (EXTRACT(MONTH FROM CURRENT_DATE)::INT - 1) THEN + -- Partial payment + v_pay_amount := v_amount * 0.5; + v_pay_date := v_due_date + INTERVAL '5 days'; + IF v_pay_date <= CURRENT_DATE THEN + EXECUTE format('INSERT INTO %I.payments (unit_id, invoice_id, payment_date, amount, payment_method, reference_number, received_by) + VALUES ($1, $2, $3, $4, ''check'', $5, $6)', v_schema) + USING v_unit_rec.id, v_invoice_id, v_pay_date, v_pay_amount, 'CHK-' || v_unit_rec.unit_number || '-' || v_month, v_user_id; + + EXECUTE format('UPDATE %I.invoices SET amount_paid = $1, status = ''partial'' WHERE id = $2', v_schema) + USING v_pay_amount, v_invoice_id; + + -- Payment JE + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, is_posted, posted_at, created_by) + VALUES ($1, $2, ''payment'', $3, ''payment'', true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING v_pay_date, 'Payment - Unit ' || v_unit_rec.unit_number, v_fp_id, v_user_id; + + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0)', v_schema) + USING v_je_id, v_cash_id, v_pay_amount; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, $3)', v_schema) + USING v_je_id, v_ar_id, v_pay_amount; + END IF; + ELSE + -- Full payment + v_pay_date := v_due_date - INTERVAL '3 days' + (random() * 10)::INT * INTERVAL '1 day'; + IF v_pay_date > CURRENT_DATE THEN + v_pay_date := CURRENT_DATE; + END IF; + + EXECUTE format('INSERT INTO %I.payments (unit_id, invoice_id, payment_date, amount, payment_method, reference_number, received_by) + VALUES ($1, $2, $3, $4, $5, $6, $7)', v_schema) + USING v_unit_rec.id, v_invoice_id, v_pay_date, v_amount, + CASE WHEN random() < 0.6 THEN 'ach' WHEN random() < 0.8 THEN 'check' ELSE 'credit_card' END, + 'PAY-' || v_unit_rec.unit_number || '-' || v_month, v_user_id; + + EXECUTE format('UPDATE %I.invoices SET amount_paid = $1, status = ''paid'', paid_at = $2 WHERE id = $3', v_schema) + USING v_amount, v_pay_date, v_invoice_id; + + -- Payment JE + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, is_posted, posted_at, created_by) + VALUES ($1, $2, ''payment'', $3, ''payment'', true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING v_pay_date, 'Payment - Unit ' || v_unit_rec.unit_number, v_fp_id, v_user_id; + + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0)', v_schema) + USING v_je_id, v_cash_id, v_amount; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, $3)', v_schema) + USING v_je_id, v_ar_id, v_amount; + END IF; + + END LOOP; -- units +END LOOP; -- months + +-- ============================================================ +-- 8. Create vendors and expense transactions +-- ============================================================ +EXECUTE format('INSERT INTO %I.vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible) VALUES + (''Desert Star Management'', ''Mike Peters'', ''mike@desertstar.com'', ''(480) 555-2000'', ''500 N Scottsdale Rd'', ''Scottsdale'', ''AZ'', ''85251'', ''82-1234567'', true), + (''Green Oasis Landscaping'', ''Rosa Gutierrez'', ''rosa@greenoasis.com'', ''(480) 555-2100'', ''1200 E Camelback'', ''Phoenix'', ''AZ'', ''85014'', ''82-2345678'', true), + (''AquaBlue Pool Service'', ''Tom Bradley'', ''tom@aquablue.com'', ''(480) 555-2200'', ''300 W Indian School'', ''Phoenix'', ''AZ'', ''85013'', ''82-3456789'', true), + (''Valley Pest Solutions'', ''Dan Kim'', ''dan@valleypest.com'', ''(480) 555-2300'', ''800 S Mill Ave'', ''Tempe'', ''AZ'', ''85281'', ''82-4567890'', true), + (''Southwest Legal Group'', ''Lisa Chen'', ''lisa@swlegal.com'', ''(480) 555-2400'', ''2 N Central Ave'', ''Phoenix'', ''AZ'', ''85004'', ''82-5678901'', true), + (''Saguaro CPA Group'', ''Jim Torres'', ''jim@saguarocpa.com'', ''(480) 555-2500'', ''4000 N Scottsdale Rd'', ''Scottsdale'', ''AZ'', ''85251'', ''82-6789012'', true), + (''CleanRight Janitorial'', ''Pat Morgan'', ''pat@cleanright.com'', ''(480) 555-2600'', ''1500 N Hayden'', ''Scottsdale'', ''AZ'', ''85257'', ''82-7890123'', true), + (''Arizona Insurance Group'', ''Amy Russell'', ''amy@azinsurance.com'', ''(480) 555-2700'', ''7000 E Shea Blvd'', ''Scottsdale'', ''AZ'', ''85254'', NULL, false) +', v_schema); + +-- Link vendor default accounts +EXECUTE format('UPDATE %I.vendors SET default_account_id = (SELECT id FROM %I.accounts WHERE account_number = 5000) WHERE name = ''Desert Star Management''', v_schema, v_schema); +EXECUTE format('UPDATE %I.vendors SET default_account_id = (SELECT id FROM %I.accounts WHERE account_number = 5300) WHERE name = ''Green Oasis Landscaping''', v_schema, v_schema); +EXECUTE format('UPDATE %I.vendors SET default_account_id = (SELECT id FROM %I.accounts WHERE account_number = 5400) WHERE name = ''AquaBlue Pool Service''', v_schema, v_schema); +EXECUTE format('UPDATE %I.vendors SET default_account_id = (SELECT id FROM %I.accounts WHERE account_number = 5600) WHERE name = ''Valley Pest Solutions''', v_schema, v_schema); +EXECUTE format('UPDATE %I.vendors SET default_account_id = (SELECT id FROM %I.accounts WHERE account_number = 5700) WHERE name = ''Southwest Legal Group''', v_schema, v_schema); +EXECUTE format('UPDATE %I.vendors SET default_account_id = (SELECT id FROM %I.accounts WHERE account_number = 5800) WHERE name = ''Saguaro CPA Group''', v_schema, v_schema); +EXECUTE format('UPDATE %I.vendors SET default_account_id = (SELECT id FROM %I.accounts WHERE account_number = 5510) WHERE name = ''CleanRight Janitorial''', v_schema, v_schema); +EXECUTE format('UPDATE %I.vendors SET default_account_id = (SELECT id FROM %I.accounts WHERE account_number = 5100) WHERE name = ''Arizona Insurance Group''', v_schema, v_schema); + +-- Create monthly expense transactions +FOR v_month IN 1..LEAST(12, EXTRACT(MONTH FROM CURRENT_DATE)::INT) LOOP + EXECUTE format('SELECT id FROM %I.fiscal_periods WHERE year = $1 AND month = $2', v_schema) INTO v_fp_id USING v_year, v_month; + + -- Management Fee: $2,500/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5000', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 5), 'Management Fee - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 2500, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 2500)', v_schema) USING v_je_id, v_cash_id; + + -- Landscape: $3,200/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5300', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 10), 'Landscape Maintenance - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 3200, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 3200)', v_schema) USING v_je_id, v_cash_id; + + -- Pool: $800/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5400', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 10), 'Pool Maintenance - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 800, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 800)', v_schema) USING v_je_id, v_cash_id; + + -- Utilities Water: $1,200/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5200', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 15), 'Water/Sewer - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 1200, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 1200)', v_schema) USING v_je_id, v_cash_id; + + -- Electric: $650/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5210', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 18), 'Electric Common Areas - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 650, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 650)', v_schema) USING v_je_id, v_cash_id; + + -- Trash: $450/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5230', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 20), 'Trash/Recycling - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 450, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 450)', v_schema) USING v_je_id, v_cash_id; + + -- Pest Control: $200/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5600', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 25), 'Pest Control - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 200, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 200)', v_schema) USING v_je_id, v_cash_id; + + -- Janitorial: $600/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5510', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 28), 'Janitorial Service - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 600, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 600)', v_schema) USING v_je_id, v_cash_id; + + -- Reserve contribution: $2,750/mo + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 1100', v_schema) INTO v_expense_account_id; + DECLARE v_reserve_income_id UUID; + BEGIN + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 4600', v_schema) INTO v_reserve_income_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''transfer'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, v_month, 1), 'Reserve Contribution - ' || TO_CHAR(make_date(v_year, v_month, 1), 'Mon YYYY'), v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 2750, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 2750)', v_schema) USING v_je_id, v_reserve_income_id; + END; + +END LOOP; + +-- Quarterly expenses +-- Insurance (annual paid quarterly): $4,500/quarter +FOR v_month IN 1..4 LOOP + EXIT WHEN (v_month - 1) * 3 + 1 > EXTRACT(MONTH FROM CURRENT_DATE)::INT; + EXECUTE format('SELECT id FROM %I.fiscal_periods WHERE year = $1 AND month = $2', v_schema) + INTO v_fp_id USING v_year, (v_month - 1) * 3 + 1; + EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5100', v_schema) INTO v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) + INTO v_je_id + USING make_date(v_year, (v_month - 1) * 3 + 1, 1), 'Property Insurance - Q' || v_month, v_fp_id, v_user_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 4500, 0)', v_schema) USING v_je_id, v_expense_account_id; + EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 4500)', v_schema) USING v_je_id, v_cash_id; +END LOOP; + +-- Annual Audit: $3,500 (January) +EXECUTE format('SELECT id FROM %I.fiscal_periods WHERE year = $1 AND month = 1', v_schema) INTO v_fp_id USING v_year; +EXECUTE format('SELECT id FROM %I.accounts WHERE account_number = 5800', v_schema) INTO v_expense_account_id; +EXECUTE format('INSERT INTO %I.journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) + VALUES ($1, $2, ''manual'', $3, true, NOW(), $4) RETURNING id', v_schema) +INTO v_je_id +USING make_date(v_year, 1, 15), 'Annual Audit Fee', v_fp_id, v_user_id; +EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 3500, 0)', v_schema) USING v_je_id, v_expense_account_id; +EXECUTE format('INSERT INTO %I.journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, 0, 3500)', v_schema) USING v_je_id, v_cash_id; + +-- ============================================================ +-- 9. Budgets for current year +-- ============================================================ +-- Income budgets +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 17500, 17500, 17500, 17500, 17500, 17500, 17500, 17500, 17500, 17500, 17500, 17500 + FROM %I.accounts WHERE account_number = 4000', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200 + FROM %I.accounts WHERE account_number = 4100', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''reserve'', 2750, 2750, 2750, 2750, 2750, 2750, 2750, 2750, 2750, 2750, 2750, 2750 + FROM %I.accounts WHERE account_number = 4600', v_schema, v_schema) +USING v_year; + +-- Expense budgets +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500 + FROM %I.accounts WHERE account_number = 5000', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 4500, 0, 0, 4500, 0, 0, 4500, 0, 0, 4500, 0, 0 + FROM %I.accounts WHERE account_number = 5100', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200, 1200 + FROM %I.accounts WHERE account_number = 5200', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 650, 650, 650, 650, 650, 650, 650, 650, 650, 650, 650, 650 + FROM %I.accounts WHERE account_number = 5210', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200, 3200 + FROM %I.accounts WHERE account_number = 5300', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800 + FROM %I.accounts WHERE account_number = 5400', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600 + FROM %I.accounts WHERE account_number = 5510', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200 + FROM %I.accounts WHERE account_number = 5600', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 450, 450, 450, 450, 450, 450, 450, 450, 450, 450, 450, 450 + FROM %I.accounts WHERE account_number = 5230', v_schema, v_schema) +USING v_year; + +EXECUTE format('INSERT INTO %I.budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) + SELECT $1, id, ''operating'', 3500, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + FROM %I.accounts WHERE account_number = 5800', v_schema, v_schema) +USING v_year; + +-- ============================================================ +-- 10. Reserve components +-- ============================================================ +EXECUTE format('INSERT INTO %I.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 + (''Roof - Building A'', ''Roofing'', ''Tile roof on main building A'', 25, 18.0, 125000, 35000, 5000, 7, ''2016-06-01'', ''2041-06-01''), + (''Roof - Building B'', ''Roofing'', ''Tile roof on building B'', 25, 20.0, 95000, 20000, 3800, 8, ''2018-03-01'', ''2043-03-01''), + (''Parking Lot Resurfacing'', ''Paving'', ''Asphalt parking areas'', 15, 8.0, 85000, 47000, 5667, 6, ''2019-09-01'', ''2034-09-01''), + (''Pool Replaster'', ''Pool'', ''Replaster community pool'', 10, 4.0, 35000, 21000, 3500, 5, ''2020-04-01'', ''2030-04-01''), + (''Exterior Paint'', ''Painting'', ''Full exterior paint all buildings'', 8, 3.0, 65000, 48750, 8125, 4, ''2021-10-01'', ''2029-10-01''), + (''Perimeter Fencing'', ''Fencing'', ''Wrought iron perimeter fence'', 20, 14.0, 45000, 13500, 2250, 7, ''2018-01-01'', ''2038-01-01''), + (''Clubhouse HVAC'', ''HVAC'', ''Central HVAC system for clubhouse'', 15, 9.0, 28000, 11200, 1867, 6, ''2020-08-01'', ''2035-08-01''), + (''Irrigation System'', ''Landscape'', ''Landscape irrigation infrastructure'', 12, 6.0, 22000, 11000, 1833, 5, ''2020-01-01'', ''2032-01-01'') +', v_schema); + +-- ============================================================ +-- 11. Investment accounts +-- ============================================================ +EXECUTE format('INSERT INTO %I.investment_accounts (name, institution, account_number_last4, investment_type, fund_type, principal, interest_rate, maturity_date, purchase_date, current_value) VALUES + (''Reserve CD - 12 Month'', ''Chase Bank'', ''4521'', ''cd'', ''reserve'', 50000, 5.2500, ''2027-01-15'', ''2026-01-15'', 51312.50), + (''Reserve CD - 6 Month'', ''Wells Fargo'', ''7834'', ''cd'', ''reserve'', 25000, 4.7500, ''2026-07-15'', ''2026-01-15'', 25593.75), + (''Reserve Money Market'', ''Schwab'', ''9912'', ''money_market'', ''reserve'', 75000, 4.5000, NULL, ''2025-06-01'', 77625.00), + (''Operating Savings'', ''Chase Bank'', ''3345'', ''savings'', ''operating'', 15000, 3.0000, NULL, ''2025-01-01'', 15450.00), + (''Treasury Bills - 3 Mo'', ''Fidelity'', ''6678'', ''treasury'', ''reserve'', 30000, 5.0000, ''2026-05-15'', ''2026-02-15'', 30375.00) +', v_schema); + +-- ============================================================ +-- 12. Capital projects (5-year plan) +-- ============================================================ +EXECUTE format('INSERT INTO %I.capital_projects (name, description, estimated_cost, target_year, target_month, status, fund_source, priority) VALUES + (''Pool Heater Replacement'', ''Replace aging pool heater system'', 8500, $1, 4, ''approved'', ''reserve'', 2), + (''Landscape Renovation - Phase 1'', ''Update entry and common area landscaping'', 15000, $1, 5, ''in_progress'', ''reserve'', 2), + (''Security Camera Upgrade'', ''Replace analog cameras with HD IP system'', 12000, $1, 8, ''planned'', ''operating'', 3), + (''Parking Lot Seal Coat'', ''Seal coat and re-stripe parking areas'', 18000, $1 + 1, 3, ''planned'', ''reserve'', 2), + (''Clubhouse Furniture'', ''Replace common area furniture'', 8000, $1 + 1, 6, ''planned'', ''operating'', 4), + (''Exterior Paint - Building A'', ''Full exterior repaint of Building A'', 35000, $1 + 1, 9, ''planned'', ''reserve'', 1), + (''Playground Equipment'', ''New playground equipment and surfacing'', 22000, $1 + 2, 4, ''planned'', ''reserve'', 3), + (''Roof Repair - Building B'', ''Patch and repair sections of Building B roof'', 15000, $1 + 2, 7, ''planned'', ''reserve'', 2), + (''Irrigation System Overhaul'', ''Replace aging irrigation controllers and valves'', 12000, $1 + 3, 3, ''planned'', ''reserve'', 3), + (''Pool Replastering'', ''Complete pool replaster and tile work'', 35000, $1 + 3, 9, ''planned'', ''reserve'', 1), + (''Parking Lot Resurface'', ''Full asphalt resurfacing of parking areas'', 85000, $1 + 4, 5, ''planned'', ''reserve'', 1), + (''Perimeter Fence Repair'', ''Replace damaged fence sections and repaint'', 8000, $1 + 4, 8, ''planned'', ''reserve'', 4) +', v_schema) USING v_year; + +RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!'; +RAISE NOTICE 'Login: admin@sunrisevalley.org / password123'; + +END $$; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1287af6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,95 @@ +services: + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + networks: + - hoanet + + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + ports: + - "3000:3000" + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - JWT_SECRET=${JWT_SECRET} + - NODE_ENV=${NODE_ENV} + volumes: + - ./backend/src:/app/src + - ./backend/nest-cli.json:/app/nest-cli.json + - ./backend/tsconfig.json:/app/tsconfig.json + - ./backend/tsconfig.build.json:/app/tsconfig.build.json + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - hoanet + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + ports: + - "5173:5173" + environment: + - NODE_ENV=${NODE_ENV} + volumes: + - ./frontend/src:/app/src + - ./frontend/index.html:/app/index.html + - ./frontend/vite.config.ts:/app/vite.config.ts + depends_on: + - backend + networks: + - hoanet + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - hoanet + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - hoanet + +volumes: + postgres_data: + redis_data: + +networks: + hoanet: + driver: bridge diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..546a9de --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7b58446 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + HOA Financial Platform + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d369d37 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "hoa-financial-platform-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx" + }, + "dependencies": { + "@mantine/core": "^7.15.3", + "@mantine/hooks": "^7.15.3", + "@mantine/form": "^7.15.3", + "@mantine/dates": "^7.15.3", + "@mantine/notifications": "^7.15.3", + "@mantine/modals": "^7.15.3", + "@tabler/icons-react": "^3.28.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.2", + "recharts": "^2.15.0", + "d3-sankey": "^0.12.3", + "zustand": "^4.5.5", + "axios": "^1.7.9", + "@tanstack/react-query": "^5.64.2", + "dayjs": "^1.11.13" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/d3-sankey": "^0.12.4", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^5.4.14", + "postcss": "^8.4.49", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1" + } +} diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..bfba0dd --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + 'postcss-preset-mantine': {}, + 'postcss-simple-vars': { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..0a486be --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,104 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from './stores/authStore'; +import { AppLayout } from './components/layout/AppLayout'; +import { LoginPage } from './pages/auth/LoginPage'; +import { RegisterPage } from './pages/auth/RegisterPage'; +import { SelectOrgPage } from './pages/auth/SelectOrgPage'; +import { DashboardPage } from './pages/dashboard/DashboardPage'; +import { AccountsPage } from './pages/accounts/AccountsPage'; +import { TransactionsPage } from './pages/transactions/TransactionsPage'; +import { BudgetsPage } from './pages/budgets/BudgetsPage'; +import { UnitsPage } from './pages/units/UnitsPage'; +import { InvoicesPage } from './pages/invoices/InvoicesPage'; +import { PaymentsPage } from './pages/payments/PaymentsPage'; +import { VendorsPage } from './pages/vendors/VendorsPage'; +import { ReservesPage } from './pages/reserves/ReservesPage'; +import { InvestmentsPage } from './pages/investments/InvestmentsPage'; +import { CapitalProjectsPage } from './pages/capital-projects/CapitalProjectsPage'; +import { BalanceSheetPage } from './pages/reports/BalanceSheetPage'; +import { IncomeStatementPage } from './pages/reports/IncomeStatementPage'; +import { BudgetVsActualPage } from './pages/reports/BudgetVsActualPage'; +import { SankeyPage } from './pages/reports/SankeyPage'; +import { PlaceholderPage } from './pages/PlaceholderPage'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.token); + if (!token) return ; + return <>{children}; +} + +function OrgRequiredRoute({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.token); + const currentOrg = useAuthStore((s) => s.currentOrg); + if (!token) return ; + if (!currentOrg) return ; + return <>{children}; +} + +function AuthRoute({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.token); + const currentOrg = useAuthStore((s) => s.currentOrg); + if (token && currentOrg) return ; + if (token && !currentOrg) return ; + return <>{children}; +} + +export function App() { + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..57d309e --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { AppShell, Burger, Group, Title, Text, Menu, UnstyledButton, Avatar } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { + IconLogout, + IconSwitchHorizontal, + IconChevronDown, +} from '@tabler/icons-react'; +import { Outlet, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; +import { Sidebar } from './Sidebar'; + +export function AppLayout() { + const [opened, { toggle }] = useDisclosure(); + const { user, currentOrg, logout } = useAuthStore(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( + + + + + + HOA Financial Platform + + + {currentOrg && ( + {currentOrg.name} + )} + + + + + + {user?.firstName?.[0]}{user?.lastName?.[0]} + + {user?.firstName} {user?.lastName} + + + + + + } + onClick={() => navigate('/select-org')} + > + Switch Organization + + + } + onClick={handleLogout} + > + Logout + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..0b3335c --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,86 @@ +import { NavLink, ScrollArea } from '@mantine/core'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { + IconDashboard, + IconListDetails, + IconReceipt, + IconHome, + IconFileInvoice, + IconCash, + IconReportAnalytics, + IconChartSankey, + IconShieldCheck, + IconPigMoney, + IconBuildingBank, + IconCalendarEvent, + IconUsers, + IconFileText, + IconSettings, +} from '@tabler/icons-react'; + +const navItems = [ + { label: 'Dashboard', icon: IconDashboard, path: '/dashboard' }, + { label: 'Chart of Accounts', icon: IconListDetails, path: '/accounts' }, + { label: 'Transactions', icon: IconReceipt, path: '/transactions' }, + { label: 'Units / Homeowners', icon: IconHome, path: '/units' }, + { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, + { label: 'Payments', icon: IconCash, path: '/payments' }, + { label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' }, + { + label: 'Reports', + icon: IconChartSankey, + children: [ + { label: 'Balance Sheet', path: '/reports/balance-sheet' }, + { label: 'Income Statement', path: '/reports/income-statement' }, + { label: 'Cash Flow', path: '/reports/cash-flow' }, + { label: 'Budget vs Actual', path: '/reports/budget-vs-actual' }, + { label: 'Aging Report', path: '/reports/aging' }, + { label: 'Sankey Diagram', path: '/reports/sankey' }, + ], + }, + { label: 'Reserves', icon: IconShieldCheck, path: '/reserves' }, + { label: 'Investments', icon: IconPigMoney, path: '/investments' }, + { label: 'Capital Projects', icon: IconBuildingBank, path: '/capital-projects' }, + { label: 'Vendors', icon: IconUsers, path: '/vendors' }, + { label: 'Year-End', icon: IconFileText, path: '/year-end' }, + { label: 'Settings', icon: IconSettings, path: '/settings' }, +]; + +export function Sidebar() { + const navigate = useNavigate(); + const location = useLocation(); + + return ( + + {navItems.map((item) => + item.children ? ( + } + defaultOpened={item.children.some((c) => + location.pathname.startsWith(c.path), + )} + > + {item.children.map((child) => ( + navigate(child.path)} + /> + ))} + + ) : ( + } + active={location.pathname === item.path} + onClick={() => navigate(item.path!)} + /> + ), + )} + + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..d50ec5a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { MantineProvider } from '@mantine/core'; +import { Notifications } from '@mantine/notifications'; +import { ModalsProvider } from '@mantine/modals'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; +import '@mantine/core/styles.css'; +import '@mantine/dates/styles.css'; +import '@mantine/notifications/styles.css'; +import { App } from './App'; +import { theme } from './theme/theme'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + , +); diff --git a/frontend/src/pages/PlaceholderPage.tsx b/frontend/src/pages/PlaceholderPage.tsx new file mode 100644 index 0000000..094d284 --- /dev/null +++ b/frontend/src/pages/PlaceholderPage.tsx @@ -0,0 +1,13 @@ +import { Title, Text, Card, Stack } from '@mantine/core'; + +export function PlaceholderPage({ title }: { title: string }) { + return ( + + {title} + + Coming soon + This feature is under development. + + + ); +} diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx new file mode 100644 index 0000000..19b373d --- /dev/null +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react'; +import { + Title, + Table, + Badge, + Group, + Button, + TextInput, + Select, + Modal, + Stack, + NumberInput, + Switch, + Text, + Card, + ActionIcon, + Tabs, + Loader, + Center, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useDisclosure } from '@mantine/hooks'; +import { notifications } from '@mantine/notifications'; +import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface Account { + id: string; + account_number: number; + name: string; + description: string; + account_type: string; + fund_type: string; + is_1099_reportable: boolean; + is_active: boolean; + is_system: boolean; + balance: string; +} + +const accountTypeColors: Record = { + asset: 'green', + liability: 'red', + equity: 'violet', + income: 'blue', + expense: 'orange', +}; + +export function AccountsPage() { + const [opened, { open, close }] = useDisclosure(false); + const [editing, setEditing] = useState(null); + const [search, setSearch] = useState(''); + const [filterType, setFilterType] = useState(null); + const [filterFund, setFilterFund] = useState(null); + const queryClient = useQueryClient(); + + const { data: accounts = [], isLoading } = useQuery({ + queryKey: ['accounts'], + queryFn: async () => { + const { data } = await api.get('/accounts'); + return data; + }, + }); + + const form = useForm({ + initialValues: { + account_number: 0, + name: '', + description: '', + account_type: 'expense', + fund_type: 'operating', + is_1099_reportable: false, + }, + validate: { + account_number: (v) => (v > 0 ? null : 'Required'), + name: (v) => (v.length > 0 ? null : 'Required'), + }, + }); + + const createMutation = useMutation({ + mutationFn: (values: any) => + editing + ? api.put(`/accounts/${editing.id}`, values) + : api.post('/accounts', values), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + notifications.show({ message: editing ? 'Account updated' : 'Account created', color: 'green' }); + close(); + setEditing(null); + form.reset(); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); + }, + }); + + const handleEdit = (account: Account) => { + setEditing(account); + form.setValues({ + account_number: account.account_number, + name: account.name, + description: account.description || '', + account_type: account.account_type, + fund_type: account.fund_type, + is_1099_reportable: account.is_1099_reportable, + }); + open(); + }; + + const handleNew = () => { + setEditing(null); + form.reset(); + open(); + }; + + const filtered = accounts.filter((a) => { + if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false; + if (filterType && a.account_type !== filterType) return false; + if (filterFund && a.fund_type !== filterFund) return false; + return true; + }); + + const totalsByType = accounts.reduce((acc, a) => { + acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0'); + return acc; + }, {} as Record); + + if (isLoading) { + return
; + } + + return ( + + + Chart of Accounts + + + + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + style={{ flex: 1 }} + /> + + + + + + All ({accounts.length}) + Operating + Reserve + + + + + + + a.fund_type === 'operating')} onEdit={handleEdit} /> + + + a.fund_type === 'reserve')} onEdit={handleEdit} /> + + + + +
createMutation.mutate(values))}> + + + + + + + + +
+
+
+ ); +} + +function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: Account) => void }) { + const fmt = (v: string) => { + const n = parseFloat(v || '0'); + return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + }; + + return ( + + + + Acct # + Name + Type + Fund + Balance + 1099 + + + + + {accounts.map((a) => ( + + {a.account_number} + {a.name} + + + {a.account_type} + + + + + {a.fund_type} + + + {fmt(a.balance)} + {a.is_1099_reportable ? '1099' : ''} + + {!a.is_system && ( + onEdit(a)}> + + + )} + + + ))} + +
+ ); +} diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..0512a45 --- /dev/null +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { + Container, + Paper, + Title, + Text, + TextInput, + PasswordInput, + Button, + Anchor, + Stack, + Alert, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconAlertCircle } from '@tabler/icons-react'; +import { useNavigate, Link } from 'react-router-dom'; +import api from '../../services/api'; +import { useAuthStore } from '../../stores/authStore'; + +export function LoginPage() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + const setAuth = useAuthStore((s) => s.setAuth); + + const form = useForm({ + initialValues: { email: '', password: '' }, + validate: { + email: (v) => (/^\S+@\S+$/.test(v) ? null : 'Invalid email'), + password: (v) => (v.length >= 1 ? null : 'Password required'), + }, + }); + + const handleSubmit = async (values: typeof form.values) => { + setLoading(true); + setError(''); + try { + const { data } = await api.post('/auth/login', values); + setAuth(data.accessToken, data.user, data.organizations); + // Always go through org selection to ensure correct JWT with orgSchema + if (data.organizations.length >= 1) { + navigate('/select-org'); + } else { + navigate('/'); + } + } catch (err: any) { + setError(err.response?.data?.message || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( + + + HOA Financial Platform + + + Don't have an account?{' '} + + Register + + + + +
+ + {error && ( + } color="red" variant="light"> + {error} + + )} + + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx new file mode 100644 index 0000000..ac4b622 --- /dev/null +++ b/frontend/src/pages/auth/RegisterPage.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { + Container, + Paper, + Title, + Text, + TextInput, + PasswordInput, + Button, + Anchor, + Stack, + Alert, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconAlertCircle } from '@tabler/icons-react'; +import { useNavigate, Link } from 'react-router-dom'; +import api from '../../services/api'; +import { useAuthStore } from '../../stores/authStore'; + +export function RegisterPage() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const navigate = useNavigate(); + const setAuth = useAuthStore((s) => s.setAuth); + + const form = useForm({ + initialValues: { email: '', password: '', firstName: '', lastName: '' }, + validate: { + email: (v) => (/^\S+@\S+$/.test(v) ? null : 'Invalid email'), + password: (v) => (v.length >= 8 ? null : 'Min 8 characters'), + firstName: (v) => (v.length >= 1 ? null : 'Required'), + lastName: (v) => (v.length >= 1 ? null : 'Required'), + }, + }); + + const handleSubmit = async (values: typeof form.values) => { + setLoading(true); + setError(''); + try { + const { data } = await api.post('/auth/register', values); + setAuth(data.accessToken, data.user, data.organizations); + navigate('/'); + } catch (err: any) { + setError(err.response?.data?.message || 'Registration failed'); + } finally { + setLoading(false); + } + }; + + return ( + + + Create Account + + + Already have an account?{' '} + + Sign in + + + + +
+ + {error && ( + } color="red" variant="light"> + {error} + + )} + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/auth/SelectOrgPage.tsx b/frontend/src/pages/auth/SelectOrgPage.tsx new file mode 100644 index 0000000..4cacd76 --- /dev/null +++ b/frontend/src/pages/auth/SelectOrgPage.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { + Container, + Paper, + Title, + Text, + Stack, + Button, + Card, + Group, + Badge, + TextInput, + Modal, + Alert, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useDisclosure } from '@mantine/hooks'; +import { IconBuilding, IconPlus, IconAlertCircle } from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; +import api from '../../services/api'; +import { useAuthStore } from '../../stores/authStore'; + +export function SelectOrgPage() { + const { organizations, setCurrentOrg, logout } = useAuthStore(); + const navigate = useNavigate(); + const [opened, { open, close }] = useDisclosure(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + // If no organizations in store (stale session), redirect to login + if (!organizations || organizations.length === 0) { + return ( + + No Organizations + + Please log in again to refresh your session. + + + + ); + } + + const form = useForm({ + initialValues: { name: '', addressLine1: '', city: '', state: '', zipCode: '' }, + validate: { + name: (v) => (v.length >= 2 ? null : 'Name required'), + }, + }); + + const handleSelect = async (org: any) => { + try { + const { data } = await api.post('/auth/switch-org', { + organizationId: org.id, + }); + setCurrentOrg(data.organization, data.accessToken); + navigate('/dashboard'); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to switch organization. Please try logging in again.'); + } + }; + + const handleCreateOrg = async (values: typeof form.values) => { + setLoading(true); + setError(''); + try { + const { data } = await api.post('/organizations', values); + // Switch to the new org + const switchRes = await api.post('/auth/switch-org', { + organizationId: data.id, + }); + setCurrentOrg( + { id: data.id, name: data.name, role: 'president' }, + switchRes.data.accessToken, + ); + close(); + navigate('/'); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to create organization'); + } finally { + setLoading(false); + } + }; + + return ( + + Select Organization + + Choose an HOA to manage or create a new one + + + + {organizations.map((org) => ( + handleSelect(org)} + > + + + +
+ {org.name} + + {org.role} + {org.schemaName && ( + + {org.schemaName} + + )} + +
+
+ +
+
+ ))} + + +
+ + +
+ + {error && ( + } color="red" variant="light"> + {error} + + )} + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/budgets/BudgetsPage.tsx b/frontend/src/pages/budgets/BudgetsPage.tsx new file mode 100644 index 0000000..ab1ba2c --- /dev/null +++ b/frontend/src/pages/budgets/BudgetsPage.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { + Title, Table, Group, Button, Stack, Text, NumberInput, + Select, Loader, Center, Badge, Card, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconDeviceFloppy } from '@tabler/icons-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface BudgetLine { + account_id: string; + account_number: number; + account_name: string; + account_type: string; + fund_type: string; + jan: number; feb: number; mar: number; apr: number; + may: number; jun: number; jul: number; aug: number; + sep: number; oct: number; nov: number; dec_amt: number; + annual_total: number; +} + +const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt']; +const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +export function BudgetsPage() { + const [year, setYear] = useState(new Date().getFullYear().toString()); + const [budgetData, setBudgetData] = useState([]); + const queryClient = useQueryClient(); + + const { isLoading } = useQuery({ + queryKey: ['budgets', year], + queryFn: async () => { + const { data } = await api.get(`/budgets/${year}`); + setBudgetData(data); + return data; + }, + }); + + const saveMutation = useMutation({ + mutationFn: async () => { + const lines = budgetData + .filter((b) => months.some((m) => (b as any)[m] > 0)) + .map((b) => ({ + account_id: b.account_id, + fund_type: b.fund_type, + jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr, + may: b.may, jun: b.jun, jul: b.jul, aug: b.aug, + sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt, + })); + return api.put(`/budgets/${year}`, { lines }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budgets', year] }); + notifications.show({ message: 'Budget saved', color: 'green' }); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Save failed', color: 'red' }); + }, + }); + + const updateCell = (idx: number, month: string, value: number) => { + const updated = [...budgetData]; + (updated[idx] as any)[month] = value || 0; + updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0); + setBudgetData(updated); + }; + + const yearOptions = Array.from({ length: 5 }, (_, i) => { + const y = new Date().getFullYear() - 1 + i; + return { value: String(y), label: String(y) }; + }); + + const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 }); + + if (isLoading) return
; + + const incomeLines = budgetData.filter((b) => b.account_type === 'income'); + const expenseLines = budgetData.filter((b) => b.account_type === 'expense'); + const totalIncome = months.reduce((s, m) => s + incomeLines.reduce((a, b) => a + ((b as any)[m] || 0), 0), 0); + const totalExpense = months.reduce((s, m) => s + expenseLines.reduce((a, b) => a + ((b as any)[m] || 0), 0), 0); + + return ( + + + Budget Manager + +