Initial commit: HOA Financial Intelligence Platform MVP

Multi-tenant financial management platform for homeowner associations featuring:
- NestJS backend with 16 modules (auth, accounts, transactions, budgets, units,
  invoices, payments, vendors, reserves, investments, capital projects, reports)
- React + Mantine frontend with dashboard, CRUD pages, and financial reports
- Schema-per-tenant PostgreSQL isolation with JWT-based tenant resolution
- Docker Compose infrastructure (nginx, backend, frontend, postgres, redis)
- Comprehensive seed data for Sunrise Valley HOA demo
- 39 API endpoints with Swagger documentation
- Double-entry bookkeeping with journal entries
- Budget vs actual reporting and Sankey cash flow visualization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 19:58:04 -05:00
commit 243770cea5
118 changed files with 8569 additions and 0 deletions

7
.env.example Normal file
View File

@@ -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

41
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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 |

12
backend/Dockerfile.dev Normal file
View File

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

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

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

68
backend/package.json Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,46 @@
import {
Controller, Get, Post, Put, Body, Param, Query, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AccountsService } from './accounts.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
@ApiTags('accounts')
@Controller('accounts')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class AccountsController {
constructor(private accountsService: AccountsService) {}
@Get()
@ApiOperation({ summary: 'List all accounts' })
findAll(@Query('fundType') fundType?: string) {
return this.accountsService.findAll(fundType);
}
@Get('trial-balance')
@ApiOperation({ summary: 'Get trial balance' })
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
return this.accountsService.getTrialBalance(asOfDate);
}
@Get(':id')
@ApiOperation({ summary: 'Get account by ID' })
findOne(@Param('id') id: string) {
return this.accountsService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a new account' })
create(@Body() dto: CreateAccountDto) {
return this.accountsService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update an account' })
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
return this.accountsService.update(id, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AccountsController } from './accounts.controller';
import { AccountsService } from './accounts.service';
@Module({
controllers: [AccountsController],
providers: [AccountsService],
exports: [AccountsService],
})
export class AccountsModule {}

View File

@@ -0,0 +1,107 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
@Injectable()
export class AccountsService {
constructor(private tenant: TenantService) {}
async findAll(fundType?: string) {
let sql = 'SELECT * FROM accounts WHERE is_active = true';
const params: any[] = [];
if (fundType) {
sql += ' AND fund_type = $1';
params.push(fundType);
}
sql += ' ORDER BY account_number';
return this.tenant.query(sql, params);
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM accounts WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Account not found');
return rows[0];
}
async create(dto: CreateAccountDto) {
const existing = await this.tenant.query(
'SELECT id FROM accounts WHERE account_number = $1',
[dto.accountNumber],
);
if (existing.length) {
throw new BadRequestException(`Account number ${dto.accountNumber} already exists`);
}
const rows = await this.tenant.query(
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
dto.accountNumber,
dto.name,
dto.description || null,
dto.accountType,
dto.fundType,
dto.parentAccountId || null,
dto.is1099Reportable || false,
],
);
return rows[0];
}
async update(id: string, dto: UpdateAccountDto) {
const account = await this.findOne(id);
if (account.is_system && dto.accountType && dto.accountType !== account.account_type) {
throw new BadRequestException('Cannot change type of system account');
}
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
if (!sets.length) return account;
sets.push(`updated_at = NOW()`);
params.push(id);
const rows = await this.tenant.query(
`UPDATE accounts SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
return rows[0];
}
async getTrialBalance(asOfDate?: string) {
const dateFilter = asOfDate
? `AND je.entry_date <= $1`
: '';
const params = asOfDate ? [asOfDate] : [];
const sql = `
SELECT
a.id, a.account_number, a.name, a.account_type, a.fund_type,
COALESCE(SUM(jel.debit), 0) as total_debits,
COALESCE(SUM(jel.credit), 0) as total_credits,
CASE
WHEN a.account_type IN ('asset', 'expense')
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
${dateFilter}
WHERE a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
ORDER BY a.account_number
`;
return this.tenant.query(sql, params);
}
}

View File

@@ -0,0 +1,35 @@
import { IsString, IsInt, IsOptional, IsBoolean, IsIn, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateAccountDto {
@ApiProperty({ example: 6600 })
@IsInt()
accountNumber: number;
@ApiProperty({ example: 'Equipment Repairs' })
@IsString()
name: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ example: 'expense', enum: ['asset', 'liability', 'equity', 'income', 'expense'] })
@IsIn(['asset', 'liability', 'equity', 'income', 'expense'])
accountType: string;
@ApiProperty({ example: 'operating', enum: ['operating', 'reserve'] })
@IsIn(['operating', 'reserve'])
fundType: string;
@ApiProperty({ required: false })
@IsUUID()
@IsOptional()
parentAccountId?: string;
@ApiProperty({ required: false, default: false })
@IsBoolean()
@IsOptional()
is1099Reportable?: boolean;
}

View File

@@ -0,0 +1,29 @@
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateAccountDto {
@ApiProperty({ required: false })
@IsString()
@IsOptional()
name?: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ required: false })
@IsIn(['asset', 'liability', 'equity', 'income', 'expense'])
@IsOptional()
accountType?: string;
@ApiProperty({ required: false })
@IsBoolean()
@IsOptional()
is1099Reportable?: boolean;
@ApiProperty({ required: false })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
import {
IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsUUID, IsIn, IsDateString,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class JournalEntryLineDto {
@ApiProperty()
@IsUUID()
accountId: string;
@ApiProperty({ example: 350.00 })
@IsNumber()
@IsOptional()
debit?: number;
@ApiProperty({ example: 0 })
@IsNumber()
@IsOptional()
credit?: number;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
memo?: string;
}
export class CreateJournalEntryDto {
@ApiProperty({ example: '2026-02-15' })
@IsDateString()
entryDate: string;
@ApiProperty({ example: 'Monthly landscaping payment' })
@IsString()
description: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
referenceNumber?: string;
@ApiProperty({ example: 'manual', required: false })
@IsIn(['manual', 'assessment', 'payment', 'late_fee', 'transfer', 'adjustment', 'closing', 'opening_balance'])
@IsOptional()
entryType?: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
sourceType?: string;
@ApiProperty({ required: false })
@IsUUID()
@IsOptional()
sourceId?: string;
@ApiProperty({ type: [JournalEntryLineDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => JournalEntryLineDto)
lines: JournalEntryLineDto[];
}

View File

@@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class VoidJournalEntryDto {
@ApiProperty({ example: 'Duplicate entry' })
@IsString()
reason: string;
}

View File

@@ -0,0 +1,51 @@
import {
Controller, Get, Post, Body, Param, Query, UseGuards, Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JournalEntriesService } from './journal-entries.service';
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
@ApiTags('journal-entries')
@Controller('journal-entries')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class JournalEntriesController {
constructor(private jeService: JournalEntriesService) {}
@Get()
@ApiOperation({ summary: 'List journal entries' })
findAll(
@Query('from') from?: string,
@Query('to') to?: string,
@Query('accountId') accountId?: string,
@Query('type') type?: string,
) {
return this.jeService.findAll({ from, to, accountId, type });
}
@Get(':id')
@ApiOperation({ summary: 'Get journal entry by ID' })
findOne(@Param('id') id: string) {
return this.jeService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a journal entry' })
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
return this.jeService.create(dto, req.user.sub);
}
@Post(':id/post')
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
post(@Param('id') id: string, @Request() req: any) {
return this.jeService.post(id, req.user.sub);
}
@Post(':id/void')
@ApiOperation({ summary: 'Void a journal entry' })
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
return this.jeService.void(id, req.user.sub, dto.reason);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { JournalEntriesController } from './journal-entries.controller';
import { JournalEntriesService } from './journal-entries.service';
import { FiscalPeriodsModule } from '../fiscal-periods/fiscal-periods.module';
@Module({
imports: [FiscalPeriodsModule],
controllers: [JournalEntriesController],
providers: [JournalEntriesService],
exports: [JournalEntriesService],
})
export class JournalEntriesModule {}

View File

@@ -0,0 +1,191 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
import { FiscalPeriodsService } from '../fiscal-periods/fiscal-periods.service';
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
@Injectable()
export class JournalEntriesService {
constructor(
private tenant: TenantService,
private fiscalPeriodsService: FiscalPeriodsService,
) {}
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
let sql = `
SELECT je.*,
json_agg(json_build_object(
'id', jel.id, 'account_id', jel.account_id,
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
'account_name', a.name, 'account_number', a.account_number
)) as lines
FROM journal_entries je
LEFT JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id
LEFT JOIN accounts a ON a.id = jel.account_id
WHERE 1=1
`;
const params: any[] = [];
let idx = 1;
if (filters.from) {
sql += ` AND je.entry_date >= $${idx++}`;
params.push(filters.from);
}
if (filters.to) {
sql += ` AND je.entry_date <= $${idx++}`;
params.push(filters.to);
}
if (filters.accountId) {
sql += ` AND je.id IN (SELECT journal_entry_id FROM journal_entry_lines WHERE account_id = $${idx++})`;
params.push(filters.accountId);
}
if (filters.type) {
sql += ` AND je.entry_type = $${idx++}`;
params.push(filters.type);
}
sql += ' GROUP BY je.id ORDER BY je.entry_date DESC, je.created_at DESC';
return this.tenant.query(sql, params);
}
async findOne(id: string) {
const rows = await this.tenant.query(
`SELECT je.*,
json_agg(json_build_object(
'id', jel.id, 'account_id', jel.account_id,
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
'account_name', a.name, 'account_number', a.account_number
)) as lines
FROM journal_entries je
LEFT JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id
LEFT JOIN accounts a ON a.id = jel.account_id
WHERE je.id = $1
GROUP BY je.id`,
[id],
);
if (!rows.length) throw new NotFoundException('Journal entry not found');
return rows[0];
}
async create(dto: CreateJournalEntryDto, userId: string) {
// Validate debits = credits
const totalDebits = dto.lines.reduce((sum, l) => sum + (l.debit || 0), 0);
const totalCredits = dto.lines.reduce((sum, l) => sum + (l.credit || 0), 0);
if (Math.abs(totalDebits - totalCredits) > 0.001) {
throw new BadRequestException(
`Debits ($${totalDebits.toFixed(2)}) must equal credits ($${totalCredits.toFixed(2)})`,
);
}
if (dto.lines.length < 2) {
throw new BadRequestException('Journal entry must have at least 2 lines');
}
// Find or create fiscal period
const entryDate = new Date(dto.entryDate);
const fp = await this.fiscalPeriodsService.findOrCreate(
entryDate.getFullYear(),
entryDate.getMonth() + 1,
);
if (fp.status === 'locked') {
throw new BadRequestException('Cannot post to a locked fiscal period');
}
if (fp.status === 'closed') {
throw new BadRequestException('Cannot post to a closed fiscal period');
}
// Create journal entry
const jeRows = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, reference_number, entry_type, fiscal_period_id, source_type, source_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[
dto.entryDate,
dto.description,
dto.referenceNumber || null,
dto.entryType || 'manual',
fp.id,
dto.sourceType || null,
dto.sourceId || null,
userId,
],
);
const je = jeRows[0];
// Create lines
for (const line of dto.lines) {
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
VALUES ($1, $2, $3, $4, $5)`,
[je.id, line.accountId, line.debit || 0, line.credit || 0, line.memo || null],
);
}
return this.findOne(je.id);
}
async post(id: string, userId: string) {
const je = await this.findOne(id);
if (je.is_posted) throw new BadRequestException('Already posted');
if (je.is_void) throw new BadRequestException('Cannot post a voided entry');
// Update account balances
for (const line of je.lines) {
const debit = parseFloat(line.debit) || 0;
const credit = parseFloat(line.credit) || 0;
const netAmount = debit - credit;
await this.tenant.query(
`UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`,
[netAmount, line.account_id],
);
}
const result = await this.tenant.query(
`UPDATE journal_entries SET is_posted = true, posted_by = $1, posted_at = NOW() WHERE id = $2 RETURNING *`,
[userId, id],
);
return this.findOne(result[0].id);
}
async void(id: string, userId: string, reason: string) {
const je = await this.findOne(id);
if (!je.is_posted) throw new BadRequestException('Cannot void an unposted entry');
if (je.is_void) throw new BadRequestException('Already voided');
// Reverse account balances
for (const line of je.lines) {
const debit = parseFloat(line.debit) || 0;
const credit = parseFloat(line.credit) || 0;
const reverseAmount = credit - debit;
await this.tenant.query(
`UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`,
[reverseAmount, line.account_id],
);
}
await this.tenant.query(
`UPDATE journal_entries SET is_void = true, voided_by = $1, voided_at = NOW(), void_reason = $2 WHERE id = $3`,
[userId, reason, id],
);
// Create reversing entry
const reverseDto: CreateJournalEntryDto = {
entryDate: new Date().toISOString().split('T')[0],
description: `VOID: ${je.description}`,
referenceNumber: `VOID-${je.reference_number || je.id.slice(0, 8)}`,
entryType: 'adjustment',
lines: je.lines.map((l: any) => ({
accountId: l.account_id,
debit: parseFloat(l.credit) || 0,
credit: parseFloat(l.debit) || 0,
memo: `Reversal of voided entry`,
})),
};
const reversing = await this.create(reverseDto, userId);
await this.post(reversing.id, userId);
return this.findOne(id);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

24
backend/tsconfig.json Normal file
View File

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

79
db/init/00-init.sql Normal file
View File

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

784
db/seed/seed.sql Normal file
View File

@@ -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 $$;

95
docker-compose.yml Normal file
View File

@@ -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

12
frontend/Dockerfile.dev Normal file
View File

@@ -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"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HOA Financial Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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',
},
},
},
};

104
frontend/src/App.tsx Normal file
View File

@@ -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 <Navigate to="/login" replace />;
return <>{children}</>;
}
function OrgRequiredRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const currentOrg = useAuthStore((s) => s.currentOrg);
if (!token) return <Navigate to="/login" replace />;
if (!currentOrg) return <Navigate to="/select-org" replace />;
return <>{children}</>;
}
function AuthRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const currentOrg = useAuthStore((s) => s.currentOrg);
if (token && currentOrg) return <Navigate to="/" replace />;
if (token && !currentOrg) return <Navigate to="/select-org" replace />;
return <>{children}</>;
}
export function App() {
return (
<Routes>
<Route
path="/login"
element={
<AuthRoute>
<LoginPage />
</AuthRoute>
}
/>
<Route
path="/register"
element={
<AuthRoute>
<RegisterPage />
</AuthRoute>
}
/>
<Route
path="/select-org"
element={
<ProtectedRoute>
<SelectOrgPage />
</ProtectedRoute>
}
/>
<Route
path="/*"
element={
<OrgRequiredRoute>
<AppLayout />
</OrgRequiredRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="transactions" element={<TransactionsPage />} />
<Route path="budgets/:year" element={<BudgetsPage />} />
<Route path="units" element={<UnitsPage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="payments" element={<PaymentsPage />} />
<Route path="vendors" element={<VendorsPage />} />
<Route path="reserves" element={<ReservesPage />} />
<Route path="investments" element={<InvestmentsPage />} />
<Route path="capital-projects" element={<CapitalProjectsPage />} />
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
<Route path="reports/cash-flow" element={<PlaceholderPage title="Cash Flow Statement" />} />
<Route path="reports/aging" element={<PlaceholderPage title="Aging Report" />} />
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="year-end" element={<PlaceholderPage title="Year-End Package" />} />
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
</Route>
</Routes>
);
}

View File

@@ -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 (
<AppShell
header={{ height: 60 }}
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Title order={3} c="blue">HOA Financial Platform</Title>
</Group>
<Group>
{currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
)}
<Menu shadow="md" width={200}>
<Menu.Target>
<UnstyledButton>
<Group gap="xs">
<Avatar size="sm" radius="xl" color="blue">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Avatar>
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconSwitchHorizontal size={14} />}
onClick={() => navigate('/select-org')}
>
Switch Organization
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconLogout size={14} />}
onClick={handleLogout}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar>
<Sidebar />
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

View File

@@ -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 (
<ScrollArea p="sm">
{navItems.map((item) =>
item.children ? (
<NavLink
key={item.label}
label={item.label}
leftSection={<item.icon size={18} />}
defaultOpened={item.children.some((c) =>
location.pathname.startsWith(c.path),
)}
>
{item.children.map((child) => (
<NavLink
key={child.path}
label={child.label}
active={location.pathname === child.path}
onClick={() => navigate(child.path)}
/>
))}
</NavLink>
) : (
<NavLink
key={item.path}
label={item.label}
leftSection={<item.icon size={18} />}
active={location.pathname === item.path}
onClick={() => navigate(item.path!)}
/>
),
)}
</ScrollArea>
);
}

37
frontend/src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<MantineProvider theme={theme}>
<Notifications position="top-right" />
<ModalsProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,13 @@
import { Title, Text, Card, Stack } from '@mantine/core';
export function PlaceholderPage({ title }: { title: string }) {
return (
<Stack>
<Title order={2}>{title}</Title>
<Card withBorder p="xl" ta="center">
<Text size="lg" c="dimmed">Coming soon</Text>
<Text size="sm" c="dimmed" mt="sm">This feature is under development.</Text>
</Card>
</Stack>
);
}

View File

@@ -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<string, string> = {
asset: 'green',
liability: 'red',
equity: 'violet',
income: 'blue',
expense: 'orange',
};
export function AccountsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Account | null>(null);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [filterFund, setFilterFund] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: accounts = [], isLoading } = useQuery<Account[]>({
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<string, number>);
if (isLoading) {
return <Center h={300}><Loader /></Center>;
}
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Chart of Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
</Group>
<Group>
<TextInput
placeholder="Search accounts..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="Type"
clearable
data={['asset', 'liability', 'equity', 'income', 'expense']}
value={filterType}
onChange={setFilterType}
w={150}
/>
<Select
placeholder="Fund"
clearable
data={['operating', 'reserve']}
value={filterFund}
onChange={setFilterFund}
w={150}
/>
</Group>
<Tabs defaultValue="all">
<Tabs.List>
<Tabs.Tab value="all">All ({accounts.length})</Tabs.Tab>
<Tabs.Tab value="operating">Operating</Tabs.Tab>
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="all" pt="sm">
<AccountTable accounts={filtered} onEdit={handleEdit} />
</Tabs.Panel>
<Tabs.Panel value="operating" pt="sm">
<AccountTable accounts={filtered.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} />
</Tabs.Panel>
<Tabs.Panel value="reserve" pt="sm">
<AccountTable accounts={filtered.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} />
</Tabs.Panel>
</Tabs>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Account' : 'New Account'} size="md">
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
<Stack>
<NumberInput label="Account Number" required {...form.getInputProps('account_number')} />
<TextInput label="Account Name" required {...form.getInputProps('name')} />
<TextInput label="Description" {...form.getInputProps('description')} />
<Select
label="Account Type"
required
data={[
{ value: 'asset', label: 'Asset' },
{ value: 'liability', label: 'Liability' },
{ value: 'equity', label: 'Equity' },
{ value: 'income', label: 'Income' },
{ value: 'expense', label: 'Expense' },
]}
{...form.getInputProps('account_type')}
/>
<Select
label="Fund Type"
required
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
]}
{...form.getInputProps('fund_type')}
/>
<Switch label="1099 Reportable" {...form.getInputProps('is_1099_reportable', { type: 'checkbox' })} />
<Button type="submit" loading={createMutation.isPending}>
{editing ? 'Update' : 'Create'}
</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}
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 (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Acct #</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Balance</Table.Th>
<Table.Th>1099</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accounts.map((a) => (
<Table.Tr key={a.id}>
<Table.Td fw={500}>{a.account_number}</Table.Td>
<Table.Td>{a.name}</Table.Td>
<Table.Td>
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
{a.account_type}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{a.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(a.balance)}</Table.Td>
<Table.Td>{a.is_1099_reportable ? '1099' : ''}</Table.Td>
<Table.Td>
{!a.is_system && (
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View File

@@ -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 (
<Container size={420} my={80}>
<Title ta="center" order={2}>
HOA Financial Platform
</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Don&apos;t have an account?{' '}
<Anchor component={Link} to="/register" size="sm">
Register
</Anchor>
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label="Email"
placeholder="your@email.com"
required
{...form.getInputProps('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
required
{...form.getInputProps('password')}
/>
<Button type="submit" fullWidth loading={loading}>
Sign in
</Button>
</Stack>
</form>
</Paper>
</Container>
);
}

View File

@@ -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 (
<Container size={420} my={80}>
<Title ta="center" order={2}>
Create Account
</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Already have an account?{' '}
<Anchor component={Link} to="/login" size="sm">
Sign in
</Anchor>
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label="First Name"
placeholder="Jane"
required
{...form.getInputProps('firstName')}
/>
<TextInput
label="Last Name"
placeholder="Doe"
required
{...form.getInputProps('lastName')}
/>
<TextInput
label="Email"
placeholder="your@email.com"
required
{...form.getInputProps('email')}
/>
<PasswordInput
label="Password"
placeholder="Min 8 characters"
required
{...form.getInputProps('password')}
/>
<Button type="submit" fullWidth loading={loading}>
Create Account
</Button>
</Stack>
</form>
</Paper>
</Container>
);
}

View File

@@ -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 (
<Container size={500} my={80}>
<Title ta="center" order={2}>No Organizations</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Please log in again to refresh your session.
</Text>
<Button fullWidth mt="lg" onClick={() => { logout(); navigate('/login'); }}>
Go to Login
</Button>
</Container>
);
}
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 (
<Container size={500} my={80}>
<Title ta="center" order={2}>Select Organization</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Choose an HOA to manage or create a new one
</Text>
<Stack mt={30}>
{organizations.map((org) => (
<Card
key={org.id}
shadow="sm"
padding="lg"
radius="md"
withBorder
style={{ cursor: 'pointer' }}
onClick={() => handleSelect(org)}
>
<Group justify="space-between">
<Group>
<IconBuilding size={24} />
<div>
<Text fw={500}>{org.name}</Text>
<Group gap={4}>
<Badge size="sm" variant="light">{org.role}</Badge>
{org.schemaName && (
<Badge size="xs" variant="dot" color="gray">
{org.schemaName}
</Badge>
)}
</Group>
</div>
</Group>
<Button variant="light" size="xs">Select</Button>
</Group>
</Card>
))}
<Button
variant="outline"
leftSection={<IconPlus size={16} />}
onClick={open}
fullWidth
>
Create New HOA
</Button>
</Stack>
<Modal opened={opened} onClose={close} title="Create New HOA">
<form onSubmit={form.onSubmit(handleCreateOrg)}>
<Stack>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label="HOA Name"
placeholder="Sunrise Valley HOA"
required
{...form.getInputProps('name')}
/>
<TextInput
label="Address"
placeholder="123 Main St"
{...form.getInputProps('addressLine1')}
/>
<Group grow>
<TextInput label="City" placeholder="Springfield" {...form.getInputProps('city')} />
<TextInput label="State" placeholder="IL" {...form.getInputProps('state')} />
<TextInput label="ZIP" placeholder="62701" {...form.getInputProps('zipCode')} />
</Group>
<Button type="submit" fullWidth loading={loading}>
Create Organization
</Button>
</Stack>
</form>
</Modal>
</Container>
);
}

View File

@@ -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<BudgetLine[]>([]);
const queryClient = useQueryClient();
const { isLoading } = useQuery<BudgetLine[]>({
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 <Center h={300}><Loader /></Center>;
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 (
<Stack>
<Group justify="space-between">
<Title order={2}>Budget Manager</Title>
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>
</Group>
</Group>
<Group>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Income</Text>
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
</Card>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Expenses</Text>
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
</Card>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Net</Text>
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalIncome - totalExpense)}
</Text>
</Card>
</Group>
<div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 1400 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1, minWidth: 250 }}>Account</Table.Th>
{monthLabels.map((m) => (
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
))}
<Table.Th ta="right" style={{ minWidth: 100 }}>Annual</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{budgetData.length === 0 && (
<Table.Tr>
<Table.Td colSpan={14}>
<Text ta="center" c="dimmed" py="lg">No budget data. Income and expense accounts will appear here.</Text>
</Table.Td>
</Table.Tr>
)}
{['income', 'expense'].map((type) => {
const lines = budgetData.filter((b) => b.account_type === type);
if (lines.length === 0) return null;
return [
<Table.Tr key={`header-${type}`} style={{ background: type === 'income' ? '#e6f9e6' : '#fde8e8' }}>
<Table.Td colSpan={14} fw={700} tt="capitalize">{type}</Table.Td>
</Table.Tr>,
...lines.map((line) => {
const idx = budgetData.indexOf(line);
return (
<Table.Tr key={line.account_id}>
<Table.Td style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1 }}>
<Group gap="xs">
<Text size="sm" c="dimmed">{line.account_number}</Text>
<Text size="sm">{line.account_name}</Text>
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
</Group>
</Table.Td>
{months.map((m) => (
<Table.Td key={m} p={2}>
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</Table.Td>
))}
<Table.Td ta="right" fw={500} ff="monospace">
{fmt(line.annual_total || 0)}
</Table.Td>
</Table.Tr>
);
}),
];
})}
</Table.Tbody>
</Table>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,139 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface CapitalProject {
id: string; name: string; description: string; estimated_cost: string;
actual_cost: string; target_year: number; target_month: number;
status: string; fund_source: string; priority: number;
}
const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow',
completed: 'teal', deferred: 'gray', cancelled: 'red',
};
export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<CapitalProject | null>(null);
const queryClient = useQueryClient();
const { data: projects = [], isLoading } = useQuery<CapitalProject[]>({
queryKey: ['capital-projects'],
queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; },
});
const form = useForm({
initialValues: {
name: '', description: '', estimated_cost: 0, actual_cost: 0,
target_year: new Date().getFullYear(), target_month: 6,
status: 'planned', fund_source: 'reserve', priority: 3,
},
validate: { name: (v) => (v.length > 0 ? null : 'Required'), estimated_cost: (v) => (v > 0 ? null : 'Required') },
});
const saveMutation = useMutation({
mutationFn: (values: any) => editing ? api.put(`/capital-projects/${editing.id}`, values) : api.post('/capital-projects', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
notifications.show({ message: editing ? 'Project updated' : 'Project created', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (p: CapitalProject) => {
setEditing(p);
form.setValues({
name: p.name, description: p.description || '',
estimated_cost: parseFloat(p.estimated_cost || '0'), actual_cost: parseFloat(p.actual_cost || '0'),
target_year: p.target_year, target_month: p.target_month || 6,
status: p.status, fund_source: p.fund_source || 'reserve', priority: p.priority || 3,
});
open();
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const years = [...new Set(projects.map(p => p.target_year))].sort();
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Capital Projects (5-Year Plan)</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Project</Button>
</Group>
{years.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No capital projects planned yet. Add your first project.</Text>
) : years.map(year => {
const yearProjects = projects.filter(p => p.target_year === year);
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
<Stack key={year} gap="xs">
<Group>
<Title order={4}>{year}</Title>
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Project</Table.Th><Table.Th>Target</Table.Th><Table.Th>Priority</Table.Th>
<Table.Th ta="right">Estimated</Table.Th><Table.Th ta="right">Actual</Table.Th>
<Table.Th>Source</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{yearProjects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.target_month ? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' }) : ''} {p.target_year}</Table.Td>
<Table.Td><Badge size="sm" color={p.priority <= 2 ? 'red' : p.priority <= 3 ? 'yellow' : 'gray'}>P{p.priority}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(p.estimated_cost)}</Table.Td>
<Table.Td ta="right" ff="monospace">{parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{p.fund_source}</Badge></Table.Td>
<Table.Td><Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status}</Badge></Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(p)}><IconEdit size={16} /></ActionIcon></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
})}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<TextInput label="Project Name" required {...form.getInputProps('name')} />
<Textarea label="Description" {...form.getInputProps('description')} />
<Group grow>
<NumberInput label="Estimated Cost" required prefix="$" decimalScale={2} min={0} {...form.getInputProps('estimated_cost')} />
<NumberInput label="Actual Cost" prefix="$" decimalScale={2} min={0} {...form.getInputProps('actual_cost')} />
</Group>
<Group grow>
<NumberInput label="Target Year" required min={2024} max={2040} {...form.getInputProps('target_year')} />
<Select label="Target Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))}
value={String(form.values.target_month)} onChange={(v) => form.setFieldValue('target_month', Number(v))} />
</Group>
<Group grow>
<Select label="Status" data={Object.keys(statusColors).map(s => ({ value: s, label: s.replace('_', ' ') }))} {...form.getInputProps('status')} />
<Select label="Fund Source" data={[{value:'reserve',label:'Reserve'},{value:'operating',label:'Operating'},{value:'special_assessment',label:'Special Assessment'}]} {...form.getInputProps('fund_source')} />
<NumberInput label="Priority (1=High, 5=Low)" min={1} max={5} {...form.getInputProps('priority')} />
</Group>
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,147 @@
import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center,
} from '@mantine/core';
import {
IconCash,
IconFileInvoice,
IconShieldCheck,
IconAlertTriangle,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
interface DashboardData {
total_cash: string;
total_receivables: string;
reserve_fund_balance: string;
delinquent_units: number;
recent_transactions: {
id: string; entry_date: string; description: string; entry_type: string; amount: string;
}[];
}
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const { data, isLoading } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => { const { data } = await api.get('/reports/dashboard'); return data; },
enabled: !!currentOrg,
});
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const stats = [
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' },
{ title: 'Total Receivables', value: fmt(data?.total_receivables || '0'), icon: IconFileInvoice, color: 'blue' },
{ title: 'Reserve Fund', value: fmt(data?.reserve_fund_balance || '0'), icon: IconShieldCheck, color: 'violet' },
{ title: 'Delinquent Accounts', value: String(data?.delinquent_units || 0), icon: IconAlertTriangle, color: 'orange' },
];
const entryTypeColors: Record<string, string> = {
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
transfer: 'cyan', adjustment: 'yellow', closing: 'dark', opening_balance: 'indigo',
};
return (
<Stack>
<div>
<Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
</Text>
</div>
{!currentOrg ? (
<Card withBorder p="xl" ta="center">
<Text size="lg" fw={500}>Welcome to the HOA Financial Platform</Text>
<Text c="dimmed" mt="sm">
Create or select an organization to get started.
</Text>
</Card>
) : isLoading ? (
<Center h={200}><Loader /></Center>
) : (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
{stats.map((stat) => (
<Card key={stat.title} withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{stat.title}
</Text>
<Text fw={700} size="xl">
{stat.value}
</Text>
</div>
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
<stat.icon size={28} />
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Card withBorder padding="lg" radius="md">
<Title order={4} mb="sm">Recent Transactions</Title>
{(data?.recent_transactions || []).length === 0 ? (
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
) : (
<Table striped highlightOnHover>
<Table.Tbody>
{(data?.recent_transactions || []).map((tx) => (
<Table.Tr key={tx.id}>
<Table.Td>
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={1}>{tx.description}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
{tx.entry_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>
{fmt(tx.amount)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<Card withBorder padding="lg" radius="md">
<Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Cash Position</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Outstanding AR</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Reserve Funding</Text>
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_fund_balance || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Delinquent Units</Text>
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
{data?.delinquent_units || 0}
</Text>
</Group>
</Stack>
</Card>
</SimpleGrid>
</>
)}
</Stack>
);
}

Some files were not shown because too many files have changed in this diff Show More