Compare commits
49 Commits
e0c956859b
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b83defbc3 | |||
| 1e31595d7f | |||
| 07d15001ae | |||
| a0b366e94a | |||
| 3790a3bd9e | |||
| 0a07c61ca3 | |||
| 337b6061b2 | |||
| 467fdd2a6c | |||
| c12ad94b7f | |||
| 05e241c792 | |||
| 5ee4c71fc1 | |||
| 81908e48ea | |||
| 6230558b91 | |||
| 2c215353d4 | |||
| d526025926 | |||
| 411239bea4 | |||
| 7e6c4c16ce | |||
| ea0e3d6f29 | |||
| 8db89373e0 | |||
| e719f593de | |||
| 16adfd6f26 | |||
| 704f29362a | |||
| 42767e3119 | |||
| a550a8d0be | |||
| 063741adc7 | |||
| ad2f16d93b | |||
| b0b36df4e4 | |||
| aa7f2dab32 | |||
| d2d553eed6 | |||
| 2ca277b6e6 | |||
| bfcbe086f2 | |||
| c92eb1b57b | |||
| 07347a644f | |||
| f1e66966f3 | |||
| d1c40c633f | |||
| 0e82e238c1 | |||
| 2fed5d6ce1 | |||
| d9bb9363dd | |||
| e156cf7c87 | |||
| 76ab63a200 | |||
| a32d4cc179 | |||
| 0bd30a0eb8 | |||
| 0626b8d496 | |||
| 25663fc79e | |||
| fe4989bbcc | |||
| 36271585d9 | |||
| 18c7989983 | |||
| c28d7aeffc | |||
| f7e9c98bd9 |
12
.env.example
12
.env.example
@@ -5,3 +5,15 @@ DATABASE_URL=postgresql://hoafinance:change_me@postgres:5432/hoafinance
|
||||
REDIS_URL=redis://redis:6379
|
||||
JWT_SECRET=change_me_to_random_string
|
||||
NODE_ENV=development
|
||||
|
||||
# AI Investment Advisor (OpenAI-compatible API)
|
||||
AI_API_URL=https://integrate.api.nvidia.com/v1
|
||||
AI_API_KEY=your_nvidia_api_key_here
|
||||
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||
# Set to 'true' to enable detailed AI prompt/response logging
|
||||
AI_DEBUG=false
|
||||
|
||||
# New Relic APM — set ENABLED=true and provide your license key to activate
|
||||
NEW_RELIC_ENABLED=false
|
||||
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here
|
||||
NEW_RELIC_APP_NAME=HOALedgerIQ_App
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,6 +24,11 @@ postgres_data/
|
||||
redis_data/
|
||||
pgdata/
|
||||
|
||||
# Database backups
|
||||
backups/
|
||||
*.dump
|
||||
*.dump.gz
|
||||
|
||||
# SSL
|
||||
letsencrypt/
|
||||
|
||||
|
||||
136
PLAN.md
Normal file
136
PLAN.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Phase 2 Bug Fix & Tweaks - Implementation Plan
|
||||
|
||||
## 1. Admin Panel: Tenant Creation, Contract/Plan Fields, Disable/Archive
|
||||
|
||||
### Database Changes
|
||||
- Add `contract_number VARCHAR(100)` and `plan_level VARCHAR(50) DEFAULT 'standard'` to `shared.organizations` (live DB ALTER + init SQL)
|
||||
- Add `archived` to the status CHECK constraint: `('active', 'suspended', 'trial', 'archived')`
|
||||
- Add to Organization entity: `contractNumber`, `planLevel` columns
|
||||
|
||||
### Backend Changes
|
||||
- **admin.controller.ts**: Add two new endpoints:
|
||||
- `POST /admin/tenants` — Creates org + first user + tenant schema in one call. Accepts: org name, email, address, contractNumber, planLevel, plus first user's email/password/firstName/lastName. Calls OrganizationsService.create() then sets up the user.
|
||||
- `PUT /admin/organizations/:id/status` — Sets status to 'active', 'suspended', or 'archived'
|
||||
- **auth.module.ts**: Import OrganizationsModule so AdminController can inject OrganizationsService
|
||||
- **auth.service.ts**: In `login()`, after loading user with orgs, check if the default org's status is 'suspended' or 'archived' → throw UnauthorizedException("Your organization has been suspended/archived")
|
||||
- **users.service.ts**: Update `findAllOrganizations()` query to include `contract_number, plan_level` in the SELECT
|
||||
|
||||
### Frontend Changes
|
||||
- **AdminPage.tsx**:
|
||||
- Add "Create Tenant" button → opens a modal with: org name, address, email, phone, contract number, plan level (select: standard/premium/enterprise), first admin email, first admin password, first/last name
|
||||
- Orgs table: add Contract #, Plan Level columns
|
||||
- Orgs table: add Status dropdown/buttons (Active/Suspended/Archived) per row with confirmation
|
||||
- Show status colors: active=green, trial=yellow, suspended=orange, archived=red
|
||||
|
||||
## 2. Units/Homeowners: Delete + Assessment Group Binding
|
||||
|
||||
### Backend Changes
|
||||
- **units.controller.ts**: Add `@Delete(':id')` route
|
||||
- **units.service.ts**:
|
||||
- Add `delete(id)` method — checks for outstanding invoices first, then deletes
|
||||
- Add `assessment_group_id` to `create()` INSERT and `update()` UPDATE queries
|
||||
- Update `findAll()` to JOIN assessment_groups and return `assessment_group_name`
|
||||
|
||||
### Frontend Changes
|
||||
- **UnitsPage.tsx**:
|
||||
- Add delete button (trash icon) per row with confirmation dialog
|
||||
- Add Assessment Group dropdown (Select) in create/edit modal, populated from `/assessment-groups` query
|
||||
- Show assessment group name in table
|
||||
- When an assessment group is selected and no manual monthly_assessment is set, auto-fill from the group's regular_assessment
|
||||
|
||||
## 3. Assessment Groups: Frequency Field
|
||||
|
||||
### Database Changes
|
||||
- Add `frequency VARCHAR(20) DEFAULT 'monthly'` to `assessment_groups` table (live DB ALTER + tenant-schema DDL)
|
||||
- CHECK constraint: `('monthly', 'quarterly', 'annual')`
|
||||
|
||||
### Backend Changes
|
||||
- **assessment-groups.service.ts**:
|
||||
- Add `frequency` to `create()` INSERT
|
||||
- Add `frequency` to `update()` dynamic sets
|
||||
- Update `findAll()` and `getSummary()` income calculations to adjust by frequency:
|
||||
- monthly → multiply by 1 (×12/year)
|
||||
- quarterly → amounts are per quarter, so monthly = amount/3
|
||||
- annual → amounts are per year, so monthly = amount/12
|
||||
- Summary labels should change to reflect "Monthly Equivalent" for mixed frequencies
|
||||
|
||||
### Frontend Changes
|
||||
- **AssessmentGroupsPage.tsx**:
|
||||
- Add frequency Select in create/edit modal: Monthly, Quarterly, Annual
|
||||
- Show frequency badge in table
|
||||
- Update summary cards: labels → "Monthly Equivalent Operating" etc.
|
||||
- Assessment amount label changes based on frequency ("Per Month" / "Per Quarter" / "Per Year")
|
||||
|
||||
## 4. UI Streamlining: Sidebar Grouping, Rename, Logo
|
||||
|
||||
### Sidebar Restructure
|
||||
Group nav items into labeled sections:
|
||||
```
|
||||
Dashboard
|
||||
─── FINANCIALS ───
|
||||
Accounts (renamed from "Chart of Accounts")
|
||||
Budgets
|
||||
Investments
|
||||
─── ASSESSMENTS ───
|
||||
Units / Homeowners
|
||||
Assessment Groups
|
||||
─── TRANSACTIONS ───
|
||||
Transactions
|
||||
Invoices
|
||||
Payments
|
||||
─── PLANNING ───
|
||||
Capital Projects
|
||||
Reserves
|
||||
Vendors
|
||||
─── REPORTS ───
|
||||
(collapsible with sub-items)
|
||||
─── ADMIN ───
|
||||
Year-End
|
||||
Settings
|
||||
─── PLATFORM ADMIN ─── (superadmin only)
|
||||
Admin Panel
|
||||
```
|
||||
|
||||
### Logo
|
||||
- Copy SVG to `frontend/src/assets/logo.svg`
|
||||
- In AppLayout.tsx: Replace `<Title order={3} c="blue">HOA LedgerIQ</Title>` with an `<img>` tag loading the SVG, sized to fit the 60px header (height ~40px with padding)
|
||||
- SVG will be served directly (Vite handles SVG imports natively), no PNG conversion needed since browsers render SVG natively and it's cleaner
|
||||
|
||||
## 5. Capital Projects: PDF Table Export, Kanban Default, Future Category
|
||||
|
||||
### Frontend Changes
|
||||
- **CapitalProjectsPage.tsx**:
|
||||
- Change default viewMode from `'table'` to `'kanban'`
|
||||
- PDF export: temporarily switch to table view for print, then restore. Use `@media print` CSS to always show table layout regardless of current view
|
||||
- Add "Future" column in kanban: projects with `target_year = 9999` (sentinel value) display as "Future"
|
||||
- Update the form: Target Year select should include a "Future (Beyond 5-Year)" option that maps to year 9999
|
||||
- Kanban year list: always include current year through +5, plus "Future" if any projects exist there
|
||||
- Table view: group "Future" projects under a "Future" header
|
||||
- Title: "Capital Projects" (remove "(5-Year Plan)" since we now have Future)
|
||||
|
||||
### Backend
|
||||
- No backend changes needed — target_year=9999 works with existing schema (integer column, no constraint)
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `db/init/00-init.sql` | Add contract_number, plan_level, update status CHECK |
|
||||
| `backend/src/modules/organizations/entities/organization.entity.ts` | Add contractNumber, planLevel columns |
|
||||
| `backend/src/modules/organizations/dto/create-organization.dto.ts` | Add contractNumber, planLevel fields |
|
||||
| `backend/src/modules/auth/admin.controller.ts` | Add POST /admin/tenants, PUT /admin/organizations/:id/status |
|
||||
| `backend/src/modules/auth/auth.module.ts` | Import OrganizationsModule |
|
||||
| `backend/src/modules/auth/auth.service.ts` | Add org status check on login |
|
||||
| `backend/src/modules/users/users.service.ts` | Update findAllOrganizations query |
|
||||
| `backend/src/modules/units/units.controller.ts` | Add DELETE route |
|
||||
| `backend/src/modules/units/units.service.ts` | Add delete(), assessment_group_id support |
|
||||
| `backend/src/modules/assessment-groups/assessment-groups.service.ts` | Add frequency support + adjust income calcs |
|
||||
| `backend/src/database/tenant-schema.service.ts` | Add frequency to assessment_groups DDL |
|
||||
| `frontend/src/assets/logo.svg` | New — copy from /Users/claw/Downloads/logo_house.svg |
|
||||
| `frontend/src/components/layout/AppLayout.tsx` | Replace text with logo |
|
||||
| `frontend/src/components/layout/Sidebar.tsx` | Restructure with grouped sections |
|
||||
| `frontend/src/pages/admin/AdminPage.tsx` | Create tenant modal, status management, new columns |
|
||||
| `frontend/src/pages/units/UnitsPage.tsx` | Delete, assessment group dropdown |
|
||||
| `frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx` | Frequency field |
|
||||
| `frontend/src/pages/capital-projects/CapitalProjectsPage.tsx` | Kanban default, table PDF, Future category |
|
||||
| Live DB | ALTER TABLE commands for contract_number, plan_level, frequency, status CHECK |
|
||||
32
backend/Dockerfile
Normal file
32
backend/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# ---- Production Dockerfile for NestJS backend ----
|
||||
# Multi-stage build: compile TypeScript, then run with minimal image
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Only install production dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Copy compiled output and New Relic preload from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/newrelic-preload.js ./newrelic-preload.js
|
||||
|
||||
# New Relic agent — configured entirely via environment variables
|
||||
ENV NEW_RELIC_NO_CONFIG_FILE=true
|
||||
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
|
||||
ENV NEW_RELIC_LOG=stdout
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Preload the New Relic agent (activates only when NEW_RELIC_ENABLED=true)
|
||||
CMD ["node", "-r", "./newrelic-preload.js", "dist/main"]
|
||||
@@ -7,6 +7,11 @@ RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
# New Relic agent — configured entirely via environment variables
|
||||
ENV NEW_RELIC_NO_CONFIG_FILE=true
|
||||
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
|
||||
ENV NEW_RELIC_LOG=stdout
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
7
backend/newrelic-preload.js
Normal file
7
backend/newrelic-preload.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Conditionally load the New Relic agent before any other modules.
|
||||
// Controlled by the NEW_RELIC_ENABLED environment variable (.env).
|
||||
'use strict';
|
||||
|
||||
if (process.env.NEW_RELIC_ENABLED === 'true') {
|
||||
require('newrelic');
|
||||
}
|
||||
1431
backend/package-lock.json
generated
1431
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "0.2.0",
|
||||
"version": "2026.3.2-beta",
|
||||
"description": "HOA LedgerIQ - Backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:prod": "node -r ./newrelic-preload.js dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -23,6 +23,7 @@
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -36,6 +37,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20",
|
||||
"newrelic": "latest",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
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 { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@@ -23,6 +25,9 @@ import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-g
|
||||
import { ProjectsModule } from './modules/projects/projects.module';
|
||||
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
||||
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,6 +43,13 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||
autoLoadEntities: true,
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
// Connection pool — reuse connections instead of creating new ones per query
|
||||
extra: {
|
||||
max: 30, // max pool size (across all concurrent requests)
|
||||
min: 5, // keep at least 5 idle connections warm
|
||||
idleTimeoutMillis: 30000, // close idle connections after 30s
|
||||
connectionTimeoutMillis: 5000, // fail fast if pool is exhausted
|
||||
},
|
||||
}),
|
||||
}),
|
||||
DatabaseModule,
|
||||
@@ -60,8 +72,17 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||
ProjectsModule,
|
||||
MonthlyActualsModule,
|
||||
AttachmentsModule,
|
||||
InvestmentPlanningModule,
|
||||
HealthScoresModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: WriteAccessGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ALLOW_VIEWER_KEY = 'allowViewer';
|
||||
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);
|
||||
35
backend/src/common/guards/write-access.guard.ts
Normal file
35
backend/src/common/guards/write-access.guard.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class WriteAccessGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const method = request.method;
|
||||
|
||||
// Allow all read methods
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true;
|
||||
|
||||
// Determine role from either req.userRole (set by TenantMiddleware which runs
|
||||
// before guards) or req.user.role (set by JwtAuthGuard Passport strategy).
|
||||
const role = request.userRole || request.user?.role;
|
||||
if (!role) return true; // unauthenticated endpoints like login/register
|
||||
|
||||
// Check for @AllowViewer() exemption on handler or class
|
||||
const allowViewer = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (allowViewer) return true;
|
||||
|
||||
// Block viewer role from write operations
|
||||
if (role === 'viewer') {
|
||||
throw new ForbiddenException('Read-only users cannot modify data');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,8 @@ export class TenantSchemaService {
|
||||
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
||||
unit_count INTEGER DEFAULT 0,
|
||||
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
|
||||
due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}',
|
||||
due_day INTEGER DEFAULT 1,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@@ -155,8 +157,11 @@ export class TenantSchemaService {
|
||||
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'
|
||||
'draft', 'pending', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
|
||||
)),
|
||||
period_start DATE,
|
||||
period_end DATE,
|
||||
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
|
||||
journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
|
||||
sent_at TIMESTAMPTZ,
|
||||
paid_at TIMESTAMPTZ,
|
||||
@@ -202,6 +207,7 @@ export class TenantSchemaService {
|
||||
default_account_id UUID REFERENCES "${s}".accounts(id),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
|
||||
last_negotiated DATE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
@@ -316,6 +322,36 @@ export class TenantSchemaService {
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// AI Investment Recommendations (saved per tenant)
|
||||
`CREATE TABLE "${s}".ai_recommendations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
recommendations_json JSONB NOT NULL,
|
||||
overall_assessment TEXT,
|
||||
risk_notes JSONB,
|
||||
requested_by UUID,
|
||||
response_time_ms INTEGER,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Health Scores (AI-derived operating / reserve fund health)
|
||||
`CREATE TABLE "${s}".health_scores (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')),
|
||||
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||
previous_score INTEGER,
|
||||
trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')),
|
||||
label VARCHAR(30),
|
||||
summary TEXT,
|
||||
factors JSONB,
|
||||
recommendations JSONB,
|
||||
missing_data JSONB,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')),
|
||||
response_time_ms INTEGER,
|
||||
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`,
|
||||
|
||||
// Attachments (file storage for receipts/invoices)
|
||||
`CREATE TABLE "${s}".attachments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
@@ -12,9 +13,16 @@ export interface TenantRequest extends Request {
|
||||
|
||||
@Injectable()
|
||||
export class TenantMiddleware implements NestMiddleware {
|
||||
constructor(private configService: ConfigService) {}
|
||||
// In-memory cache for org status to avoid DB hit per request
|
||||
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
use(req: TenantRequest, _res: Response, next: NextFunction) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async 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 ')) {
|
||||
@@ -23,6 +31,18 @@ export class TenantMiddleware implements NestMiddleware {
|
||||
const secret = this.configService.get<string>('JWT_SECRET');
|
||||
const decoded = jwt.verify(token, secret!) as any;
|
||||
if (decoded?.orgSchema) {
|
||||
// Check if the org is still active (catches post-JWT suspension)
|
||||
if (decoded.orgId) {
|
||||
const status = await this.getOrgStatus(decoded.orgId);
|
||||
if (status && ['suspended', 'archived'].includes(status)) {
|
||||
res.status(403).json({
|
||||
statusCode: 403,
|
||||
message: `This organization has been ${status}. Please contact your administrator.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
req.tenantSchema = decoded.orgSchema;
|
||||
req.orgId = decoded.orgId;
|
||||
req.userId = decoded.sub;
|
||||
@@ -34,4 +54,24 @@ export class TenantMiddleware implements NestMiddleware {
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
private async getOrgStatus(orgId: string): Promise<string | null> {
|
||||
const cached = this.orgStatusCache.get(orgId);
|
||||
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||
return cached.status;
|
||||
}
|
||||
try {
|
||||
const result = await this.dataSource.query(
|
||||
`SELECT status FROM shared.organizations WHERE id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
if (result.length > 0) {
|
||||
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
||||
return result[0].status;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — don't block requests on cache miss errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
import * as _cluster from 'node:cluster';
|
||||
import * as os from 'node:os';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clustering — fork one worker per CPU core in production
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKERS = isProduction
|
||||
? Math.min(os.cpus().length, 4) // cap at 4 workers to stay within DB pool
|
||||
: 1; // single process in dev
|
||||
|
||||
if (WORKERS > 1 && cluster.isPrimary) {
|
||||
console.log(`Primary ${process.pid} forking ${WORKERS} workers ...`);
|
||||
for (let i = 0; i < WORKERS; i++) {
|
||||
cluster.fork();
|
||||
}
|
||||
cluster.on('exit', (worker: any, code: number) => {
|
||||
console.warn(`Worker ${worker.process.pid} exited (code ${code}), restarting ...`);
|
||||
cluster.fork();
|
||||
});
|
||||
} else {
|
||||
bootstrap();
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NestJS bootstrap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Request logging
|
||||
// Request logging — only in development (too noisy / slow for prod)
|
||||
if (!isProduction) {
|
||||
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({
|
||||
@@ -22,21 +57,22 @@ async function bootstrap() {
|
||||
}),
|
||||
);
|
||||
|
||||
// CORS — in production nginx handles this; accept all origins behind the proxy
|
||||
app.enableCors({
|
||||
origin: ['http://localhost', 'http://localhost:5173'],
|
||||
origin: isProduction ? true : ['http://localhost', 'http://localhost:5173'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Swagger docs — available in all environments
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('HOA LedgerIQ API')
|
||||
.setDescription('API for the HOA LedgerIQ')
|
||||
.setVersion('0.1.0')
|
||||
.setVersion('2026.3.2')
|
||||
.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');
|
||||
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -142,7 +142,21 @@ export class AccountsService {
|
||||
}
|
||||
}
|
||||
|
||||
return account;
|
||||
// Auto-set as primary if this is the first asset account for this fund_type
|
||||
if (dto.accountType === 'asset') {
|
||||
const existingPrimary = await this.tenant.query(
|
||||
'SELECT id FROM accounts WHERE fund_type = $1 AND is_primary = true AND id != $2',
|
||||
[dto.fundType, accountId],
|
||||
);
|
||||
if (!existingPrimary.length) {
|
||||
await this.tenant.query(
|
||||
'UPDATE accounts SET is_primary = true WHERE id = $1',
|
||||
[accountId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.findOne(accountId);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateAccountDto) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
const DEFAULT_DUE_MONTHS: Record<string, number[]> = {
|
||||
monthly: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
quarterly: [1, 4, 7, 10],
|
||||
annual: [1],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AssessmentGroupsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
|
||||
return rows.length ? rows[0] : null;
|
||||
}
|
||||
|
||||
private validateDueMonths(frequency: string, dueMonths: number[]) {
|
||||
if (!dueMonths || !dueMonths.length) {
|
||||
throw new BadRequestException('Due months are required');
|
||||
}
|
||||
// Validate all values are 1-12
|
||||
if (dueMonths.some((m) => m < 1 || m > 12 || !Number.isInteger(m))) {
|
||||
throw new BadRequestException('Due months must be integers between 1 and 12');
|
||||
}
|
||||
switch (frequency) {
|
||||
case 'monthly':
|
||||
if (dueMonths.length !== 12) {
|
||||
throw new BadRequestException('Monthly frequency must include all 12 months');
|
||||
}
|
||||
break;
|
||||
case 'quarterly':
|
||||
if (dueMonths.length !== 4) {
|
||||
throw new BadRequestException('Quarterly frequency must have exactly 4 due months');
|
||||
}
|
||||
break;
|
||||
case 'annual':
|
||||
if (dueMonths.length !== 1) {
|
||||
throw new BadRequestException('Annual frequency must have exactly 1 due month');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
|
||||
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
|
||||
@@ -51,17 +84,23 @@ export class AssessmentGroupsService {
|
||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||
}
|
||||
|
||||
const frequency = dto.frequency || 'monthly';
|
||||
const dueMonths = dto.dueMonths || DEFAULT_DUE_MONTHS[frequency] || DEFAULT_DUE_MONTHS.monthly;
|
||||
const dueDay = Math.min(Math.max(dto.dueDay || 1, 1), 28);
|
||||
|
||||
this.validateDueMonths(frequency, dueMonths);
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
|
||||
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault],
|
||||
dto.unitCount || 0, frequency, dueMonths, dueDay, shouldBeDefault],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const existing = await this.findOne(id);
|
||||
|
||||
if (dto.isDefault === true) {
|
||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||
@@ -80,6 +119,24 @@ export class AssessmentGroupsService {
|
||||
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
|
||||
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
|
||||
|
||||
// Handle due_months: if frequency changed and no explicit dueMonths, auto-populate defaults
|
||||
const effectiveFrequency = dto.frequency || existing.frequency;
|
||||
if (dto.dueMonths !== undefined) {
|
||||
this.validateDueMonths(effectiveFrequency, dto.dueMonths);
|
||||
sets.push(`due_months = $${idx++}`);
|
||||
params.push(dto.dueMonths);
|
||||
} else if (dto.frequency !== undefined && dto.frequency !== existing.frequency) {
|
||||
// Frequency changed, auto-populate due_months
|
||||
const newDueMonths = DEFAULT_DUE_MONTHS[dto.frequency] || DEFAULT_DUE_MONTHS.monthly;
|
||||
sets.push(`due_months = $${idx++}`);
|
||||
params.push(newDueMonths);
|
||||
}
|
||||
|
||||
if (dto.dueDay !== undefined) {
|
||||
sets.push(`due_day = $${idx++}`);
|
||||
params.push(Math.min(Math.max(dto.dueDay, 1), 28));
|
||||
}
|
||||
|
||||
if (!sets.length) return this.findOne(id);
|
||||
|
||||
sets.push('updated_at = NOW()');
|
||||
|
||||
325
backend/src/modules/auth/admin-analytics.service.ts
Normal file
325
backend/src/modules/auth/admin-analytics.service.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAnalyticsService {
|
||||
private readonly logger = new Logger(AdminAnalyticsService.name);
|
||||
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* Platform-wide metrics for the admin dashboard.
|
||||
*/
|
||||
async getPlatformMetrics() {
|
||||
const [
|
||||
userStats,
|
||||
orgStats,
|
||||
planBreakdown,
|
||||
statusBreakdown,
|
||||
newTenantsPerMonth,
|
||||
newUsersPerMonth,
|
||||
aiStats,
|
||||
activeUsers30d,
|
||||
] = await Promise.all([
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
COUNT(*) FILTER (WHERE is_superadmin = true) as superadmin_count,
|
||||
COUNT(*) FILTER (WHERE is_platform_owner = true) as platform_owner_count
|
||||
FROM shared.users
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_organizations,
|
||||
COUNT(*) FILTER (WHERE status = 'active') as active_count,
|
||||
COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
|
||||
COUNT(*) FILTER (WHERE status = 'suspended') as suspended_count,
|
||||
COUNT(*) FILTER (WHERE status = 'trial') as trial_count
|
||||
FROM shared.organizations
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT plan_level, COUNT(*) as count
|
||||
FROM shared.organizations
|
||||
WHERE status != 'archived'
|
||||
GROUP BY plan_level
|
||||
ORDER BY count DESC
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM shared.organizations
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
DATE_TRUNC('month', created_at) as month,
|
||||
COUNT(*) as count
|
||||
FROM shared.organizations
|
||||
WHERE created_at > NOW() - INTERVAL '6 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
ORDER BY month DESC
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
DATE_TRUNC('month', created_at) as month,
|
||||
COUNT(*) as count
|
||||
FROM shared.users
|
||||
WHERE created_at > NOW() - INTERVAL '6 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
ORDER BY month DESC
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COUNT(*) FILTER (WHERE status = 'success') as successful,
|
||||
ROUND(AVG(response_time_ms)) as avg_response_ms
|
||||
FROM shared.ai_recommendation_log
|
||||
WHERE requested_at > NOW() - INTERVAL '30 days'
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT COUNT(DISTINCT user_id) as count
|
||||
FROM shared.login_history
|
||||
WHERE logged_in_at > NOW() - INTERVAL '30 days'
|
||||
`),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalUsers: parseInt(userStats[0]?.total_users || '0'),
|
||||
superadminCount: parseInt(userStats[0]?.superadmin_count || '0'),
|
||||
platformOwnerCount: parseInt(userStats[0]?.platform_owner_count || '0'),
|
||||
activeUsers30d: parseInt(activeUsers30d[0]?.count || '0'),
|
||||
totalOrganizations: parseInt(orgStats[0]?.total_organizations || '0'),
|
||||
activeOrganizations: parseInt(orgStats[0]?.active_count || '0'),
|
||||
archivedOrganizations: parseInt(orgStats[0]?.archived_count || '0'),
|
||||
suspendedOrganizations: parseInt(orgStats[0]?.suspended_count || '0'),
|
||||
trialOrganizations: parseInt(orgStats[0]?.trial_count || '0'),
|
||||
planBreakdown: planBreakdown.map((r: any) => ({
|
||||
plan: r.plan_level,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
statusBreakdown: statusBreakdown.map((r: any) => ({
|
||||
status: r.status,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
newTenantsPerMonth: newTenantsPerMonth.map((r: any) => ({
|
||||
month: r.month,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
newUsersPerMonth: newUsersPerMonth.map((r: any) => ({
|
||||
month: r.month,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
aiRequestsLast30d: parseInt(aiStats[0]?.total_requests || '0'),
|
||||
aiSuccessfulLast30d: parseInt(aiStats[0]?.successful || '0'),
|
||||
aiAvgResponseMs: parseInt(aiStats[0]?.avg_response_ms || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed analytics for a specific tenant/organization.
|
||||
*/
|
||||
async getTenantDetail(orgId: string) {
|
||||
const [orgInfo, loginStats, weeklyLogins, monthlyLogins, aiCount, memberCount] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT o.*, (SELECT MAX(logged_in_at) FROM shared.login_history WHERE organization_id = o.id) as last_login
|
||||
FROM shared.organizations o WHERE o.id = $1`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '7 days') as logins_this_week,
|
||||
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as logins_this_month,
|
||||
COUNT(DISTINCT user_id) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d
|
||||
FROM shared.login_history WHERE organization_id = $1`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT
|
||||
DATE_TRUNC('week', logged_in_at) as week,
|
||||
COUNT(*) as count
|
||||
FROM shared.login_history
|
||||
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '4 weeks'
|
||||
GROUP BY DATE_TRUNC('week', logged_in_at)
|
||||
ORDER BY week DESC`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT
|
||||
DATE_TRUNC('month', logged_in_at) as month,
|
||||
COUNT(*) as count
|
||||
FROM shared.login_history
|
||||
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '6 months'
|
||||
GROUP BY DATE_TRUNC('month', logged_in_at)
|
||||
ORDER BY month DESC`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM shared.ai_recommendation_log
|
||||
WHERE organization_id = $1 AND requested_at > NOW() - INTERVAL '30 days'`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT COUNT(*) as count FROM shared.user_organizations WHERE organization_id = $1 AND is_active = true`,
|
||||
[orgId],
|
||||
),
|
||||
]);
|
||||
|
||||
const org = orgInfo[0];
|
||||
if (!org) return null;
|
||||
|
||||
// Cross-schema queries for tenant financial data
|
||||
let cashOnHand = 0;
|
||||
let hasBudget = false;
|
||||
let recentTransactions = 0;
|
||||
|
||||
try {
|
||||
const cashResult = await this.dataSource.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM "${org.schema_name}".accounts a
|
||||
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN "${org.schema_name}".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.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
cashOnHand = parseFloat(cashResult[0]?.total || '0');
|
||||
|
||||
const budgetResult = await this.dataSource.query(
|
||||
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
|
||||
[new Date().getFullYear()],
|
||||
);
|
||||
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
|
||||
|
||||
const txnResult = await this.dataSource.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "${org.schema_name}".journal_entries
|
||||
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
|
||||
`);
|
||||
recentTransactions = parseInt(txnResult[0]?.count || '0');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to query tenant schema ${org.schema_name}: ${err.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
organization: org,
|
||||
lastLogin: org.last_login,
|
||||
loginsThisWeek: parseInt(loginStats[0]?.logins_this_week || '0'),
|
||||
loginsThisMonth: parseInt(loginStats[0]?.logins_this_month || '0'),
|
||||
activeUsers30d: parseInt(loginStats[0]?.active_users_30d || '0'),
|
||||
weeklyLogins: weeklyLogins.map((r: any) => ({
|
||||
week: r.week,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
monthlyLogins: monthlyLogins.map((r: any) => ({
|
||||
month: r.month,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
aiRecommendations30d: parseInt(aiCount[0]?.count || '0'),
|
||||
memberCount: parseInt(memberCount[0]?.count || '0'),
|
||||
cashOnHand,
|
||||
hasBudget,
|
||||
recentTransactions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All tenants with health scores for the Health & Adoption tab.
|
||||
*
|
||||
* Health Score (0-100):
|
||||
* Active users 30d ≥ 1 → 25pts
|
||||
* Has current-year budget → 25pts
|
||||
* Journal entries 30d ≥ 1 → 25pts
|
||||
* 2+ active members → 15pts
|
||||
* AI usage 30d ≥ 1 → 10pts
|
||||
*/
|
||||
async getAllTenantsHealth() {
|
||||
const orgs = await this.dataSource.query(`
|
||||
SELECT
|
||||
o.id, o.name, o.schema_name, o.status, o.plan_level, o.created_at,
|
||||
o.payment_date, o.renewal_date,
|
||||
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id AND is_active = true) as member_count,
|
||||
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_login,
|
||||
(SELECT COUNT(DISTINCT lh.user_id) FROM shared.login_history lh WHERE lh.organization_id = o.id AND lh.logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d,
|
||||
(SELECT COUNT(*) FROM shared.ai_recommendation_log ar WHERE ar.organization_id = o.id AND ar.requested_at > NOW() - INTERVAL '30 days') as ai_usage_30d
|
||||
FROM shared.organizations o
|
||||
WHERE o.status != 'archived'
|
||||
ORDER BY o.name
|
||||
`);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const results = [];
|
||||
|
||||
for (const org of orgs) {
|
||||
let cashOnHand = 0;
|
||||
let hasBudget = false;
|
||||
let journalEntries30d = 0;
|
||||
|
||||
try {
|
||||
const cashResult = await this.dataSource.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM "${org.schema_name}".accounts a
|
||||
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN "${org.schema_name}".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.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
cashOnHand = parseFloat(cashResult[0]?.total || '0');
|
||||
|
||||
const budgetResult = await this.dataSource.query(
|
||||
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
|
||||
[currentYear],
|
||||
);
|
||||
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
|
||||
|
||||
const jeResult = await this.dataSource.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "${org.schema_name}".journal_entries
|
||||
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
|
||||
`);
|
||||
journalEntries30d = parseInt(jeResult[0]?.count || '0');
|
||||
} catch (err) {
|
||||
// Schema may not exist yet (new tenant)
|
||||
this.logger.warn(`Health check skip for ${org.schema_name}: ${err.message}`);
|
||||
}
|
||||
|
||||
// Calculate health score
|
||||
const activeUsers = parseInt(org.active_users_30d) || 0;
|
||||
const memberCount = parseInt(org.member_count) || 0;
|
||||
const aiUsage = parseInt(org.ai_usage_30d) || 0;
|
||||
|
||||
let healthScore = 0;
|
||||
if (activeUsers >= 1) healthScore += 25;
|
||||
if (hasBudget) healthScore += 25;
|
||||
if (journalEntries30d >= 1) healthScore += 25;
|
||||
if (memberCount >= 2) healthScore += 15;
|
||||
if (aiUsage >= 1) healthScore += 10;
|
||||
|
||||
results.push({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
schemaName: org.schema_name,
|
||||
status: org.status,
|
||||
planLevel: org.plan_level,
|
||||
createdAt: org.created_at,
|
||||
paymentDate: org.payment_date,
|
||||
renewalDate: org.renewal_date,
|
||||
memberCount,
|
||||
lastLogin: org.last_login,
|
||||
activeUsers30d: activeUsers,
|
||||
aiUsage30d: aiUsage,
|
||||
cashOnHand,
|
||||
hasBudget,
|
||||
journalEntries30d,
|
||||
healthScore,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { OrganizationsService } from '../organizations/organizations.service';
|
||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@ApiTags('admin')
|
||||
@@ -11,8 +13,10 @@ import * as bcrypt from 'bcryptjs';
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AdminController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private usersService: UsersService,
|
||||
private orgService: OrganizationsService,
|
||||
private analyticsService: AdminAnalyticsService,
|
||||
) {}
|
||||
|
||||
private async requireSuperadmin(req: any) {
|
||||
@@ -22,25 +26,93 @@ export class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Platform Metrics ──
|
||||
|
||||
@Get('metrics')
|
||||
async getPlatformMetrics(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.analyticsService.getPlatformMetrics();
|
||||
}
|
||||
|
||||
// ── Users ──
|
||||
|
||||
@Get('users')
|
||||
async listUsers(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
const users = await this.usersService.findAllUsers();
|
||||
return users.map(u => ({
|
||||
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
|
||||
isSuperadmin: u.isSuperadmin, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
||||
isSuperadmin: u.isSuperadmin, isPlatformOwner: u.isPlatformOwner || false,
|
||||
lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
||||
organizations: u.userOrganizations?.map(uo => ({
|
||||
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
||||
})) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Organizations ──
|
||||
|
||||
@Get('organizations')
|
||||
async listOrganizations(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.usersService.findAllOrganizations();
|
||||
}
|
||||
|
||||
@Get('organizations/:id/detail')
|
||||
async getTenantDetail(@Req() req: any, @Param('id') id: string) {
|
||||
await this.requireSuperadmin(req);
|
||||
const detail = await this.analyticsService.getTenantDetail(id);
|
||||
if (!detail) {
|
||||
throw new BadRequestException('Organization not found');
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Put('organizations/:id/subscription')
|
||||
async updateSubscription(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const org = await this.orgService.updateSubscription(id, body);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
@Put('organizations/:id/status')
|
||||
async updateOrgStatus(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
const org = await this.orgService.updateStatus(id, body.status);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
// ── Plan Level ──
|
||||
|
||||
@Put('organizations/:id/plan')
|
||||
async updateOrgPlan(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { planLevel: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const validPlans = ['standard', 'premium', 'enterprise'];
|
||||
if (!validPlans.includes(body.planLevel)) {
|
||||
throw new BadRequestException(`Invalid plan. Must be one of: ${validPlans.join(', ')}`);
|
||||
}
|
||||
const org = await this.orgService.updatePlanLevel(id, body.planLevel);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
// ── Superadmin Toggle ──
|
||||
|
||||
@Post('users/:id/superadmin')
|
||||
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
||||
await this.requireSuperadmin(req);
|
||||
@@ -48,6 +120,25 @@ export class AdminController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── User Impersonation ──
|
||||
|
||||
@Post('impersonate/:userId')
|
||||
async impersonateUser(@Req() req: any, @Param('userId') userId: string) {
|
||||
await this.requireSuperadmin(req);
|
||||
const adminUserId = req.user.userId || req.user.sub;
|
||||
return this.authService.impersonateUser(adminUserId, userId);
|
||||
}
|
||||
|
||||
// ── Tenant Health ──
|
||||
|
||||
@Get('tenants-health')
|
||||
async getTenantsHealth(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.analyticsService.getAllTenantsHealth();
|
||||
}
|
||||
|
||||
// ── Create Tenant ──
|
||||
|
||||
@Post('tenants')
|
||||
async createTenant(@Req() req: any, @Body() body: {
|
||||
orgName: string;
|
||||
@@ -105,19 +196,4 @@ export class AdminController {
|
||||
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
@Put('organizations/:id/status')
|
||||
async updateOrgStatus(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
const org = await this.orgService.updateStatus(id, body.status);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -13,6 +14,7 @@ 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';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -29,7 +31,9 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@UseGuards(AuthGuard('local'))
|
||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||
return this.authService.login(req.user);
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
const ua = req.headers['user-agent'];
|
||||
return this.authService.login(req.user, ip, ua);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@@ -40,11 +44,24 @@ export class AuthController {
|
||||
return this.authService.getProfile(req.user.sub);
|
||||
}
|
||||
|
||||
@Patch('intro-seen')
|
||||
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AllowViewer()
|
||||
async markIntroSeen(@Request() req: any) {
|
||||
await this.authService.markIntroSeen(req.user.sub);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('switch-org')
|
||||
@ApiOperation({ summary: 'Switch active organization' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AllowViewer()
|
||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId);
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
const ua = req.headers['user-agent'];
|
||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
@@ -25,7 +26,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController, AdminController],
|
||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
||||
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -2,8 +2,11 @@ import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { DataSource } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
@@ -14,6 +17,7 @@ export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
@@ -47,7 +51,7 @@ export class AuthService {
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(user: User) {
|
||||
async login(user: User, ipAddress?: string, userAgent?: string) {
|
||||
await this.usersService.updateLastLogin(user.id);
|
||||
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
|
||||
const u = fullUser || user;
|
||||
@@ -65,6 +69,9 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Record login in history (org_id is null at initial login)
|
||||
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
|
||||
|
||||
return this.generateTokenResponse(u);
|
||||
}
|
||||
|
||||
@@ -86,7 +93,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async switchOrganization(userId: string, organizationId: string) {
|
||||
async switchOrganization(userId: string, organizationId: string, ipAddress?: string, userAgent?: string) {
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
@@ -99,6 +106,14 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Not a member of this organization');
|
||||
}
|
||||
|
||||
// Block access to suspended/archived organizations
|
||||
const orgStatus = membership.organization?.status;
|
||||
if (orgStatus && ['suspended', 'archived'].includes(orgStatus)) {
|
||||
throw new ForbiddenException(
|
||||
`This organization has been ${orgStatus}. Please contact your administrator.`,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
@@ -107,18 +122,47 @@ export class AuthService {
|
||||
role: membership.role,
|
||||
};
|
||||
|
||||
// Record org switch in login history
|
||||
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
organization: {
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
role: membership.role,
|
||||
settings: membership.organization.settings || {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private generateTokenResponse(user: User) {
|
||||
const orgs = user.userOrganizations || [];
|
||||
async markIntroSeen(userId: string): Promise<void> {
|
||||
await this.usersService.markIntroSeen(userId);
|
||||
}
|
||||
|
||||
private async recordLoginHistory(
|
||||
userId: string,
|
||||
organizationId: string | null,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
try {
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.login_history (user_id, organization_id, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[userId, organizationId, ipAddress || null, userAgent || null],
|
||||
);
|
||||
} catch (err) {
|
||||
// Non-critical — don't let login history failure block auth
|
||||
}
|
||||
}
|
||||
|
||||
private generateTokenResponse(user: User, impersonatedBy?: string) {
|
||||
const allOrgs = user.userOrganizations || [];
|
||||
// Filter out suspended/archived organizations
|
||||
const orgs = allOrgs.filter(
|
||||
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
|
||||
);
|
||||
const defaultOrg = orgs[0];
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
@@ -127,6 +171,10 @@ export class AuthService {
|
||||
isSuperadmin: user.isSuperadmin || false,
|
||||
};
|
||||
|
||||
if (impersonatedBy) {
|
||||
payload.impersonatedBy = impersonatedBy;
|
||||
}
|
||||
|
||||
if (defaultOrg) {
|
||||
payload.orgId = defaultOrg.organizationId;
|
||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
||||
@@ -141,13 +189,27 @@ export class AuthService {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isSuperadmin: user.isSuperadmin || false,
|
||||
isPlatformOwner: user.isPlatformOwner || false,
|
||||
hasSeenIntro: user.hasSeenIntro || false,
|
||||
},
|
||||
organizations: orgs.map((uo) => ({
|
||||
id: uo.organizationId,
|
||||
name: uo.organization?.name,
|
||||
schemaName: uo.organization?.schemaName,
|
||||
status: uo.organization?.status,
|
||||
role: uo.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async impersonateUser(adminUserId: string, targetUserId: string) {
|
||||
const targetUser = await this.usersService.findByIdWithOrgs(targetUserId);
|
||||
if (!targetUser) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
if (targetUser.isSuperadmin) {
|
||||
throw new ForbiddenException('Cannot impersonate another superadmin');
|
||||
}
|
||||
return this.generateTokenResponse(targetUser, adminUserId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
orgSchema: payload.orgSchema,
|
||||
role: payload.role,
|
||||
isSuperadmin: payload.isSuperadmin || false,
|
||||
impersonatedBy: payload.impersonatedBy || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
import { HealthScoresService } from './health-scores.service';
|
||||
|
||||
@ApiTags('health-scores')
|
||||
@Controller('health-scores')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class HealthScoresController {
|
||||
constructor(private service: HealthScoresService) {}
|
||||
|
||||
@Get('latest')
|
||||
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||
getLatest(@Req() req: any) {
|
||||
const schema = req.user?.orgSchema;
|
||||
return this.service.getLatestScores(schema);
|
||||
}
|
||||
|
||||
@Post('calculate')
|
||||
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' })
|
||||
@AllowViewer()
|
||||
async calculate(@Req() req: any) {
|
||||
const schema = req.user?.orgSchema;
|
||||
const [operating, reserve] = await Promise.all([
|
||||
this.service.calculateScore(schema, 'operating'),
|
||||
this.service.calculateScore(schema, 'reserve'),
|
||||
]);
|
||||
return { operating, reserve };
|
||||
}
|
||||
|
||||
@Post('calculate/operating')
|
||||
@ApiOperation({ summary: 'Recalculate operating fund health score only' })
|
||||
@AllowViewer()
|
||||
async calculateOperating(@Req() req: any) {
|
||||
const schema = req.user?.orgSchema;
|
||||
const operating = await this.service.calculateScore(schema, 'operating');
|
||||
return { operating };
|
||||
}
|
||||
|
||||
@Post('calculate/reserve')
|
||||
@ApiOperation({ summary: 'Recalculate reserve fund health score only' })
|
||||
@AllowViewer()
|
||||
async calculateReserve(@Req() req: any) {
|
||||
const schema = req.user?.orgSchema;
|
||||
const reserve = await this.service.calculateScore(schema, 'reserve');
|
||||
return { reserve };
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/health-scores/health-scores.module.ts
Normal file
10
backend/src/modules/health-scores/health-scores.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthScoresController } from './health-scores.controller';
|
||||
import { HealthScoresService } from './health-scores.service';
|
||||
import { HealthScoresScheduler } from './health-scores.scheduler';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthScoresController],
|
||||
providers: [HealthScoresService, HealthScoresScheduler],
|
||||
})
|
||||
export class HealthScoresModule {}
|
||||
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal file
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { HealthScoresService } from './health-scores.service';
|
||||
|
||||
@Injectable()
|
||||
export class HealthScoresScheduler {
|
||||
private readonly logger = new Logger(HealthScoresScheduler.name);
|
||||
|
||||
constructor(
|
||||
private dataSource: DataSource,
|
||||
private healthScoresService: HealthScoresService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Run daily at 2:00 AM — calculate health scores for all active tenants.
|
||||
* Uses DataSource directly to list tenants (no HTTP request context needed).
|
||||
*/
|
||||
@Cron('0 2 * * *')
|
||||
async calculateAllTenantScores() {
|
||||
this.logger.log('Starting daily health score calculation for all tenants...');
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const orgs = await this.dataSource.query(
|
||||
`SELECT id, name, schema_name FROM shared.organizations WHERE status = 'active'`,
|
||||
);
|
||||
|
||||
this.logger.log(`Found ${orgs.length} active tenants`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
await this.healthScoresService.calculateScore(org.schema_name, 'operating');
|
||||
await this.healthScoresService.calculateScore(org.schema_name, 'reserve');
|
||||
successCount++;
|
||||
this.logger.log(`Health scores calculated for ${org.name} (${org.schema_name})`);
|
||||
} catch (err: any) {
|
||||
errorCount++;
|
||||
this.logger.error(`Failed to calculate health scores for ${org.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logger.log(
|
||||
`Daily health scores complete: ${successCount} success, ${errorCount} errors (${elapsed}ms)`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Health score scheduler failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
1192
backend/src/modules/health-scores/health-scores.service.ts
Normal file
1192
backend/src/modules/health-scores/health-scores.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
import { InvestmentPlanningService } from './investment-planning.service';
|
||||
|
||||
@ApiTags('investment-planning')
|
||||
@Controller('investment-planning')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InvestmentPlanningController {
|
||||
constructor(private service: InvestmentPlanningService) {}
|
||||
|
||||
@Get('snapshot')
|
||||
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
||||
getSnapshot() {
|
||||
return this.service.getFinancialSnapshot();
|
||||
}
|
||||
|
||||
@Get('cd-rates')
|
||||
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
||||
getCdRates() {
|
||||
return this.service.getCdRates();
|
||||
}
|
||||
|
||||
@Get('market-rates')
|
||||
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
||||
getMarketRates() {
|
||||
return this.service.getMarketRates();
|
||||
}
|
||||
|
||||
@Get('saved-recommendation')
|
||||
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
||||
getSavedRecommendation() {
|
||||
return this.service.getSavedRecommendation();
|
||||
}
|
||||
|
||||
@Post('recommendations')
|
||||
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
||||
@AllowViewer()
|
||||
getRecommendations(@Req() req: any) {
|
||||
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InvestmentPlanningController } from './investment-planning.controller';
|
||||
import { InvestmentPlanningService } from './investment-planning.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InvestmentPlanningController],
|
||||
providers: [InvestmentPlanningService],
|
||||
})
|
||||
export class InvestmentPlanningModule {}
|
||||
@@ -0,0 +1,989 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
// ── Interfaces ──
|
||||
|
||||
export interface AccountBalance {
|
||||
id: string;
|
||||
account_number: string;
|
||||
name: string;
|
||||
account_type: string;
|
||||
fund_type: string;
|
||||
interest_rate: string | null;
|
||||
balance: string;
|
||||
}
|
||||
|
||||
export interface InvestmentAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
investment_type: string;
|
||||
fund_type: string;
|
||||
principal: string;
|
||||
interest_rate: string;
|
||||
maturity_date: string | null;
|
||||
purchase_date: string | null;
|
||||
current_value: string;
|
||||
}
|
||||
|
||||
export interface MarketRate {
|
||||
bank_name: string;
|
||||
apy: string;
|
||||
min_deposit: string | null;
|
||||
term: string;
|
||||
term_months: number | null;
|
||||
rate_type: string;
|
||||
fetched_at: string;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
summary: string;
|
||||
details: string;
|
||||
fund_type: 'operating' | 'reserve' | 'both';
|
||||
suggested_amount?: number;
|
||||
suggested_term?: string;
|
||||
suggested_rate?: number;
|
||||
bank_name?: string;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
export interface AIResponse {
|
||||
recommendations: Recommendation[];
|
||||
overall_assessment: string;
|
||||
risk_notes: string[];
|
||||
}
|
||||
|
||||
export interface SavedRecommendation {
|
||||
id: string;
|
||||
recommendations: Recommendation[];
|
||||
overall_assessment: string;
|
||||
risk_notes: string[];
|
||||
response_time_ms: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InvestmentPlanningService {
|
||||
private readonly logger = new Logger(InvestmentPlanningService.name);
|
||||
private debugEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private tenant: TenantService,
|
||||
private configService: ConfigService,
|
||||
private dataSource: DataSource,
|
||||
) {
|
||||
// Toggle with AI_DEBUG=true in .env for detailed prompt/response logging
|
||||
this.debugEnabled = this.configService.get<string>('AI_DEBUG') === 'true';
|
||||
if (this.debugEnabled) {
|
||||
this.logger.warn('AI DEBUG MODE ENABLED — prompts and responses will be logged');
|
||||
}
|
||||
}
|
||||
|
||||
private debug(label: string, data: any) {
|
||||
if (!this.debugEnabled) return;
|
||||
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
// Truncate very long output to keep logs manageable
|
||||
const truncated = text.length > 5000 ? text.slice(0, 5000) + `\n... [truncated, ${text.length} total chars]` : text;
|
||||
this.logger.log(`[AI_DEBUG] ${label}:\n${truncated}`);
|
||||
}
|
||||
|
||||
// ── Public API Methods ──
|
||||
|
||||
/**
|
||||
* Build a comprehensive financial snapshot for the investment planning page.
|
||||
* All financial data is tenant-scoped via TenantService.
|
||||
*/
|
||||
async getFinancialSnapshot() {
|
||||
const [
|
||||
accountBalances,
|
||||
investmentAccounts,
|
||||
budgets,
|
||||
projects,
|
||||
cashFlowContext,
|
||||
] = await Promise.all([
|
||||
this.getAccountBalances(),
|
||||
this.getInvestmentAccounts(),
|
||||
this.getBudgets(),
|
||||
this.getProjects(),
|
||||
this.getCashFlowContext(),
|
||||
]);
|
||||
|
||||
// Compute summary totals
|
||||
const operatingCash = accountBalances
|
||||
.filter((a) => a.fund_type === 'operating' && a.account_type === 'asset')
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||
|
||||
const reserveCash = accountBalances
|
||||
.filter((a) => a.fund_type === 'reserve' && a.account_type === 'asset')
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||
|
||||
const operatingInvestments = investmentAccounts
|
||||
.filter((i) => i.fund_type === 'operating')
|
||||
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
|
||||
const reserveInvestments = investmentAccounts
|
||||
.filter((i) => i.fund_type === 'reserve')
|
||||
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
operating_cash: operatingCash,
|
||||
reserve_cash: reserveCash,
|
||||
operating_investments: operatingInvestments,
|
||||
reserve_investments: reserveInvestments,
|
||||
total_operating: operatingCash + operatingInvestments,
|
||||
total_reserve: reserveCash + reserveInvestments,
|
||||
total_all: operatingCash + reserveCash + operatingInvestments + reserveInvestments,
|
||||
},
|
||||
account_balances: accountBalances,
|
||||
investment_accounts: investmentAccounts,
|
||||
budgets,
|
||||
projects,
|
||||
cash_flow_context: cashFlowContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest market rates from the shared schema (cross-tenant market data).
|
||||
* Returns rates grouped by type, each showing only the most recent fetch batch.
|
||||
*/
|
||||
async getMarketRates(): Promise<{ cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] }> {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
try {
|
||||
await queryRunner.connect();
|
||||
|
||||
// For each rate type, get the latest batch (same fetched_at timestamp)
|
||||
const fetchLatest = async (rateType: string): Promise<MarketRate[]> => {
|
||||
return queryRunner.query(
|
||||
`SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
|
||||
FROM shared.cd_rates
|
||||
WHERE rate_type = $1
|
||||
AND fetched_at = (
|
||||
SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type = $1
|
||||
)
|
||||
ORDER BY apy DESC
|
||||
LIMIT 25`,
|
||||
[rateType],
|
||||
);
|
||||
};
|
||||
|
||||
const [cd, moneyMarket, highYieldSavings] = await Promise.all([
|
||||
fetchLatest('cd'),
|
||||
fetchLatest('money_market'),
|
||||
fetchLatest('high_yield_savings'),
|
||||
]);
|
||||
|
||||
return {
|
||||
cd,
|
||||
money_market: moneyMarket,
|
||||
high_yield_savings: highYieldSavings,
|
||||
};
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible: get only CD rates.
|
||||
*/
|
||||
async getCdRates(): Promise<MarketRate[]> {
|
||||
const rates = await this.getMarketRates();
|
||||
return rates.cd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest saved AI recommendation for this tenant.
|
||||
*/
|
||||
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
||||
try {
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
||||
response_time_ms, created_at
|
||||
FROM ai_recommendations
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
if (!rows || rows.length === 0) return null;
|
||||
|
||||
const row = rows[0];
|
||||
const recData = row.recommendations_json || {};
|
||||
return {
|
||||
id: row.id,
|
||||
recommendations: recData.recommendations || [],
|
||||
overall_assessment: row.overall_assessment || recData.overall_assessment || '',
|
||||
risk_notes: row.risk_notes || recData.risk_notes || [],
|
||||
response_time_ms: row.response_time_ms || 0,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
} catch (err: any) {
|
||||
// Table might not exist yet (pre-migration tenants)
|
||||
this.logger.warn(`Could not load saved recommendations: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save AI recommendation result to tenant schema.
|
||||
*/
|
||||
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
|
||||
try {
|
||||
await this.tenant.query(
|
||||
`INSERT INTO ai_recommendations
|
||||
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
JSON.stringify(aiResponse),
|
||||
aiResponse.overall_assessment || '',
|
||||
JSON.stringify(aiResponse.risk_notes || []),
|
||||
userId || null,
|
||||
elapsed,
|
||||
],
|
||||
);
|
||||
} catch (err: any) {
|
||||
// Non-critical — don't let storage failure break recommendations
|
||||
this.logger.warn(`Could not save recommendation: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrate the AI recommendation flow:
|
||||
* 1. Gather all financial data (tenant-scoped)
|
||||
* 2. Fetch all market rates (shared schema)
|
||||
* 3. Build the prompt with all context
|
||||
* 4. Call the AI API
|
||||
* 5. Parse and return structured recommendations
|
||||
* 6. Save to tenant storage for future retrieval
|
||||
*/
|
||||
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
|
||||
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
|
||||
const startTime = Date.now();
|
||||
|
||||
const [snapshot, allRates, monthlyForecast] = await Promise.all([
|
||||
this.getFinancialSnapshot(),
|
||||
this.getMarketRates(),
|
||||
this.getMonthlyForecast(),
|
||||
]);
|
||||
|
||||
this.debug('snapshot_summary', {
|
||||
operating_cash: snapshot.summary.operating_cash,
|
||||
reserve_cash: snapshot.summary.reserve_cash,
|
||||
total_all: snapshot.summary.total_all,
|
||||
investment_accounts: snapshot.investment_accounts.length,
|
||||
budgets: snapshot.budgets.length,
|
||||
projects: snapshot.projects.length,
|
||||
cd_rates: allRates.cd.length,
|
||||
money_market_rates: allRates.money_market.length,
|
||||
savings_rates: allRates.high_yield_savings.length,
|
||||
forecast_months: monthlyForecast.datapoints.length,
|
||||
});
|
||||
|
||||
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
|
||||
const aiResponse = await this.callAI(messages);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
this.debug('final_response', {
|
||||
recommendation_count: aiResponse.recommendations.length,
|
||||
has_assessment: !!aiResponse.overall_assessment,
|
||||
risk_notes_count: aiResponse.risk_notes?.length || 0,
|
||||
});
|
||||
|
||||
// Save recommendation to tenant storage (fire-and-forget)
|
||||
this.saveRecommendation(aiResponse, userId, elapsed).catch(() => {});
|
||||
|
||||
// Log AI usage to shared.ai_recommendation_log (fire-and-forget)
|
||||
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
|
||||
|
||||
return aiResponse;
|
||||
}
|
||||
|
||||
private async logAIUsage(userId: string | undefined, orgId: string | undefined, response: AIResponse, elapsed: number) {
|
||||
try {
|
||||
const schema = this.tenant.getSchema();
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.ai_recommendation_log
|
||||
(tenant_schema, organization_id, user_id, recommendation_count, response_time_ms, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
schema || null,
|
||||
orgId || null,
|
||||
userId || null,
|
||||
response.recommendations?.length || 0,
|
||||
elapsed,
|
||||
response.recommendations?.length > 0 ? 'success' : 'empty',
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
// Non-critical — don't let logging failure break recommendations
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private: Tenant-Scoped Data Queries ──
|
||||
|
||||
private async getAccountBalances(): Promise<AccountBalance[]> {
|
||||
return this.tenant.query(`
|
||||
SELECT
|
||||
a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
a.interest_rate,
|
||||
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
|
||||
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, a.interest_rate
|
||||
ORDER BY a.account_number
|
||||
`);
|
||||
}
|
||||
|
||||
private async getInvestmentAccounts(): Promise<InvestmentAccount[]> {
|
||||
return this.tenant.query(`
|
||||
SELECT
|
||||
id, name, institution, investment_type, fund_type,
|
||||
principal, interest_rate, maturity_date, purchase_date, current_value
|
||||
FROM investment_accounts
|
||||
WHERE is_active = true
|
||||
ORDER BY maturity_date NULLS LAST
|
||||
`);
|
||||
}
|
||||
|
||||
private async getBudgets() {
|
||||
const year = new Date().getFullYear();
|
||||
return this.tenant.query(
|
||||
`SELECT
|
||||
b.fund_type, a.account_type, a.name, a.account_number,
|
||||
(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 annual_total
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1
|
||||
ORDER BY a.account_type, a.account_number`,
|
||||
[year],
|
||||
);
|
||||
}
|
||||
|
||||
private async getProjects() {
|
||||
return this.tenant.query(`
|
||||
SELECT
|
||||
name, estimated_cost, target_year, target_month, fund_source,
|
||||
status, priority, current_fund_balance, funded_percentage
|
||||
FROM projects
|
||||
WHERE is_active = true
|
||||
AND status IN ('planned', 'approved', 'in_progress')
|
||||
ORDER BY target_year, target_month NULLS LAST, priority
|
||||
`);
|
||||
}
|
||||
|
||||
private async getCashFlowContext() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
// Current operating cash position
|
||||
const opCashResult = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
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
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
|
||||
// Current reserve cash position
|
||||
const resCashResult = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
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
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
|
||||
// Annual budget summary by fund_type and account_type
|
||||
const budgetSummary = await this.tenant.query(
|
||||
`SELECT
|
||||
b.fund_type, a.account_type,
|
||||
SUM(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 annual_total
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1
|
||||
GROUP BY b.fund_type, a.account_type`,
|
||||
[year],
|
||||
);
|
||||
|
||||
// Assessment income (monthly recurring revenue)
|
||||
const assessmentIncome = await this.tenant.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(ag.regular_assessment * (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active')), 0) as monthly_assessment_income
|
||||
FROM assessment_groups ag
|
||||
WHERE ag.is_active = true
|
||||
`);
|
||||
|
||||
return {
|
||||
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'),
|
||||
current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
|
||||
budget_summary: budgetSummary,
|
||||
monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 12-month forward cash flow forecast for the AI.
|
||||
*/
|
||||
private async getMonthlyForecast() {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
const monthNames = ['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'];
|
||||
const forecastMonths = 12;
|
||||
|
||||
// ── 1) Current cash positions ──
|
||||
const [opCashRows, resCashRows, opInvRows, resInvRows] = await Promise.all([
|
||||
this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
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
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`),
|
||||
this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
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
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`),
|
||||
this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total
|
||||
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
|
||||
`),
|
||||
this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total
|
||||
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||
`),
|
||||
]);
|
||||
|
||||
let runOpCash = parseFloat(opCashRows[0]?.total || '0');
|
||||
let runResCash = parseFloat(resCashRows[0]?.total || '0');
|
||||
let runOpInv = parseFloat(opInvRows[0]?.total || '0');
|
||||
let runResInv = parseFloat(resInvRows[0]?.total || '0');
|
||||
|
||||
// ── 2) Assessment income schedule (regular + special assessments) ──
|
||||
const assessmentGroups = await this.tenant.query(`
|
||||
SELECT ag.frequency, ag.regular_assessment, ag.special_assessment,
|
||||
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as unit_count
|
||||
FROM assessment_groups ag WHERE ag.is_active = true
|
||||
`);
|
||||
|
||||
const getAssessmentIncome = (month: number): { operating: number; reserve: number } => {
|
||||
let operating = 0;
|
||||
let reserve = 0;
|
||||
for (const g of assessmentGroups) {
|
||||
const units = parseInt(g.unit_count) || 0;
|
||||
const regular = parseFloat(g.regular_assessment) || 0;
|
||||
const special = parseFloat(g.special_assessment) || 0;
|
||||
const freq = g.frequency || 'monthly';
|
||||
let applies = false;
|
||||
if (freq === 'monthly') applies = true;
|
||||
else if (freq === 'quarterly') applies = [1,4,7,10].includes(month);
|
||||
else if (freq === 'annual') applies = month === 1;
|
||||
if (applies) {
|
||||
operating += regular * units;
|
||||
reserve += special * units;
|
||||
}
|
||||
}
|
||||
return { operating, reserve };
|
||||
};
|
||||
|
||||
// ── 3) Monthly budget data (income & expenses by month) ──
|
||||
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||
for (const yr of [currentYear, currentYear + 1]) {
|
||||
const budgetRows = await this.tenant.query(
|
||||
`SELECT b.fund_type, a.account_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
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1`, [yr],
|
||||
);
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const key = `${yr}-${m + 1}`;
|
||||
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
for (const row of budgetRows) {
|
||||
const amt = parseFloat(row[monthNames[m]]) || 0;
|
||||
if (amt === 0) continue;
|
||||
const isOp = row.fund_type === 'operating';
|
||||
if (row.account_type === 'income') {
|
||||
if (isOp) budgetsByYearMonth[key].opIncome += amt;
|
||||
else budgetsByYearMonth[key].resIncome += amt;
|
||||
} else if (row.account_type === 'expense') {
|
||||
if (isOp) budgetsByYearMonth[key].opExpense += amt;
|
||||
else budgetsByYearMonth[key].resExpense += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4) Investment maturities ──
|
||||
const maturities = await this.tenant.query(`
|
||||
SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date
|
||||
FROM investment_accounts
|
||||
WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
||||
`);
|
||||
const maturityIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const inv of maturities) {
|
||||
const d = new Date(inv.maturity_date);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
|
||||
const val = parseFloat(inv.current_value) || 0;
|
||||
const rate = parseFloat(inv.interest_rate) || 0;
|
||||
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||
const matDate = new Date(inv.maturity_date);
|
||||
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||
const interestEarned = val * (rate / 100) * (daysHeld / 365);
|
||||
const maturityTotal = val + interestEarned;
|
||||
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
|
||||
else maturityIndex[key].reserve += maturityTotal;
|
||||
}
|
||||
|
||||
// ── 5) Capital project expenses ──
|
||||
const projectExpenses = await this.tenant.query(`
|
||||
SELECT estimated_cost, target_year, target_month, fund_source
|
||||
FROM projects
|
||||
WHERE is_active = true AND status IN ('planned', 'in_progress')
|
||||
AND target_year IS NOT NULL AND estimated_cost > 0
|
||||
`);
|
||||
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const p of projectExpenses) {
|
||||
const yr = parseInt(p.target_year);
|
||||
const mo = parseInt(p.target_month) || 6;
|
||||
const key = `${yr}-${mo}`;
|
||||
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||
const cost = parseFloat(p.estimated_cost) || 0;
|
||||
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||
else projectIndex[key].reserve += cost;
|
||||
}
|
||||
|
||||
// ── 6) Build 12-month forward datapoints ──
|
||||
const datapoints: any[] = [];
|
||||
for (let i = 0; i < forecastMonths; i++) {
|
||||
const year = currentYear + Math.floor((currentMonth - 1 + i) / 12);
|
||||
const month = ((currentMonth - 1 + i) % 12) + 1;
|
||||
const key = `${year}-${month}`;
|
||||
const label = `${monthLabels[month - 1]} ${year}`;
|
||||
|
||||
const assessments = getAssessmentIncome(month);
|
||||
const budget = budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
const maturity = maturityIndex[key] || { operating: 0, reserve: 0 };
|
||||
const project = projectIndex[key] || { operating: 0, reserve: 0 };
|
||||
|
||||
// Use budget income if available, else assessment income
|
||||
const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating;
|
||||
const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve;
|
||||
|
||||
// Net change: income - expenses - project costs + maturity returns
|
||||
runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating;
|
||||
runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve;
|
||||
|
||||
// Subtract maturing investment values from investment balances
|
||||
if (maturity.operating > 0) runOpInv = Math.max(0, runOpInv - (maturity.operating * 0.96));
|
||||
if (maturity.reserve > 0) runResInv = Math.max(0, runResInv - (maturity.reserve * 0.96));
|
||||
|
||||
datapoints.push({
|
||||
month: label,
|
||||
operating_cash: Math.round(runOpCash * 100) / 100,
|
||||
operating_investments: Math.round(runOpInv * 100) / 100,
|
||||
reserve_cash: Math.round(runResCash * 100) / 100,
|
||||
reserve_investments: Math.round(runResInv * 100) / 100,
|
||||
// Include drivers for transparency
|
||||
op_income: Math.round(opIncomeMonth * 100) / 100,
|
||||
op_expense: Math.round(budget.opExpense * 100) / 100,
|
||||
res_income: Math.round(resIncomeMonth * 100) / 100,
|
||||
res_expense: Math.round(budget.resExpense * 100) / 100,
|
||||
project_cost_op: Math.round(project.operating * 100) / 100,
|
||||
project_cost_res: Math.round(project.reserve * 100) / 100,
|
||||
maturity_op: Math.round(maturity.operating * 100) / 100,
|
||||
maturity_res: Math.round(maturity.reserve * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Build assessment schedule summary for the AI
|
||||
const assessmentSchedule = assessmentGroups.map((g: any) => ({
|
||||
frequency: g.frequency || 'monthly',
|
||||
regular_per_unit: parseFloat(g.regular_assessment) || 0,
|
||||
special_per_unit: parseFloat(g.special_assessment) || 0,
|
||||
units: parseInt(g.unit_count) || 0,
|
||||
total_regular: (parseFloat(g.regular_assessment) || 0) * (parseInt(g.unit_count) || 0),
|
||||
total_special: (parseFloat(g.special_assessment) || 0) * (parseInt(g.unit_count) || 0),
|
||||
}));
|
||||
|
||||
return {
|
||||
datapoints,
|
||||
assessment_schedule: assessmentSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private: AI Prompt Construction ──
|
||||
|
||||
private buildPromptMessages(
|
||||
snapshot: any,
|
||||
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
|
||||
monthlyForecast: any,
|
||||
) {
|
||||
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const systemPrompt = `You are a financial advisor specializing in HOA (Homeowners Association) reserve fund management and conservative investment strategy. You provide fiduciary-grade investment recommendations.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. HOAs are legally required to maintain adequate reserves. NEVER recommend depleting reserve funds below safe levels.
|
||||
2. HOA investments must be conservative ONLY: CDs, money market accounts, treasury bills, and high-yield savings. NO stocks, bonds, mutual funds, or speculative instruments.
|
||||
3. Liquidity is paramount: always ensure enough cash to cover at least 3 months of operating expenses AND any capital project expenses due within the next 12 months.
|
||||
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
|
||||
5. Operating funds should remain highly liquid (money market or high-yield savings only).
|
||||
6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
|
||||
7. Base your recommendations ONLY on the available market rates (CDs, Money Market, High Yield Savings) provided. Do not reference rates or banks not in the provided data.
|
||||
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
|
||||
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
|
||||
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
|
||||
|
||||
RESPONSE FORMAT:
|
||||
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
|
||||
{
|
||||
"recommendations": [
|
||||
{
|
||||
"type": "cd_ladder" | "new_investment" | "reallocation" | "maturity_action" | "liquidity_warning" | "general",
|
||||
"priority": "high" | "medium" | "low",
|
||||
"title": "Short action title (under 60 chars)",
|
||||
"summary": "One sentence summary of the recommendation",
|
||||
"details": "Detailed explanation with specific dollar amounts and timeframes",
|
||||
"fund_type": "operating" | "reserve" | "both",
|
||||
"suggested_amount": 50000.00,
|
||||
"suggested_term": "12 months",
|
||||
"suggested_rate": 4.50,
|
||||
"bank_name": "Bank name from market rates (if applicable)",
|
||||
"rationale": "Financial reasoning for why this makes sense"
|
||||
}
|
||||
],
|
||||
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
|
||||
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
||||
}
|
||||
|
||||
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
|
||||
|
||||
// Build the data context for the user prompt
|
||||
const investmentsList = investment_accounts.length === 0
|
||||
? 'No current investments.'
|
||||
: investment_accounts.map((i: any) =>
|
||||
`- ${i.name} | Type: ${i.investment_type} | Fund: ${i.fund_type} | Principal: $${parseFloat(i.principal).toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
||||
).join('\n');
|
||||
|
||||
const budgetLines = budgets.length === 0
|
||||
? 'No budget data available.'
|
||||
: budgets.map((b: any) =>
|
||||
`- ${b.name} (${b.account_number}) | ${b.account_type}/${b.fund_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr`,
|
||||
).join('\n');
|
||||
|
||||
const projectLines = projects.length === 0
|
||||
? 'No upcoming capital projects.'
|
||||
: projects.map((p: any) =>
|
||||
`- ${p.name} | Cost: $${parseFloat(p.estimated_cost).toFixed(2)} | Target: ${p.target_year || '?'}/${p.target_month || '?'} | Fund: ${p.fund_source} | Status: ${p.status} | Funded: ${parseFloat(p.funded_percentage || '0').toFixed(1)}%`,
|
||||
).join('\n');
|
||||
|
||||
const budgetSummaryLines = (cash_flow_context.budget_summary || []).length === 0
|
||||
? 'No budget summary available.'
|
||||
: cash_flow_context.budget_summary.map((b: any) =>
|
||||
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
|
||||
).join('\n');
|
||||
|
||||
// Format market rates by type
|
||||
const formatRates = (rates: MarketRate[], typeLabel: string): string => {
|
||||
if (rates.length === 0) return `No ${typeLabel} rate data available. Rate fetcher may not have been run yet.`;
|
||||
return rates.map((r: MarketRate) => {
|
||||
const termStr = r.term !== 'N/A' ? ` | Term: ${r.term}` : '';
|
||||
return `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${termStr} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`;
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
const cdRateLines = formatRates(allRates.cd, 'CD');
|
||||
const moneyMarketLines = formatRates(allRates.money_market, 'Money Market');
|
||||
const savingsRateLines = formatRates(allRates.high_yield_savings, 'High Yield Savings');
|
||||
|
||||
// Format assessment schedule showing regular + special
|
||||
const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0
|
||||
? 'No assessment schedule available.'
|
||||
: monthlyForecast.assessment_schedule.map((a: any) =>
|
||||
`- ${a.frequency} collection | ${a.units} units | Regular: $${a.regular_per_unit.toFixed(2)}/unit ($${a.total_regular.toFixed(2)} total) → Operating | Special: $${a.special_per_unit.toFixed(2)}/unit ($${a.total_special.toFixed(2)} total) → Reserve`,
|
||||
).join('\n');
|
||||
|
||||
// Format 12-month forecast table
|
||||
const forecastLines = (monthlyForecast.datapoints || []).map((dp: any) => {
|
||||
const drivers: string[] = [];
|
||||
if (dp.op_income > 0) drivers.push(`OpInc:$${dp.op_income.toFixed(0)}`);
|
||||
if (dp.op_expense > 0) drivers.push(`OpExp:$${dp.op_expense.toFixed(0)}`);
|
||||
if (dp.res_income > 0) drivers.push(`ResInc:$${dp.res_income.toFixed(0)}`);
|
||||
if (dp.res_expense > 0) drivers.push(`ResExp:$${dp.res_expense.toFixed(0)}`);
|
||||
if (dp.project_cost_res > 0) drivers.push(`ResProjCost:$${dp.project_cost_res.toFixed(0)}`);
|
||||
if (dp.project_cost_op > 0) drivers.push(`OpProjCost:$${dp.project_cost_op.toFixed(0)}`);
|
||||
if (dp.maturity_op > 0) drivers.push(`OpMaturity:$${dp.maturity_op.toFixed(0)}`);
|
||||
if (dp.maturity_res > 0) drivers.push(`ResMaturity:$${dp.maturity_res.toFixed(0)}`);
|
||||
return `- ${dp.month} | OpCash: $${dp.operating_cash.toFixed(0)} | ResCash: $${dp.reserve_cash.toFixed(0)} | OpInv: $${dp.operating_investments.toFixed(0)} | ResInv: $${dp.reserve_investments.toFixed(0)} | Drivers: ${drivers.join(', ') || 'none'}`;
|
||||
}).join('\n');
|
||||
|
||||
const userPrompt = `Analyze this HOA's financial position and provide investment recommendations.
|
||||
|
||||
TODAY'S DATE: ${today}
|
||||
|
||||
=== CURRENT CASH POSITIONS ===
|
||||
Operating Cash (bank accounts): $${summary.operating_cash.toFixed(2)}
|
||||
Reserve Cash (bank accounts): $${summary.reserve_cash.toFixed(2)}
|
||||
Operating Investments: $${summary.operating_investments.toFixed(2)}
|
||||
Reserve Investments: $${summary.reserve_investments.toFixed(2)}
|
||||
Total Operating Fund: $${summary.total_operating.toFixed(2)}
|
||||
Total Reserve Fund: $${summary.total_reserve.toFixed(2)}
|
||||
Grand Total: $${summary.total_all.toFixed(2)}
|
||||
|
||||
=== CURRENT INVESTMENTS ===
|
||||
${investmentsList}
|
||||
|
||||
=== ASSESSMENT INCOME SCHEDULE ===
|
||||
${assessmentScheduleLines}
|
||||
Note: "Regular" assessments fund Operating. "Special" assessments fund Reserve. Both are collected from homeowners per the frequency above.
|
||||
|
||||
=== ANNUAL BUDGET (${new Date().getFullYear()}) ===
|
||||
${budgetLines}
|
||||
|
||||
=== BUDGET SUMMARY (Annual Totals by Category) ===
|
||||
${budgetSummaryLines}
|
||||
|
||||
=== MONTHLY ASSESSMENT INCOME ===
|
||||
Recurring monthly regular assessment income: $${cash_flow_context.monthly_assessment_income.toFixed(2)}/month (operating fund)
|
||||
|
||||
=== UPCOMING CAPITAL PROJECTS ===
|
||||
${projectLines}
|
||||
|
||||
=== 12-MONTH CASH FLOW FORECAST (Projected) ===
|
||||
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
|
||||
${forecastLines}
|
||||
|
||||
=== AVAILABLE MARKET RATES ===
|
||||
|
||||
--- CD Rates ---
|
||||
${cdRateLines}
|
||||
|
||||
--- Money Market Rates ---
|
||||
${moneyMarketLines}
|
||||
|
||||
--- High Yield Savings Rates ---
|
||||
${savingsRateLines}
|
||||
|
||||
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
|
||||
1. Is there excess cash that could earn better returns in CDs, money market accounts, or high-yield savings?
|
||||
2. Are any current investments maturing soon that need reinvestment planning?
|
||||
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
|
||||
4. Would a CD ladder strategy improve the yield while maintaining access to funds?
|
||||
5. Are operating and reserve funds properly separated in the investment strategy?
|
||||
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
|
||||
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
|
||||
|
||||
return [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private: AI API Call ──
|
||||
|
||||
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
||||
const apiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
|
||||
const apiKey = this.configService.get<string>('AI_API_KEY');
|
||||
const model = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.error('AI_API_KEY not configured');
|
||||
return {
|
||||
recommendations: [],
|
||||
overall_assessment: 'AI recommendations are not available. The AI_API_KEY has not been configured in the environment.',
|
||||
risk_notes: ['Configure AI_API_KEY in .env to enable investment recommendations.'],
|
||||
};
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 4096,
|
||||
};
|
||||
|
||||
const bodyString = JSON.stringify(requestBody);
|
||||
|
||||
this.debug('prompt_system', messages[0]?.content);
|
||||
this.debug('prompt_user', messages[1]?.content);
|
||||
this.debug('request_meta', {
|
||||
url: `${apiUrl}/chat/completions`,
|
||||
model,
|
||||
temperature: 0.3,
|
||||
max_tokens: 4096,
|
||||
body_length_bytes: Buffer.byteLength(bodyString, 'utf-8'),
|
||||
message_count: messages.length,
|
||||
});
|
||||
|
||||
try {
|
||||
this.logger.log(`Calling AI API: ${apiUrl} with model ${model} (body: ${Buffer.byteLength(bodyString, 'utf-8')} bytes)`);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Use Node.js https module instead of native fetch for better
|
||||
// compatibility in Docker Alpine environments
|
||||
const { URL } = await import('url');
|
||||
const https = await import('https');
|
||||
|
||||
const aiResult = await new Promise<any>((resolve, reject) => {
|
||||
const url = new URL(`${apiUrl}/chat/completions`);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||
},
|
||||
timeout: 300000, // 5 minute timeout
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
resolve({ status: res.statusCode, body: data });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => reject(err));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Request timed out after 300s`));
|
||||
});
|
||||
|
||||
req.write(bodyString);
|
||||
req.end();
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logger.log(`AI API responded in ${elapsed}ms with status ${aiResult.status}`);
|
||||
this.debug('response_timing', { elapsed_ms: elapsed, status: aiResult.status });
|
||||
|
||||
if (aiResult.status >= 400) {
|
||||
this.logger.error(`AI API error ${aiResult.status}: ${aiResult.body}`);
|
||||
this.debug('response_error_body', aiResult.body);
|
||||
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(aiResult.body);
|
||||
const msg = data.choices?.[0]?.message;
|
||||
// Thinking models (kimi-k2.5) may return content in 'content' or
|
||||
// spend all tokens on 'reasoning_content' with content=null
|
||||
const content = msg?.content || null;
|
||||
|
||||
this.logger.log(`AI response: content=${content ? content.length + ' chars' : 'null'}, reasoning=${msg?.reasoning_content ? 'yes' : 'no'}, finish=${data.choices?.[0]?.finish_reason}`);
|
||||
this.debug('response_raw_content', content);
|
||||
this.debug('response_usage', data.usage);
|
||||
if (msg?.reasoning_content) {
|
||||
this.debug('response_reasoning', msg.reasoning_content);
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error('AI model returned empty content — it may have exhausted tokens on reasoning. Try a non-thinking model or increase max_tokens.');
|
||||
}
|
||||
|
||||
// Parse the JSON response — handle potential markdown code fences
|
||||
let cleaned = content.trim();
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
|
||||
// Handle thinking model wrapper: strip <think>...</think> blocks
|
||||
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
|
||||
|
||||
const parsed = JSON.parse(cleaned) as AIResponse;
|
||||
|
||||
// Validate the response structure
|
||||
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
|
||||
this.debug('invalid_response_structure', parsed);
|
||||
throw new Error('Invalid AI response: missing recommendations array');
|
||||
}
|
||||
|
||||
this.logger.log(`AI returned ${parsed.recommendations.length} recommendations`);
|
||||
this.debug('parsed_recommendations', parsed.recommendations.map((r) => ({
|
||||
type: r.type,
|
||||
priority: r.priority,
|
||||
title: r.title,
|
||||
fund_type: r.fund_type,
|
||||
suggested_amount: r.suggested_amount,
|
||||
})));
|
||||
|
||||
return parsed;
|
||||
} catch (error: any) {
|
||||
// Log the full error chain for debugging
|
||||
this.logger.error(`AI recommendation failed: ${error.message}`);
|
||||
if (error.cause) {
|
||||
this.logger.error(` → cause: ${error.cause?.message || error.cause}`);
|
||||
this.debug('error_cause', {
|
||||
message: error.cause?.message,
|
||||
code: error.cause?.code,
|
||||
errno: error.cause?.errno,
|
||||
syscall: error.cause?.syscall,
|
||||
hostname: error.cause?.hostname,
|
||||
stack: error.cause?.stack,
|
||||
});
|
||||
}
|
||||
this.debug('error_full', {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
code: error.code,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
// For JSON parse errors, return what we can
|
||||
if (error instanceof SyntaxError) {
|
||||
return {
|
||||
recommendations: [],
|
||||
overall_assessment: 'The AI service returned an invalid response format. Please try again.',
|
||||
risk_notes: [`Response parsing error: ${error.message}`],
|
||||
};
|
||||
}
|
||||
|
||||
// For network/timeout errors, return a graceful fallback
|
||||
return {
|
||||
recommendations: [],
|
||||
overall_assessment: 'Unable to generate AI recommendations at this time. Please try again later.',
|
||||
risk_notes: [`AI service error: ${error.message}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export class InvoicesController {
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
||||
|
||||
@Post('generate-preview')
|
||||
generatePreview(@Body() dto: { month: number; year: number }) {
|
||||
return this.invoicesService.generatePreview(dto);
|
||||
}
|
||||
|
||||
@Post('generate-bulk')
|
||||
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
||||
return this.invoicesService.generateBulk(dto, req.user.sub);
|
||||
|
||||
@@ -1,33 +1,135 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'', 'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
const MONTH_ABBREV = [
|
||||
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query(`
|
||||
SELECT i.*, u.unit_number,
|
||||
SELECT i.*, u.unit_number, u.owner_name, ag.name as assessment_group_name, ag.frequency,
|
||||
(i.amount - i.amount_paid) as balance_due
|
||||
FROM invoices i
|
||||
JOIN units u ON u.id = i.unit_id
|
||||
LEFT JOIN assessment_groups ag ON ag.id = i.assessment_group_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
|
||||
SELECT i.*, u.unit_number, u.owner_name 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`,
|
||||
/**
|
||||
* Calculate billing period based on frequency and the billing month.
|
||||
*/
|
||||
private calculatePeriod(frequency: string, month: number, year: number): { start: string; end: string; description: string } {
|
||||
switch (frequency) {
|
||||
case 'quarterly': {
|
||||
// Period covers 3 months starting from the billing month
|
||||
const startDate = new Date(year, month - 1, 1);
|
||||
const endDate = new Date(year, month + 2, 0); // last day of month+2
|
||||
const endMonth = month + 2 > 12 ? month + 2 - 12 : month + 2;
|
||||
const quarter = Math.ceil(month / 3);
|
||||
return {
|
||||
start: startDate.toISOString().split('T')[0],
|
||||
end: endDate.toISOString().split('T')[0],
|
||||
description: `Q${quarter} ${year} Assessment (${MONTH_ABBREV[month]}-${MONTH_ABBREV[endMonth]})`,
|
||||
};
|
||||
}
|
||||
case 'annual': {
|
||||
const startDate = new Date(year, 0, 1);
|
||||
const endDate = new Date(year, 11, 31);
|
||||
return {
|
||||
start: startDate.toISOString().split('T')[0],
|
||||
end: endDate.toISOString().split('T')[0],
|
||||
description: `Annual Assessment ${year}`,
|
||||
};
|
||||
}
|
||||
default: { // monthly
|
||||
const startDate = new Date(year, month - 1, 1);
|
||||
const endDate = new Date(year, month, 0); // last day of month
|
||||
return {
|
||||
start: startDate.toISOString().split('T')[0],
|
||||
end: endDate.toISOString().split('T')[0],
|
||||
description: `Monthly Assessment - ${MONTH_NAMES[month]} ${year}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview which groups/units will be billed for a given month/year.
|
||||
*/
|
||||
async generatePreview(dto: { month: number; year: number }) {
|
||||
const allGroups = await this.tenant.query(
|
||||
`SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as active_units
|
||||
FROM assessment_groups ag WHERE ag.is_active = true ORDER BY ag.name`,
|
||||
);
|
||||
if (!units.length) throw new BadRequestException('No active units with assessments found');
|
||||
|
||||
const groups = allGroups.map((g: any) => {
|
||||
const dueMonths: number[] = g.due_months || [1,2,3,4,5,6,7,8,9,10,11,12];
|
||||
const isBillingMonth = dueMonths.includes(dto.month);
|
||||
const activeUnits = parseInt(g.active_units || '0');
|
||||
const totalAmount = isBillingMonth
|
||||
? (parseFloat(g.regular_assessment) + parseFloat(g.special_assessment || '0')) * activeUnits
|
||||
: 0;
|
||||
const period = this.calculatePeriod(g.frequency || 'monthly', dto.month, dto.year);
|
||||
|
||||
return {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
frequency: g.frequency || 'monthly',
|
||||
due_months: dueMonths,
|
||||
active_units: activeUnits,
|
||||
regular_assessment: g.regular_assessment,
|
||||
special_assessment: g.special_assessment,
|
||||
is_billing_month: isBillingMonth,
|
||||
total_amount: totalAmount,
|
||||
period_description: period.description,
|
||||
};
|
||||
});
|
||||
|
||||
const billableGroups = groups.filter((g: any) => g.is_billing_month && g.active_units > 0);
|
||||
const totalInvoices = billableGroups.reduce((sum: number, g: any) => sum + g.active_units, 0);
|
||||
const totalAmount = billableGroups.reduce((sum: number, g: any) => sum + g.total_amount, 0);
|
||||
|
||||
return {
|
||||
month: dto.month,
|
||||
year: dto.year,
|
||||
month_name: MONTH_NAMES[dto.month],
|
||||
groups,
|
||||
summary: { total_groups_billing: billableGroups.length, total_invoices: totalInvoices, total_amount: totalAmount },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate invoices for all assessment groups where the given month is a billing month.
|
||||
*/
|
||||
async generateBulk(dto: { month: number; year: number }, userId: string) {
|
||||
// Get assessment groups where this month is a billing month
|
||||
const groups = await this.tenant.query(
|
||||
`SELECT * FROM assessment_groups WHERE is_active = true AND $1 = ANY(due_months)`,
|
||||
[dto.month],
|
||||
);
|
||||
|
||||
if (!groups.length) {
|
||||
throw new BadRequestException(`No assessment groups have billing scheduled for ${MONTH_NAMES[dto.month]}`);
|
||||
}
|
||||
|
||||
// Get or create fiscal period
|
||||
let fp = await this.tenant.query(
|
||||
@@ -41,9 +143,32 @@ export class InvoicesService {
|
||||
}
|
||||
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);
|
||||
// Look up GL accounts once
|
||||
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'`);
|
||||
|
||||
let created = 0;
|
||||
const groupResults: any[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
// Get active units in this assessment group
|
||||
const units = await this.tenant.query(
|
||||
`SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`,
|
||||
[group.id],
|
||||
);
|
||||
|
||||
if (!units.length) continue;
|
||||
|
||||
const frequency = group.frequency || 'monthly';
|
||||
const period = this.calculatePeriod(frequency, dto.month, dto.year);
|
||||
const dueDay = Math.min(group.due_day || 1, 28);
|
||||
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
|
||||
const dueDate = new Date(dto.year, dto.month - 1, dueDay);
|
||||
|
||||
// Use the group's assessment amount (full period amount, not monthly equivalent)
|
||||
const assessmentAmount = parseFloat(group.regular_assessment) + parseFloat(group.special_assessment || '0');
|
||||
|
||||
let groupCreated = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
|
||||
@@ -54,19 +179,24 @@ export class InvoicesService {
|
||||
);
|
||||
if (existing.length) continue;
|
||||
|
||||
// Create the invoice
|
||||
// Use unit-level override if set, otherwise use group amount
|
||||
const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0
|
||||
? (frequency === 'monthly'
|
||||
? parseFloat(unit.monthly_assessment)
|
||||
: frequency === 'quarterly'
|
||||
? parseFloat(unit.monthly_assessment) * 3
|
||||
: parseFloat(unit.monthly_assessment) * 12)
|
||||
: assessmentAmount;
|
||||
|
||||
// Create the invoice with status 'pending' (no email sending capability)
|
||||
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`,
|
||||
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status, period_start, period_end, assessment_group_id)
|
||||
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) 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],
|
||||
period.description, unitAmount, period.start, period.end, group.id],
|
||||
);
|
||||
|
||||
// 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)
|
||||
@@ -75,16 +205,25 @@ export class InvoicesService {
|
||||
);
|
||||
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],
|
||||
[je[0].id, arAccount[0].id, unitAmount, incomeAccount[0].id],
|
||||
);
|
||||
await this.tenant.query(
|
||||
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
|
||||
);
|
||||
}
|
||||
created++;
|
||||
groupCreated++;
|
||||
}
|
||||
|
||||
return { created, month: dto.month, year: dto.year };
|
||||
groupResults.push({
|
||||
group_name: group.name,
|
||||
frequency,
|
||||
period: period.description,
|
||||
invoices_created: groupCreated,
|
||||
});
|
||||
}
|
||||
|
||||
return { created, month: dto.month, year: dto.year, groups: groupResults };
|
||||
}
|
||||
|
||||
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
|
||||
@@ -95,7 +234,7 @@ export class InvoicesService {
|
||||
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
|
||||
WHERE i.status IN ('pending', '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 || '%'
|
||||
@@ -109,7 +248,7 @@ export class InvoicesService {
|
||||
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')`,
|
||||
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'pending')`,
|
||||
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
|
||||
);
|
||||
applied++;
|
||||
|
||||
@@ -61,6 +61,15 @@ export class Organization {
|
||||
@Column({ name: 'plan_level', default: 'standard' })
|
||||
planLevel: string;
|
||||
|
||||
@Column({ name: 'payment_date', type: 'date', nullable: true })
|
||||
paymentDate: Date;
|
||||
|
||||
@Column({ name: 'confirmation_number', type: 'varchar', nullable: true })
|
||||
confirmationNumber: string;
|
||||
|
||||
@Column({ name: 'renewal_date', type: 'date', nullable: true })
|
||||
renewalDate: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Put, Patch, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
@@ -23,6 +23,13 @@ export class OrganizationsController {
|
||||
return this.orgService.findByUser(req.user.sub);
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
@ApiOperation({ summary: 'Update settings for the current organization' })
|
||||
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
||||
this.requireTenantAdmin(req);
|
||||
return this.orgService.updateSettings(req.user.orgId, body);
|
||||
}
|
||||
|
||||
// ── Org Member Management ──
|
||||
|
||||
private requireTenantAdmin(req: any) {
|
||||
|
||||
@@ -62,6 +62,29 @@ export class OrganizationsService {
|
||||
return this.orgRepository.save(org);
|
||||
}
|
||||
|
||||
async updatePlanLevel(id: string, planLevel: string) {
|
||||
const org = await this.orgRepository.findOne({ where: { id } });
|
||||
if (!org) throw new NotFoundException('Organization not found');
|
||||
org.planLevel = planLevel;
|
||||
return this.orgRepository.save(org);
|
||||
}
|
||||
|
||||
async updateSubscription(id: string, data: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string }) {
|
||||
const org = await this.orgRepository.findOne({ where: { id } });
|
||||
if (!org) throw new NotFoundException('Organization not found');
|
||||
if (data.paymentDate !== undefined) (org as any).paymentDate = data.paymentDate ? new Date(data.paymentDate) : null;
|
||||
if (data.confirmationNumber !== undefined) (org as any).confirmationNumber = data.confirmationNumber || null;
|
||||
if (data.renewalDate !== undefined) (org as any).renewalDate = data.renewalDate ? new Date(data.renewalDate) : null;
|
||||
return this.orgRepository.save(org);
|
||||
}
|
||||
|
||||
async updateSettings(id: string, settings: Record<string, any>) {
|
||||
const org = await this.orgRepository.findOne({ where: { id } });
|
||||
if (!org) throw new NotFoundException('Organization not found');
|
||||
org.settings = { ...(org.settings || {}), ...settings };
|
||||
return this.orgRepository.save(org);
|
||||
}
|
||||
|
||||
async findByUser(userId: string) {
|
||||
const memberships = await this.userOrgRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Put, Delete, 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';
|
||||
@@ -18,4 +18,12 @@ export class PaymentsController {
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
||||
return this.paymentsService.update(id, dto, req.user.sub);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
||||
}
|
||||
|
||||
@@ -74,17 +74,95 @@ export class PaymentsService {
|
||||
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
|
||||
}
|
||||
|
||||
// Update invoice if linked
|
||||
// Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error
|
||||
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],
|
||||
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $4`,
|
||||
[newPaid, newStatus, newStatus, invoice.id],
|
||||
);
|
||||
}
|
||||
|
||||
return payment[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any, userId: string) {
|
||||
const existing = await this.findOne(id);
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (dto.payment_date !== undefined) { sets.push(`payment_date = $${idx++}`); params.push(dto.payment_date); }
|
||||
if (dto.amount !== undefined) { sets.push(`amount = $${idx++}`); params.push(dto.amount); }
|
||||
if (dto.payment_method !== undefined) { sets.push(`payment_method = $${idx++}`); params.push(dto.payment_method); }
|
||||
if (dto.reference_number !== undefined) { sets.push(`reference_number = $${idx++}`); params.push(dto.reference_number); }
|
||||
if (dto.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(dto.notes); }
|
||||
|
||||
if (!sets.length) return this.findOne(id);
|
||||
|
||||
params.push(id);
|
||||
await this.tenant.query(
|
||||
`UPDATE payments SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
|
||||
// If amount changed and payment is linked to an invoice, recalculate invoice totals
|
||||
if (dto.amount !== undefined && existing.invoice_id) {
|
||||
await this.recalculateInvoice(existing.invoice_id);
|
||||
}
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const payment = await this.findOne(id);
|
||||
const invoiceId = payment.invoice_id;
|
||||
|
||||
// Delete associated journal entry lines and journal entry
|
||||
if (payment.journal_entry_id) {
|
||||
await this.tenant.query('DELETE FROM journal_entry_lines WHERE journal_entry_id = $1', [payment.journal_entry_id]);
|
||||
await this.tenant.query('DELETE FROM journal_entries WHERE id = $1', [payment.journal_entry_id]);
|
||||
}
|
||||
|
||||
// Delete the payment
|
||||
await this.tenant.query('DELETE FROM payments WHERE id = $1', [id]);
|
||||
|
||||
// Recalculate invoice totals if payment was linked
|
||||
if (invoiceId) {
|
||||
await this.recalculateInvoice(invoiceId);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async recalculateInvoice(invoiceId: string) {
|
||||
// Sum all remaining payments for this invoice
|
||||
const result = await this.tenant.query(
|
||||
'SELECT COALESCE(SUM(amount), 0) as total_paid FROM payments WHERE invoice_id = $1',
|
||||
[invoiceId],
|
||||
);
|
||||
const totalPaid = parseFloat(result[0].total_paid);
|
||||
|
||||
// Get the invoice amount
|
||||
const inv = await this.tenant.query('SELECT amount FROM invoices WHERE id = $1', [invoiceId]);
|
||||
if (!inv.length) return;
|
||||
|
||||
const invoiceAmt = parseFloat(inv[0].amount);
|
||||
let newStatus: string;
|
||||
if (totalPaid >= invoiceAmt) {
|
||||
newStatus = 'paid';
|
||||
} else if (totalPaid > 0) {
|
||||
newStatus = 'partial';
|
||||
} else {
|
||||
newStatus = 'pending';
|
||||
}
|
||||
|
||||
await this.tenant.query(
|
||||
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = $4`,
|
||||
[totalPaid, newStatus, newStatus, invoiceId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export class ProjectsService {
|
||||
|
||||
async findAll() {
|
||||
const projects = await this.tenant.query(
|
||||
'SELECT * FROM projects WHERE is_active = true ORDER BY name',
|
||||
'SELECT * FROM projects WHERE is_active = true ORDER BY planned_date NULLS LAST, target_year NULLS LAST, target_month NULLS LAST, name',
|
||||
);
|
||||
return this.computeFunding(projects);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export class ProjectsService {
|
||||
|
||||
async findForPlanning() {
|
||||
const projects = await this.tenant.query(
|
||||
'SELECT * FROM projects WHERE is_active = true AND target_year IS NOT NULL ORDER BY target_year, target_month NULLS LAST, priority',
|
||||
'SELECT * FROM projects WHERE is_active = true ORDER BY target_year NULLS LAST, target_month NULLS LAST, priority',
|
||||
);
|
||||
return this.computeFunding(projects);
|
||||
}
|
||||
@@ -157,6 +157,9 @@ export class ProjectsService {
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
// Date columns must be null (not empty string) for PostgreSQL DATE type
|
||||
const dateFields = new Set(['last_replacement_date', 'next_replacement_date', 'planned_date']);
|
||||
|
||||
// Build dynamic SET clause
|
||||
const fields: [string, string][] = [
|
||||
['name', 'name'], ['description', 'description'], ['category', 'category'],
|
||||
@@ -175,7 +178,8 @@ export class ProjectsService {
|
||||
for (const [dtoKey, dbCol] of fields) {
|
||||
if (dto[dtoKey] !== undefined) {
|
||||
sets.push(`${dbCol} = $${idx++}`);
|
||||
params.push(dto[dtoKey]);
|
||||
const val = dateFields.has(dtoKey) && dto[dtoKey] === '' ? null : dto[dtoKey];
|
||||
params.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +280,7 @@ export class ProjectsService {
|
||||
await this.findOne(id);
|
||||
const rows = await this.tenant.query(
|
||||
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
|
||||
[id, planned_date],
|
||||
[id, planned_date || null],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
@@ -24,8 +24,16 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get('cash-flow-sankey')
|
||||
getCashFlowSankey(@Query('year') year?: string) {
|
||||
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
|
||||
getCashFlowSankey(
|
||||
@Query('year') year?: string,
|
||||
@Query('source') source?: string,
|
||||
@Query('fundType') fundType?: string,
|
||||
) {
|
||||
return this.reportsService.getCashFlowSankey(
|
||||
parseInt(year || '') || new Date().getFullYear(),
|
||||
source || 'actuals',
|
||||
fundType || 'all',
|
||||
);
|
||||
}
|
||||
|
||||
@Get('cash-flow')
|
||||
@@ -66,4 +74,20 @@ export class ReportsController {
|
||||
const mo = Math.min(parseInt(months || '') || 24, 48);
|
||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||
}
|
||||
|
||||
@Get('quarterly')
|
||||
getQuarterlyFinancial(
|
||||
@Query('year') year?: string,
|
||||
@Query('quarter') quarter?: string,
|
||||
) {
|
||||
const now = new Date();
|
||||
const defaultYear = now.getFullYear();
|
||||
// Default to last complete quarter
|
||||
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
||||
const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4;
|
||||
const defaultQYear = currentQuarter > 1 ? defaultYear : defaultYear - 1;
|
||||
const yr = parseInt(year || '') || defaultQYear;
|
||||
const q = Math.min(Math.max(parseInt(quarter || '') || defaultQuarter, 1), 4);
|
||||
return this.reportsService.getQuarterlyFinancial(yr, q);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ export class ReportsService {
|
||||
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
|
||||
LEFT JOIN (
|
||||
journal_entry_lines jel
|
||||
INNER 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
|
||||
) ON jel.account_id = a.id
|
||||
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
|
||||
@@ -32,6 +34,71 @@ export class ReportsService {
|
||||
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
|
||||
const equity = rows.filter((r: any) => r.account_type === 'equity');
|
||||
|
||||
// Compute current year net income (income - expenses) for the fiscal year through as_of date
|
||||
// This balances the accounting equation: Assets = Liabilities + Equity + Net Income
|
||||
const fiscalYearStart = `${asOf.substring(0, 4)}-01-01`;
|
||||
const netIncomeSql = `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN a.account_type = 'income'
|
||||
THEN jel.credit - jel.debit ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
|
||||
THEN jel.debit - jel.credit ELSE 0 END), 0) as net_income
|
||||
FROM journal_entry_lines jel
|
||||
INNER 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
|
||||
INNER JOIN accounts a ON a.id = jel.account_id
|
||||
AND a.account_type IN ('income', 'expense') AND a.is_active = true
|
||||
`;
|
||||
const netIncomeResult = await this.tenant.query(netIncomeSql, [fiscalYearStart, asOf]);
|
||||
const netIncome = parseFloat(netIncomeResult[0]?.net_income || '0');
|
||||
|
||||
// Add current year net income as a synthetic equity line
|
||||
if (netIncome !== 0) {
|
||||
equity.push({
|
||||
id: null,
|
||||
account_number: '',
|
||||
name: 'Current Year Net Income',
|
||||
account_type: 'equity',
|
||||
fund_type: 'operating',
|
||||
balance: netIncome.toFixed(2),
|
||||
});
|
||||
}
|
||||
|
||||
// Add investment account balances to assets and corresponding equity
|
||||
const investmentsSql = `
|
||||
SELECT id, name, institution, current_value as balance, fund_type
|
||||
FROM investment_accounts
|
||||
WHERE is_active = true AND current_value > 0
|
||||
`;
|
||||
const investments = await this.tenant.query(investmentsSql);
|
||||
const investmentsByFund: Record<string, number> = {};
|
||||
for (const inv of investments) {
|
||||
assets.push({
|
||||
id: inv.id,
|
||||
account_number: '',
|
||||
name: `${inv.name} (${inv.institution})`,
|
||||
account_type: 'asset',
|
||||
fund_type: inv.fund_type,
|
||||
balance: parseFloat(inv.balance).toFixed(2),
|
||||
});
|
||||
investmentsByFund[inv.fund_type] = (investmentsByFund[inv.fund_type] || 0) + parseFloat(inv.balance);
|
||||
}
|
||||
// Add investment balances as synthetic equity lines to maintain A = L + E
|
||||
for (const [fundType, total] of Object.entries(investmentsByFund)) {
|
||||
if (total > 0) {
|
||||
const label = fundType === 'reserve' ? 'Reserve' : 'Operating';
|
||||
equity.push({
|
||||
id: null,
|
||||
account_number: '',
|
||||
name: `${label} Investment Holdings`,
|
||||
account_type: 'equity',
|
||||
fund_type: fundType,
|
||||
balance: total.toFixed(2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -54,10 +121,12 @@ export class ReportsService {
|
||||
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
|
||||
LEFT JOIN (
|
||||
journal_entry_lines jel
|
||||
INNER 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
|
||||
) ON jel.account_id = a.id
|
||||
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
|
||||
@@ -83,33 +152,151 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getCashFlowSankey(year: number) {
|
||||
// Get income accounts with amounts
|
||||
const income = await this.tenant.query(`
|
||||
async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
|
||||
let income: any[];
|
||||
let expenses: any[];
|
||||
|
||||
const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : '';
|
||||
const fundParams = fundType !== 'all' ? [year, fundType] : [year];
|
||||
|
||||
const monthSum = `COALESCE(b.jan,0)+COALESCE(b.feb,0)+COALESCE(b.mar,0)+COALESCE(b.apr,0)+COALESCE(b.may,0)+COALESCE(b.jun,0)+COALESCE(b.jul,0)+COALESCE(b.aug,0)+COALESCE(b.sep,0)+COALESCE(b.oct,0)+COALESCE(b.nov,0)+COALESCE(b.dec_amt,0)`;
|
||||
|
||||
if (source === 'budget') {
|
||||
income = await this.tenant.query(`
|
||||
SELECT a.name, SUM(${monthSum}) as amount
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
|
||||
GROUP BY a.id, a.name
|
||||
HAVING SUM(${monthSum}) > 0
|
||||
ORDER BY SUM(${monthSum}) DESC
|
||||
`, fundParams);
|
||||
|
||||
expenses = await this.tenant.query(`
|
||||
SELECT a.name, a.fund_type, SUM(${monthSum}) as amount
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
|
||||
GROUP BY a.id, a.name, a.fund_type
|
||||
HAVING SUM(${monthSum}) > 0
|
||||
ORDER BY SUM(${monthSum}) DESC
|
||||
`, fundParams);
|
||||
|
||||
} else if (source === 'forecast') {
|
||||
// Combine actuals (Jan to current date) + budget (remaining months)
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth(); // 0-indexed
|
||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||
const remainingMonths = monthNames.slice(currentMonth + 1);
|
||||
|
||||
const actualsFundCond = fundType !== 'all' ? ' AND a.fund_type = $2' : '';
|
||||
const actualsParams: any[] = fundType !== 'all' ? [`${year}-01-01`, fundType] : [`${year}-01-01`];
|
||||
|
||||
const actualsIncome = 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 je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
|
||||
WHERE a.account_type = 'income' AND a.is_active = true${actualsFundCond}
|
||||
GROUP BY a.id, a.name
|
||||
`, actualsParams);
|
||||
|
||||
const actualsExpenses = 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 je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
|
||||
WHERE a.account_type = 'expense' AND a.is_active = true${actualsFundCond}
|
||||
GROUP BY a.id, a.name, a.fund_type
|
||||
`, actualsParams);
|
||||
|
||||
// Budget for remaining months
|
||||
let budgetIncome: any[] = [];
|
||||
let budgetExpenses: any[] = [];
|
||||
if (remainingMonths.length > 0) {
|
||||
const budgetMonthSum = remainingMonths.map(m => `COALESCE(b.${m},0)`).join('+');
|
||||
budgetIncome = await this.tenant.query(`
|
||||
SELECT a.name, SUM(${budgetMonthSum}) as amount
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
|
||||
GROUP BY a.id, a.name
|
||||
`, fundParams);
|
||||
|
||||
budgetExpenses = await this.tenant.query(`
|
||||
SELECT a.name, a.fund_type, SUM(${budgetMonthSum}) as amount
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
|
||||
GROUP BY a.id, a.name, a.fund_type
|
||||
`, fundParams);
|
||||
}
|
||||
|
||||
// Merge actuals + budget by account name
|
||||
const incomeMap = new Map<string, number>();
|
||||
for (const a of actualsIncome) {
|
||||
const amt = parseFloat(a.amount) || 0;
|
||||
if (amt > 0) incomeMap.set(a.name, (incomeMap.get(a.name) || 0) + amt);
|
||||
}
|
||||
for (const b of budgetIncome) {
|
||||
const amt = parseFloat(b.amount) || 0;
|
||||
if (amt > 0) incomeMap.set(b.name, (incomeMap.get(b.name) || 0) + amt);
|
||||
}
|
||||
income = Array.from(incomeMap.entries())
|
||||
.map(([name, amount]) => ({ name, amount: String(amount) }))
|
||||
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
|
||||
|
||||
const expenseMap = new Map<string, { amount: number; fund_type: string }>();
|
||||
for (const a of actualsExpenses) {
|
||||
const amt = parseFloat(a.amount) || 0;
|
||||
if (amt > 0) {
|
||||
const existing = expenseMap.get(a.name);
|
||||
expenseMap.set(a.name, { amount: (existing?.amount || 0) + amt, fund_type: a.fund_type });
|
||||
}
|
||||
}
|
||||
for (const b of budgetExpenses) {
|
||||
const amt = parseFloat(b.amount) || 0;
|
||||
if (amt > 0) {
|
||||
const existing = expenseMap.get(b.name);
|
||||
expenseMap.set(b.name, { amount: (existing?.amount || 0) + amt, fund_type: b.fund_type });
|
||||
}
|
||||
}
|
||||
expenses = Array.from(expenseMap.entries())
|
||||
.map(([name, { amount, fund_type }]) => ({ name, amount: String(amount), fund_type }))
|
||||
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
|
||||
|
||||
} else {
|
||||
// Actuals: query journal entries for the year
|
||||
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
|
||||
WHERE a.account_type = 'income' AND a.is_active = true${fundCondition}
|
||||
GROUP BY a.id, a.name
|
||||
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
||||
ORDER BY amount DESC
|
||||
`, [year]);
|
||||
`, fundParams);
|
||||
|
||||
const expenses = await this.tenant.query(`
|
||||
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
|
||||
WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition}
|
||||
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]);
|
||||
`, fundParams);
|
||||
}
|
||||
|
||||
if (!income.length && !expenses.length) {
|
||||
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
|
||||
@@ -222,20 +409,20 @@ export class ReportsService {
|
||||
ORDER BY a.name
|
||||
`, [from, to]);
|
||||
|
||||
// Asset filter: cash-only vs cash + investment accounts
|
||||
const assetFilter = includeInvestments
|
||||
? `a.account_type = 'asset'`
|
||||
: `a.account_type = 'asset' AND a.name LIKE '%Cash%'`;
|
||||
// Asset filter: all asset accounts (bank/checking/savings are the cash accounts)
|
||||
const assetFilter = `a.account_type = 'asset'`;
|
||||
|
||||
// Cash beginning and ending balances
|
||||
const beginCash = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
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
|
||||
LEFT JOIN (
|
||||
journal_entry_lines jel
|
||||
INNER 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
|
||||
) ON jel.account_id = a.id
|
||||
WHERE ${assetFilter} AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
@@ -245,10 +432,12 @@ export class ReportsService {
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
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
|
||||
LEFT JOIN (
|
||||
journal_entry_lines jel
|
||||
INNER 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
|
||||
) ON jel.account_id = a.id
|
||||
WHERE ${assetFilter} AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
@@ -273,7 +462,8 @@ export class ReportsService {
|
||||
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
|
||||
const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance;
|
||||
// Only include investment balances in ending balance when includeInvestments is toggled on
|
||||
const endingBalance = parseFloat(endCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
|
||||
|
||||
return {
|
||||
from, to,
|
||||
@@ -360,19 +550,22 @@ export class ReportsService {
|
||||
const incomeStmt = await this.getIncomeStatement(from, to);
|
||||
const balanceSheet = await this.getBalanceSheet(to);
|
||||
|
||||
// 1099 vendor data
|
||||
// 1099 vendor data — uses journal entries via vendor's default_account_id
|
||||
const vendors1099 = await this.tenant.query(`
|
||||
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
|
||||
COALESCE(SUM(p.amount), 0) as total_paid
|
||||
COALESCE(SUM(p_amounts.amount), 0) as total_paid
|
||||
FROM vendors v
|
||||
JOIN (
|
||||
SELECT vendor_id, amount FROM invoices
|
||||
WHERE EXTRACT(YEAR FROM invoice_date) = $1
|
||||
AND status IN ('paid', 'partial')
|
||||
) p ON p.vendor_id = v.id
|
||||
LEFT JOIN (
|
||||
SELECT jel.account_id, jel.debit as amount
|
||||
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, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
|
||||
HAVING COALESCE(SUM(p.amount), 0) >= 600
|
||||
HAVING COALESCE(SUM(p_amounts.amount), 0) >= 600
|
||||
ORDER BY v.name
|
||||
`, [year]);
|
||||
|
||||
@@ -444,24 +637,43 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
async getDashboardKPIs() {
|
||||
// Total cash: ALL asset accounts (not just those named "Cash")
|
||||
// Uses proper double-entry balance: debit - credit for assets
|
||||
const cash = await this.tenant.query(`
|
||||
// Operating cash (asset accounts, fund_type=operating)
|
||||
const opCash = 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.is_active = true
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
// Also include investment account current_value in total cash
|
||||
const investmentCash = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total
|
||||
FROM investment_accounts WHERE is_active = true
|
||||
// Reserve cash (asset accounts, fund_type=reserve)
|
||||
const resCash = 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.fund_type = 'reserve' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
const totalCash = parseFloat(cash[0]?.total || '0') + parseFloat(investmentCash[0]?.total || '0');
|
||||
// Investment accounts split by fund type
|
||||
const opInv = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total
|
||||
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
|
||||
`);
|
||||
const resInv = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total
|
||||
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||
`);
|
||||
|
||||
const operatingCash = parseFloat(opCash[0]?.total || '0');
|
||||
const reserveCash = parseFloat(resCash[0]?.total || '0');
|
||||
const operatingInvestments = parseFloat(opInv[0]?.total || '0');
|
||||
const reserveInvestments = parseFloat(resInv[0]?.total || '0');
|
||||
const totalCash = operatingCash + reserveCash + operatingInvestments + reserveInvestments;
|
||||
|
||||
// Receivables: sum of unpaid invoices
|
||||
const ar = await this.tenant.query(`
|
||||
@@ -469,9 +681,7 @@ export class ReportsService {
|
||||
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
||||
`);
|
||||
|
||||
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100)
|
||||
// The equity accounts track the total reserve fund position via double-entry bookkeeping
|
||||
// This is the standard HOA approach — every reserve contribution/expenditure flows through equity
|
||||
// Reserve fund balance via equity accounts + reserve investments
|
||||
const reserves = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
||||
@@ -482,17 +692,43 @@ export class ReportsService {
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
// Add reserve investment account values to the reserve fund total
|
||||
const reserveInvestments = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value), 0) as total
|
||||
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||
`);
|
||||
|
||||
// Delinquent count (overdue invoices)
|
||||
const delinquent = await this.tenant.query(`
|
||||
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
|
||||
`);
|
||||
|
||||
// Monthly interest estimate from accounts + investments with rates
|
||||
const acctInterest = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.monthly_interest), 0) as total FROM (
|
||||
SELECT (COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) * (a.interest_rate / 100) / 12 as monthly_interest
|
||||
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.is_active = true AND a.interest_rate > 0
|
||||
GROUP BY a.id, a.interest_rate
|
||||
) sub
|
||||
`);
|
||||
const acctInterestTotal = parseFloat(acctInterest[0]?.total || '0');
|
||||
const invInterest = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value * interest_rate / 100 / 12), 0) as total
|
||||
FROM investment_accounts WHERE is_active = true AND interest_rate > 0
|
||||
`);
|
||||
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
|
||||
|
||||
// Interest earned YTD: approximate from current_value - principal (unrealized gains)
|
||||
const interestEarned = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_value - principal), 0) as total
|
||||
FROM investment_accounts WHERE is_active = true AND current_value > principal
|
||||
`);
|
||||
|
||||
// Planned capital spend for current year
|
||||
const currentYear = new Date().getFullYear();
|
||||
const capitalSpend = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
||||
`, [currentYear]);
|
||||
|
||||
// Recent transactions
|
||||
const recentTx = await this.tenant.query(`
|
||||
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
||||
@@ -504,9 +740,17 @@ export class ReportsService {
|
||||
return {
|
||||
total_cash: totalCash.toFixed(2),
|
||||
total_receivables: ar[0]?.total || '0.00',
|
||||
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2),
|
||||
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + reserveInvestments).toFixed(2),
|
||||
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
||||
recent_transactions: recentTx,
|
||||
// Enhanced split data
|
||||
operating_cash: operatingCash.toFixed(2),
|
||||
reserve_cash: reserveCash.toFixed(2),
|
||||
operating_investments: operatingInvestments.toFixed(2),
|
||||
reserve_investments: reserveInvestments.toFixed(2),
|
||||
est_monthly_interest: estMonthlyInterest.toFixed(2),
|
||||
interest_earned_ytd: interestEarned[0]?.total || '0.00',
|
||||
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -795,4 +1039,168 @@ export class ReportsService {
|
||||
datapoints,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quarterly Financial Report: quarter income statement, YTD income statement,
|
||||
* budget vs actuals for the quarter and YTD, and over-budget items.
|
||||
*/
|
||||
async getQuarterlyFinancial(year: number, quarter: number) {
|
||||
// Quarter date ranges
|
||||
const qStartMonths = [1, 4, 7, 10];
|
||||
const qEndMonths = [3, 6, 9, 12];
|
||||
const qStart = `${year}-${String(qStartMonths[quarter - 1]).padStart(2, '0')}-01`;
|
||||
const qEndMonth = qEndMonths[quarter - 1];
|
||||
const qEndDay = [31, 30, 30, 31][quarter - 1]; // Mar=31, Jun=30, Sep=30, Dec=31
|
||||
const qEnd = `${year}-${String(qEndMonth).padStart(2, '0')}-${qEndDay}`;
|
||||
const ytdStart = `${year}-01-01`;
|
||||
|
||||
// Quarter and YTD income statements (reuse existing method)
|
||||
const quarterIS = await this.getIncomeStatement(qStart, qEnd);
|
||||
const ytdIS = await this.getIncomeStatement(ytdStart, qEnd);
|
||||
|
||||
// Budget data for the quarter months
|
||||
const budgetMonthCols = {
|
||||
1: ['jan', 'feb', 'mar'],
|
||||
2: ['apr', 'may', 'jun'],
|
||||
3: ['jul', 'aug', 'sep'],
|
||||
4: ['oct', 'nov', 'dec_amt'],
|
||||
} as Record<number, string[]>;
|
||||
const ytdMonthCols = {
|
||||
1: ['jan', 'feb', 'mar'],
|
||||
2: ['jan', 'feb', 'mar', 'apr', 'may', 'jun'],
|
||||
3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep'],
|
||||
4: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'],
|
||||
} as Record<number, string[]>;
|
||||
|
||||
const qCols = budgetMonthCols[quarter];
|
||||
const ytdCols = ytdMonthCols[quarter];
|
||||
|
||||
const budgetRows = await this.tenant.query(
|
||||
`SELECT b.account_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
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1`, [year],
|
||||
);
|
||||
|
||||
// Actual amounts per account for the quarter and YTD
|
||||
const quarterActuals = await this.tenant.query(`
|
||||
SELECT a.id as account_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
|
||||
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 je.entry_date BETWEEN $1 AND $2
|
||||
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||
`, [qStart, qEnd]);
|
||||
|
||||
const ytdActuals = await this.tenant.query(`
|
||||
SELECT a.id as account_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
|
||||
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 je.entry_date BETWEEN $1 AND $2
|
||||
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||
`, [ytdStart, qEnd]);
|
||||
|
||||
// Build budget vs actual comparison
|
||||
const actualsByIdQ = new Map<string, number>();
|
||||
for (const a of quarterActuals) {
|
||||
actualsByIdQ.set(a.account_id, parseFloat(a.amount) || 0);
|
||||
}
|
||||
const actualsByIdYTD = new Map<string, number>();
|
||||
for (const a of ytdActuals) {
|
||||
actualsByIdYTD.set(a.account_id, parseFloat(a.amount) || 0);
|
||||
}
|
||||
|
||||
const budgetVsActual: any[] = [];
|
||||
const overBudgetItems: any[] = [];
|
||||
|
||||
for (const b of budgetRows) {
|
||||
const qBudget = qCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
|
||||
const ytdBudget = ytdCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
|
||||
const qActual = actualsByIdQ.get(b.account_id) || 0;
|
||||
const ytdActual = actualsByIdYTD.get(b.account_id) || 0;
|
||||
|
||||
if (qBudget === 0 && ytdBudget === 0 && qActual === 0 && ytdActual === 0) continue;
|
||||
|
||||
const qVariance = qActual - qBudget;
|
||||
const ytdVariance = ytdActual - ytdBudget;
|
||||
const isExpense = b.account_type === 'expense';
|
||||
|
||||
const item = {
|
||||
account_id: b.account_id,
|
||||
account_number: b.account_number,
|
||||
name: b.name,
|
||||
account_type: b.account_type,
|
||||
fund_type: b.fund_type,
|
||||
quarter_budget: qBudget,
|
||||
quarter_actual: qActual,
|
||||
quarter_variance: qVariance,
|
||||
ytd_budget: ytdBudget,
|
||||
ytd_actual: ytdActual,
|
||||
ytd_variance: ytdVariance,
|
||||
};
|
||||
budgetVsActual.push(item);
|
||||
|
||||
// Flag expenses over budget by more than 10%
|
||||
if (isExpense && qBudget > 0 && qActual > qBudget * 1.1) {
|
||||
overBudgetItems.push({
|
||||
...item,
|
||||
variance_pct: ((qActual / qBudget - 1) * 100).toFixed(1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also include accounts with actuals but no budget
|
||||
for (const a of quarterActuals) {
|
||||
if (!budgetRows.find((b: any) => b.account_id === a.account_id)) {
|
||||
const ytdActual = actualsByIdYTD.get(a.account_id) || 0;
|
||||
budgetVsActual.push({
|
||||
account_id: a.account_id,
|
||||
account_number: a.account_number,
|
||||
name: a.name,
|
||||
account_type: a.account_type,
|
||||
fund_type: a.fund_type,
|
||||
quarter_budget: 0,
|
||||
quarter_actual: parseFloat(a.amount) || 0,
|
||||
quarter_variance: parseFloat(a.amount) || 0,
|
||||
ytd_budget: 0,
|
||||
ytd_actual: ytdActual,
|
||||
ytd_variance: ytdActual,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: income first, then expenses, both by account number
|
||||
budgetVsActual.sort((a: any, b: any) => {
|
||||
if (a.account_type !== b.account_type) return a.account_type === 'income' ? -1 : 1;
|
||||
return (a.account_number || '').localeCompare(b.account_number || '');
|
||||
});
|
||||
|
||||
return {
|
||||
year,
|
||||
quarter,
|
||||
quarter_label: `Q${quarter} ${year}`,
|
||||
date_range: { from: qStart, to: qEnd },
|
||||
quarter_income_statement: quarterIS,
|
||||
ytd_income_statement: ytdIS,
|
||||
budget_vs_actual: budgetVsActual,
|
||||
over_budget_items: overBudgetItems,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ export class User {
|
||||
@Column({ name: 'is_superadmin', default: false })
|
||||
isSuperadmin: boolean;
|
||||
|
||||
@Column({ name: 'is_platform_owner', default: false })
|
||||
isPlatformOwner: boolean;
|
||||
|
||||
@Column({ name: 'has_seen_intro', default: false })
|
||||
hasSeenIntro: boolean;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||
lastLoginAt: Date;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
@@ -50,13 +50,23 @@ export class UsersService {
|
||||
const dataSource = this.usersRepository.manager.connection;
|
||||
return dataSource.query(`
|
||||
SELECT o.*,
|
||||
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count
|
||||
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count,
|
||||
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_activity
|
||||
FROM shared.organizations o
|
||||
ORDER BY o.created_at DESC
|
||||
`);
|
||||
}
|
||||
|
||||
async markIntroSeen(id: string): Promise<void> {
|
||||
await this.usersRepository.update(id, { hasSeenIntro: true });
|
||||
}
|
||||
|
||||
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
||||
// Protect platform owner from having superadmin removed
|
||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||
if (user?.isPlatformOwner) {
|
||||
throw new ForbiddenException('Cannot modify platform owner superadmin status');
|
||||
}
|
||||
await this.usersRepository.update(userId, { isSuperadmin });
|
||||
}
|
||||
}
|
||||
|
||||
27
backend/src/modules/vendors/vendors.service.ts
vendored
27
backend/src/modules/vendors/vendors.service.ts
vendored
@@ -17,10 +17,10 @@ export class VendorsService {
|
||||
|
||||
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 *`,
|
||||
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id, last_negotiated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) 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],
|
||||
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null, dto.last_negotiated || null],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -32,24 +32,25 @@ export class VendorsService {
|
||||
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()
|
||||
default_account_id = COALESCE($12, default_account_id), last_negotiated = $13, 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],
|
||||
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id, dto.last_negotiated || null],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async exportCSV(): Promise<string> {
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible
|
||||
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated
|
||||
FROM vendors WHERE is_active = true ORDER BY name`,
|
||||
);
|
||||
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible'];
|
||||
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible', 'last_negotiated'];
|
||||
const lines = [headers.join(',')];
|
||||
for (const r of rows) {
|
||||
lines.push(headers.map((h) => {
|
||||
const v = r[h] ?? '';
|
||||
let v = r[h] ?? '';
|
||||
if (v instanceof Date) v = v.toISOString().split('T')[0];
|
||||
const s = String(v);
|
||||
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}).join(','));
|
||||
@@ -80,20 +81,22 @@ export class VendorsService {
|
||||
zip_code = COALESCE(NULLIF($8, ''), zip_code),
|
||||
tax_id = COALESCE(NULLIF($9, ''), tax_id),
|
||||
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
|
||||
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
|
||||
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible],
|
||||
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible, row.last_negotiated],
|
||||
);
|
||||
updated++;
|
||||
} else {
|
||||
await this.tenant.query(
|
||||
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[name, row.contact_name || null, row.email || null, row.phone || null,
|
||||
row.address_line1 || null, row.city || null, row.state || null,
|
||||
row.zip_code || null, row.tax_id || null,
|
||||
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false],
|
||||
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false,
|
||||
row.last_negotiated || null],
|
||||
);
|
||||
created++;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ CREATE TABLE shared.organizations (
|
||||
email VARCHAR(255),
|
||||
tax_id VARCHAR(20),
|
||||
fiscal_year_start_month INTEGER DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12),
|
||||
payment_date DATE,
|
||||
confirmation_number VARCHAR(100),
|
||||
renewal_date DATE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -45,6 +48,7 @@ CREATE TABLE shared.users (
|
||||
oauth_provider_id VARCHAR(255),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
is_superadmin BOOLEAN DEFAULT FALSE,
|
||||
is_platform_owner BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -73,6 +77,43 @@ CREATE TABLE shared.invitations (
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Market Rates (cross-tenant market data for investment recommendations)
|
||||
-- Supports CD, Money Market, and High Yield Savings rate types
|
||||
CREATE TABLE shared.cd_rates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
bank_name VARCHAR(255) NOT NULL,
|
||||
apy DECIMAL(6,4) NOT NULL,
|
||||
min_deposit DECIMAL(15,2),
|
||||
term VARCHAR(100) NOT NULL,
|
||||
term_months INTEGER,
|
||||
rate_type VARCHAR(50) NOT NULL DEFAULT 'cd',
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url VARCHAR(500),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Login history (track logins/org-switches for platform analytics)
|
||||
CREATE TABLE shared.login_history (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||
logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
-- AI recommendation log (track AI usage per tenant)
|
||||
CREATE TABLE shared.ai_recommendation_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_schema VARCHAR(63),
|
||||
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
|
||||
recommendation_count INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
status VARCHAR(20),
|
||||
requested_at TIMESTAMPTZ NOT NULL 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);
|
||||
@@ -80,3 +121,12 @@ 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);
|
||||
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
|
||||
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);
|
||||
CREATE INDEX idx_cd_rates_type ON shared.cd_rates(rate_type);
|
||||
CREATE INDEX idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
|
||||
CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC);
|
||||
CREATE INDEX idx_login_history_user ON shared.login_history(user_id);
|
||||
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);
|
||||
CREATE INDEX idx_ai_rec_log_org ON shared.ai_recommendation_log(organization_id);
|
||||
CREATE INDEX idx_ai_rec_log_time ON shared.ai_recommendation_log(requested_at DESC);
|
||||
|
||||
17
db/migrations/005-cd-rates.sql
Normal file
17
db/migrations/005-cd-rates.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Migration: Add CD rates table to shared schema
|
||||
-- For existing deployments that already have the shared schema initialized
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared.cd_rates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
bank_name VARCHAR(255) NOT NULL,
|
||||
apy DECIMAL(6,4) NOT NULL,
|
||||
min_deposit DECIMAL(15,2),
|
||||
term VARCHAR(100) NOT NULL,
|
||||
term_months INTEGER,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url VARCHAR(500),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cd_rates_apy ON shared.cd_rates(apy DESC);
|
||||
52
db/migrations/006-admin-platform.sql
Normal file
52
db/migrations/006-admin-platform.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- ============================================================
|
||||
-- Migration 006: Platform Administration Features
|
||||
-- Adds: is_platform_owner, subscription fields, login_history, ai_recommendation_log
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Add is_platform_owner to users
|
||||
ALTER TABLE shared.users
|
||||
ADD COLUMN IF NOT EXISTS is_platform_owner BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- 2. Add subscription fields to organizations
|
||||
ALTER TABLE shared.organizations
|
||||
ADD COLUMN IF NOT EXISTS payment_date DATE,
|
||||
ADD COLUMN IF NOT EXISTS confirmation_number VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS renewal_date DATE;
|
||||
|
||||
-- 3. Create login_history table
|
||||
CREATE TABLE IF NOT EXISTS shared.login_history (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||
logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_login_history_org_time
|
||||
ON shared.login_history(organization_id, logged_in_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_history_user
|
||||
ON shared.login_history(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_history_time
|
||||
ON shared.login_history(logged_in_at DESC);
|
||||
|
||||
-- 4. Create ai_recommendation_log table
|
||||
CREATE TABLE IF NOT EXISTS shared.ai_recommendation_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_schema VARCHAR(63),
|
||||
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
|
||||
recommendation_count INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
status VARCHAR(20),
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_rec_log_org
|
||||
ON shared.ai_recommendation_log(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_rec_log_time
|
||||
ON shared.ai_recommendation_log(requested_at DESC);
|
||||
|
||||
COMMIT;
|
||||
36
db/migrations/007-market-rates.sql
Normal file
36
db/migrations/007-market-rates.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- Migration: Expand cd_rates for multiple market rate types + tenant AI recommendation storage
|
||||
-- Phase 6: AI Features Part 2
|
||||
|
||||
-- 1) Add rate_type column to shared.cd_rates to support CD, Money Market, and High Yield Savings
|
||||
ALTER TABLE shared.cd_rates
|
||||
ADD COLUMN IF NOT EXISTS rate_type VARCHAR(50) DEFAULT 'cd' NOT NULL;
|
||||
|
||||
-- Index for filtering by rate type
|
||||
CREATE INDEX IF NOT EXISTS idx_cd_rates_type ON shared.cd_rates(rate_type);
|
||||
|
||||
-- Composite index for getting latest rates by type efficiently
|
||||
CREATE INDEX IF NOT EXISTS idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
|
||||
|
||||
-- 2) Create ai_recommendations table in each existing tenant schema
|
||||
-- This stores saved AI investment recommendations per tenant
|
||||
-- For new tenants, this is handled by tenant-schema.service.ts
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_schema TEXT;
|
||||
BEGIN
|
||||
FOR tenant_schema IN
|
||||
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS %I.ai_recommendations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
recommendations_json JSONB NOT NULL,
|
||||
overall_assessment TEXT,
|
||||
risk_notes JSONB,
|
||||
requested_by UUID,
|
||||
response_time_ms INTEGER,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', tenant_schema
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
16
db/migrations/008-vendor-last-negotiated.sql
Normal file
16
db/migrations/008-vendor-last-negotiated.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Migration: Add last_negotiated date to vendors table
|
||||
-- Bug & Tweak Sprint
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_schema TEXT;
|
||||
BEGIN
|
||||
FOR tenant_schema IN
|
||||
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'ALTER TABLE %I.vendors ADD COLUMN IF NOT EXISTS last_negotiated DATE',
|
||||
tenant_schema
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
9
db/migrations/009-onboarding-flags.sql
Normal file
9
db/migrations/009-onboarding-flags.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Migration: Add onboarding tracking flag to users table
|
||||
-- Phase 7: Onboarding Features
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE shared.users
|
||||
ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
|
||||
|
||||
COMMIT;
|
||||
34
db/migrations/010-health-scores.sql
Normal file
34
db/migrations/010-health-scores.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- Migration: Add health_scores table to all tenant schemas
|
||||
-- This table stores AI-derived operating and reserve fund health scores
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant RECORD;
|
||||
BEGIN
|
||||
FOR tenant IN
|
||||
SELECT schema_name FROM shared.organizations WHERE status = 'active'
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'CREATE TABLE IF NOT EXISTS %I.health_scores (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
score_type VARCHAR(20) NOT NULL CHECK (score_type IN (''operating'', ''reserve'')),
|
||||
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||
previous_score INTEGER,
|
||||
trajectory VARCHAR(20) CHECK (trajectory IN (''improving'', ''stable'', ''declining'')),
|
||||
label VARCHAR(30),
|
||||
summary TEXT,
|
||||
factors JSONB,
|
||||
recommendations JSONB,
|
||||
missing_data JSONB,
|
||||
status VARCHAR(20) NOT NULL DEFAULT ''complete'' CHECK (status IN (''complete'', ''pending'', ''error'')),
|
||||
response_time_ms INTEGER,
|
||||
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', tenant.schema_name
|
||||
);
|
||||
EXECUTE format(
|
||||
'CREATE INDEX IF NOT EXISTS idx_%s_hs_type_calc ON %I.health_scores(score_type, calculated_at DESC)',
|
||||
replace(tenant.schema_name, '.', '_'), tenant.schema_name
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
57
db/migrations/011-invoice-billing-frequency.sql
Normal file
57
db/migrations/011-invoice-billing-frequency.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- Migration 011: Add billing frequency support to invoices
|
||||
-- Adds due_months and due_day to assessment_groups
|
||||
-- Adds period_start, period_end, assessment_group_id to invoices
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_schema TEXT;
|
||||
BEGIN
|
||||
FOR v_schema IN
|
||||
SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name LIKE 'tenant_%'
|
||||
LOOP
|
||||
-- Add due_months and due_day to assessment_groups
|
||||
EXECUTE format('
|
||||
ALTER TABLE %I.assessment_groups
|
||||
ADD COLUMN IF NOT EXISTS due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
|
||||
ADD COLUMN IF NOT EXISTS due_day INTEGER DEFAULT 1
|
||||
', v_schema);
|
||||
|
||||
-- Add period tracking and assessment group link to invoices
|
||||
EXECUTE format('
|
||||
ALTER TABLE %I.invoices
|
||||
ADD COLUMN IF NOT EXISTS period_start DATE,
|
||||
ADD COLUMN IF NOT EXISTS period_end DATE,
|
||||
ADD COLUMN IF NOT EXISTS assessment_group_id UUID
|
||||
', v_schema);
|
||||
|
||||
-- Backfill due_months based on existing frequency values
|
||||
EXECUTE format('
|
||||
UPDATE %I.assessment_groups
|
||||
SET due_months = CASE frequency
|
||||
WHEN ''quarterly'' THEN ''{1,4,7,10}''::INTEGER[]
|
||||
WHEN ''annual'' THEN ''{1}''::INTEGER[]
|
||||
ELSE ''{1,2,3,4,5,6,7,8,9,10,11,12}''::INTEGER[]
|
||||
END
|
||||
WHERE due_months IS NULL OR due_months = ''{1,2,3,4,5,6,7,8,9,10,11,12}''
|
||||
AND frequency != ''monthly''
|
||||
', v_schema);
|
||||
|
||||
-- Backfill period_start/period_end for existing invoices (all monthly)
|
||||
EXECUTE format('
|
||||
UPDATE %I.invoices
|
||||
SET period_start = invoice_date,
|
||||
period_end = (invoice_date + INTERVAL ''1 month'' - INTERVAL ''1 day'')::DATE
|
||||
WHERE period_start IS NULL AND invoice_type = ''regular_assessment''
|
||||
', v_schema);
|
||||
|
||||
-- Backfill assessment_group_id on existing invoices from units
|
||||
EXECUTE format('
|
||||
UPDATE %I.invoices i
|
||||
SET assessment_group_id = u.assessment_group_id
|
||||
FROM %I.units u
|
||||
WHERE i.unit_id = u.id AND i.assessment_group_id IS NULL
|
||||
', v_schema, v_schema);
|
||||
|
||||
END LOOP;
|
||||
END $$;
|
||||
33
db/migrations/012-invoice-status-pending.sql
Normal file
33
db/migrations/012-invoice-status-pending.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Migration 012: Replace 'sent' status with 'pending' for invoices
|
||||
-- 'sent' implied email delivery which doesn't exist; 'pending' is more accurate
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_schema TEXT;
|
||||
v_constraint TEXT;
|
||||
BEGIN
|
||||
FOR v_schema IN
|
||||
SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name LIKE 'tenant_%'
|
||||
LOOP
|
||||
-- Find and drop the existing status check constraint
|
||||
SELECT constraint_name INTO v_constraint
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = v_schema
|
||||
AND table_name = 'invoices'
|
||||
AND constraint_type = 'CHECK'
|
||||
AND constraint_name LIKE '%status%';
|
||||
|
||||
IF v_constraint IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE %I.invoices DROP CONSTRAINT %I', v_schema, v_constraint);
|
||||
END IF;
|
||||
|
||||
-- Add new constraint that includes 'pending'
|
||||
EXECUTE format('ALTER TABLE %I.invoices ADD CONSTRAINT invoices_status_check CHECK (status IN (
|
||||
''draft'', ''pending'', ''sent'', ''paid'', ''partial'', ''overdue'', ''void'', ''written_off''
|
||||
))', v_schema);
|
||||
|
||||
-- Convert existing 'sent' invoices to 'pending'
|
||||
EXECUTE format('UPDATE %I.invoices SET status = ''pending'' WHERE status = ''sent''', v_schema);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -16,6 +16,31 @@
|
||||
-- Enable UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ============================================================
|
||||
-- 0. Create platform owner account (admin@hoaledgeriq.com)
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_platform_owner_id UUID;
|
||||
BEGIN
|
||||
SELECT id INTO v_platform_owner_id FROM shared.users WHERE email = 'admin@hoaledgeriq.com';
|
||||
IF v_platform_owner_id IS NULL THEN
|
||||
INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin, is_platform_owner)
|
||||
VALUES (
|
||||
uuid_generate_v4(),
|
||||
'admin@hoaledgeriq.com',
|
||||
-- bcrypt hash of platform owner password (cost 12)
|
||||
'$2b$12$QRJEJYsjy.24Va.57h13Te7UX7nMTN9hWhW19bwuCAkr1Dm0FWqrm',
|
||||
'Platform',
|
||||
'Admin',
|
||||
true,
|
||||
true
|
||||
) RETURNING id INTO v_platform_owner_id;
|
||||
END IF;
|
||||
-- Platform owner has NO org memberships — admin-only account
|
||||
RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (id: %)', v_platform_owner_id;
|
||||
END $$;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Create test user and organization
|
||||
-- ============================================================
|
||||
@@ -179,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
|
||||
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
||||
unit_count INTEGER DEFAULT 0,
|
||||
frequency VARCHAR(20) DEFAULT ''monthly'',
|
||||
due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
|
||||
due_day INTEGER DEFAULT 1,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)', v_schema);
|
||||
@@ -219,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices (
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
amount_paid DECIMAL(10,2) DEFAULT 0.00,
|
||||
status VARCHAR(20) DEFAULT ''draft'',
|
||||
period_start DATE,
|
||||
period_end DATE,
|
||||
assessment_group_id UUID,
|
||||
journal_entry_id UUID,
|
||||
sent_at TIMESTAMPTZ,
|
||||
paid_at TIMESTAMPTZ,
|
||||
@@ -418,10 +449,10 @@ END LOOP;
|
||||
-- ============================================================
|
||||
-- 4b. Seed Assessment Groups
|
||||
-- ============================================================
|
||||
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES
|
||||
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20),
|
||||
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15),
|
||||
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15)
|
||||
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day) VALUES
|
||||
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 15),
|
||||
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 1275.00, 0.00, 15, ''quarterly'', ''{1,4,7,10}'', 1),
|
||||
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1)
|
||||
', v_schema);
|
||||
|
||||
-- ============================================================
|
||||
@@ -836,7 +867,42 @@ EXECUTE format('INSERT INTO %I.capital_projects (name, description, estimated_co
|
||||
(''Perimeter Fence Repair'', ''Replace damaged fence sections and repaint'', 8000, $1 + 4, 8, ''planned'', ''reserve'', 4)
|
||||
', v_schema) USING v_year;
|
||||
|
||||
-- Add subscription data to the organization
|
||||
UPDATE shared.organizations
|
||||
SET payment_date = (CURRENT_DATE - INTERVAL '15 days')::DATE,
|
||||
confirmation_number = 'PAY-2026-SVH-001',
|
||||
renewal_date = (CURRENT_DATE + INTERVAL '350 days')::DATE
|
||||
WHERE schema_name = v_schema;
|
||||
|
||||
-- ============================================================
|
||||
-- 13. Seed login_history for demo analytics
|
||||
-- ============================================================
|
||||
-- Admin user: regular logins over the past 30 days
|
||||
FOR v_month IN 0..29 LOOP
|
||||
INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent)
|
||||
VALUES (
|
||||
v_user_id,
|
||||
v_org_id,
|
||||
NOW() - (v_month || ' days')::INTERVAL - (random() * 8 || ' hours')::INTERVAL,
|
||||
'192.168.1.' || (10 + (random() * 50)::INT),
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- Viewer user: occasional logins (every 3-5 days)
|
||||
FOR v_month IN 0..9 LOOP
|
||||
INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent)
|
||||
VALUES (
|
||||
(SELECT id FROM shared.users WHERE email = 'viewer@sunrisevalley.org'),
|
||||
v_org_id,
|
||||
NOW() - ((v_month * 3) || ' days')::INTERVAL - (random() * 12 || ' hours')::INTERVAL,
|
||||
'10.0.0.' || (100 + (random() * 50)::INT),
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!';
|
||||
RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (SuperAdmin + Platform Owner)';
|
||||
RAISE NOTICE 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)';
|
||||
RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)';
|
||||
|
||||
|
||||
95
docker-compose.prod.yml
Normal file
95
docker-compose.prod.yml
Normal file
@@ -0,0 +1,95 @@
|
||||
# Production override — use with:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
#
|
||||
# What this changes from the base (dev) config:
|
||||
# - Disables the Docker nginx container (host nginx handles routing + SSL)
|
||||
# - Backend: production Dockerfile (compiled JS, no watch, no devDeps)
|
||||
# - Frontend: production Dockerfile (static build served by nginx on port 3001)
|
||||
# - Backend + Frontend bound to 127.0.0.1 only (host nginx proxies to them)
|
||||
# - No source-code volume mounts (uses baked-in built code)
|
||||
# - Memory limits and health checks on backend
|
||||
# - Tuned PostgreSQL for production workloads
|
||||
# - Restart policies for reliability
|
||||
#
|
||||
# SSL/TLS and request routing are handled by the host-level nginx.
|
||||
# See nginx/host-production.conf for a ready-to-use reference config.
|
||||
|
||||
services:
|
||||
nginx:
|
||||
# Disabled in production — host nginx handles routing + SSL directly.
|
||||
# The dev-only Docker nginx is still used by the base docker-compose.yml.
|
||||
deploy:
|
||||
replicas: 0
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile # production Dockerfile (compiled JS)
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # loopback only — host nginx proxies here
|
||||
volumes: [] # override: no source mounts in prod
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- NODE_ENV=production
|
||||
- AI_API_URL=${AI_API_URL}
|
||||
- AI_API_KEY=${AI_API_KEY}
|
||||
- AI_MODEL=${AI_MODEL}
|
||||
- AI_DEBUG=${AI_DEBUG:-false}
|
||||
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1024M
|
||||
reservations:
|
||||
memory: 256M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile # production Dockerfile (static nginx)
|
||||
ports:
|
||||
- "127.0.0.1:3001:3001" # loopback only — host nginx proxies here
|
||||
volumes: [] # override: no source mounts in prod
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
# Tune PostgreSQL for production workloads
|
||||
command: >
|
||||
postgres
|
||||
-c max_connections=200
|
||||
-c shared_buffers=256MB
|
||||
-c effective_cache_size=512MB
|
||||
-c work_mem=4MB
|
||||
-c maintenance_work_mem=64MB
|
||||
-c checkpoint_completion_target=0.9
|
||||
-c wal_buffers=16MB
|
||||
-c random_page_cost=1.1
|
||||
# No host port mapping — backend reaches postgres via the Docker network.
|
||||
# Removes 2 docker-proxy processes and closes 0.0.0.0:5432 to the internet.
|
||||
ports: []
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1024M
|
||||
reservations:
|
||||
memory: 512M
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
# No host port mapping — backend reaches redis via the Docker network.
|
||||
# Removes 2 docker-proxy processes and closes 0.0.0.0:6379 to the internet.
|
||||
ports: []
|
||||
restart: unless-stopped
|
||||
28
docker-compose.ssl.yml
Normal file
28
docker-compose.ssl.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
# SSL override — use with: docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d
|
||||
#
|
||||
# This adds port 443, certbot volumes, and a certbot renewal service
|
||||
# to the base docker-compose.yml configuration.
|
||||
|
||||
services:
|
||||
nginx:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/ssl.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- certbot_www:/var/www/certbot:ro
|
||||
- certbot_conf:/etc/letsencrypt:ro
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot:latest
|
||||
volumes:
|
||||
- certbot_www:/var/www/certbot
|
||||
- certbot_conf:/etc/letsencrypt
|
||||
networks:
|
||||
- hoanet
|
||||
# Auto-renew: check twice daily, only renews if < 30 days remain
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done'"
|
||||
|
||||
volumes:
|
||||
certbot_www:
|
||||
certbot_conf:
|
||||
@@ -15,13 +15,20 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
# No host port mapping — dev traffic goes through the Docker nginx container.
|
||||
# Production overlay maps 127.0.0.1:3000 for the host reverse proxy.
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
- AI_API_URL=${AI_API_URL}
|
||||
- AI_API_KEY=${AI_API_KEY}
|
||||
- AI_MODEL=${AI_MODEL}
|
||||
- AI_DEBUG=${AI_DEBUG:-false}
|
||||
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||
volumes:
|
||||
- ./backend/src:/app/src
|
||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||
@@ -39,8 +46,8 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "5173:5173"
|
||||
# No host port mapping — dev traffic goes through the Docker nginx container.
|
||||
# Production overlay maps 127.0.0.1:3001 for the host reverse proxy.
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
volumes:
|
||||
|
||||
545
docs/AI_FEATURE_AUDIT.md
Normal file
545
docs/AI_FEATURE_AUDIT.md
Normal file
@@ -0,0 +1,545 @@
|
||||
# AI Feature Audit Report
|
||||
|
||||
**Audit Date:** 2026-03-05
|
||||
**Tenant Under Test:** Pine Creek HOA (`tenant_pine_creek_hoa_q33i`)
|
||||
**AI Model:** Qwen 3.5-397B-A17B via NVIDIA NIM (Temperature: 0.3)
|
||||
**Auditor:** Claude Opus 4.6 (automated)
|
||||
**Data Snapshot Date:** 2026-03-04
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Three AI-powered features were audited against ground-truth database records: **Operating Fund Health**, **Reserve Fund Health**, and **Investment Recommendations**. Overall, the AI demonstrates strong financial reasoning and produces actionable, fiduciary-appropriate recommendations. However, score consistency across runs is a concern (16-point spread on operating, 20-point spread on reserve), and several specific data interpretation issues were identified.
|
||||
|
||||
| Feature | Latest Score/Grade | Concurrence | Verdict |
|
||||
|---|---|---|---|
|
||||
| Operating Fund Health | 88 / Good | **72%** | Score ~10-15 pts high; cash runway below its own "Good" threshold |
|
||||
| Reserve Fund Health | 45 / Needs Attention | **85%** | Well-calibrated; minor data misquote on annual contributions |
|
||||
| Investment Recommendations | 6 recommendations | **88%** | Excellent specificity; all market rates verified accurate |
|
||||
|
||||
---
|
||||
|
||||
## Data Foundation (Ground Truth)
|
||||
|
||||
### Financial Position
|
||||
|
||||
| Metric | Value | Source |
|
||||
|---|---|---|
|
||||
| Operating Cash (Checking) | $27,418.81 | GL balance |
|
||||
| Reserve Cash (Savings) | $10,688.45 | GL balance |
|
||||
| Reserve CD #1a (FCB) | $10,000 @ 3.67%, matures 6/19/26 | `investment_accounts` |
|
||||
| Reserve CD #2a (FCB) | $8,000 @ 3.60%, matures 4/14/26 | `investment_accounts` |
|
||||
| Reserve CD #3a (FCB) | $10,000 @ 3.67%, matures 8/18/26 | `investment_accounts` |
|
||||
| Total Reserve Fund | $38,688.45 | Cash + Investments |
|
||||
| Total Assets | $66,107.26 | Operating + Reserve |
|
||||
|
||||
### Budget (FY2026)
|
||||
|
||||
| Category | Annual Total |
|
||||
|---|---|
|
||||
| Operating Income | $184,207.40 |
|
||||
| Operating Expense | $139,979.95 |
|
||||
| **Net Operating Surplus** | **$44,227.45** |
|
||||
| Monthly Expense Run Rate | $11,665.00 |
|
||||
| Reserve Interest Income | $1,449.96 |
|
||||
| Reserve Disbursements | $22,000.00 (Mar $13K, Apr $9K) |
|
||||
|
||||
### Assessment Structure
|
||||
|
||||
- **67 units** at $2,328.14/year regular + $300.00/year special (annual frequency)
|
||||
- Total annual regular assessments: ~$155,985
|
||||
- Total annual special assessments: ~$20,100
|
||||
- Budget timing: assessments front-loaded in Mar-Jun
|
||||
|
||||
### Actuals (YTD through March 4, 2026)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| YTD Income | $88.16 (ARC fees $100 - $50 adj + $38.16 interest) |
|
||||
| YTD Expenses | $1,850.42 (January only) |
|
||||
| Delinquent Invoices | 0 ($0.00) |
|
||||
| Journal Entries Posted | 4 (Jan actuals + Feb adjusting + Feb opening balances) |
|
||||
|
||||
### Capital Projects (from `projects` table, 26 total)
|
||||
|
||||
| Project | Cost | Target | Funded % |
|
||||
|---|---|---|---|
|
||||
| Pond Spillway | $7,000 | Mar 2026 | 0% |
|
||||
| Tuscany Drain Box | $5,500 | May 2026 | 0% |
|
||||
| Front Entrance Power Washing | $1,500 | Mar 2027 | 0% |
|
||||
| Irrigation Pump Replacement | $1,500 | Jun 2027 | 0% |
|
||||
| **Road Sealing - All Roads** | **$80,000** | **Jun 2029** | **0%** |
|
||||
| Asphalt Repair - Creek Stone Dr | $43,000 | TBD | 0% |
|
||||
| Pavilion & Vineyard Structures | $7,000 | Jun 2035 | 0% |
|
||||
| 16 placeholder items | $1.00 each | TBD | 0% |
|
||||
| **Total Planned** | **$152,016** | | **0%** |
|
||||
|
||||
### Reserve Components
|
||||
|
||||
- **0 components tracked** (empty `reserve_components` table)
|
||||
|
||||
### Market Rates (fetched 2026-03-04)
|
||||
|
||||
| Type | Top Rate | Bank | Term |
|
||||
|---|---|---|---|
|
||||
| CD | 4.10% | E*TRADE / Synchrony | 12-14 mo |
|
||||
| High-Yield Savings | 4.09% | Openbank | Liquid |
|
||||
| Money Market | 4.03% | Vio Bank | Liquid |
|
||||
|
||||
---
|
||||
|
||||
## 1. Operating Fund Health Score
|
||||
|
||||
**Latest Score:** 88 (Good) — Generated 2026-03-04T19:24:36Z
|
||||
**Score History:** 48 → 72 → 78 → 72 → 78 → **88** (6 runs, March 2-4)
|
||||
**Overall Concurrence: 72%**
|
||||
|
||||
### Factor-by-Factor Analysis
|
||||
|
||||
#### Factor 1: "Projected Cash Flow" — Impact: Positive
|
||||
> "12-month forecast shows consistent positive liquidity, with cash balances never dipping below the starting $27,419 and peaking at $142,788 in June."
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Budget surplus ($184K income vs $140K expense) | **Verified** ✅ |
|
||||
| Assessments front-loaded Mar-Jun | **Verified** ✅ (budget shows $48K Mar, $64K Apr, $32K May, $16K Jun) |
|
||||
| Peak of ~$142K in June | **Plausible** ✅ ($27K + cumulative income through June) |
|
||||
| Cash never below starting $27K | **Plausible** ✅ (expenses < income by month) |
|
||||
|
||||
**Concurrence: 95%** — Forecast logic is sound. The only risk is the assumption that assessments are collected on the exact budget schedule.
|
||||
|
||||
---
|
||||
|
||||
#### Factor 2: "Delinquency Rate" — Impact: Positive
|
||||
> "$0.00 in overdue invoices and a 0.0% delinquency rate."
|
||||
|
||||
**Concurrence: 100%** ✅ — Database confirms zero delinquent invoices.
|
||||
|
||||
---
|
||||
|
||||
#### Factor 3: "Budget Performance (Timing)" — Impact: Neutral
|
||||
> "YTD income is 99.8% below budget ($55k variance) primarily due to the timing of the large Special Assessment ($20,700) and regular assessments appearing in future projected months."
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| YTD income $88.16 | **Verified** ✅ |
|
||||
| Budget includes March ($55K) in YTD calc | **Accurate** — AI uses month 3 of 12, includes full March budget |
|
||||
| Timing explanation | **Reasonable** — we're only 4 days into March |
|
||||
| Rating as "neutral" vs "negative" | **Appropriate** ✅ — correctly avoids penalizing for calendar timing |
|
||||
|
||||
**Concurrence: 80%** — The variance is accurately computed but presenting a $55K "variance" when we're 4 days into March could alarm a board member. The YTD window through month 3 includes all of March's budget despite only 4 days having elapsed. Consider computing YTD budget pro-rata or through the prior complete month.
|
||||
|
||||
**🔧 Tuning Suggestion:** Add a note to the prompt about pro-rating the current month's budget, or instruct the AI to note "X days into the current month" when the variance is driven by incomplete-month timing.
|
||||
|
||||
---
|
||||
|
||||
#### Factor 4: "Cash Reserves" — Impact: Positive
|
||||
> "Current operating cash of $27,419 provides 2.4 months of runway based on the annual expense run rate."
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| $27,419 / ($139,980 / 12) = 2.35 months | **Math verified** ✅ |
|
||||
| Rated as "positive" | **Questionable** ⚠️ |
|
||||
|
||||
**Concurrence: 60%** — The math is correct, but rating 2.4 months as "positive" contradicts the scoring guidelines which state 2-3 months = "Fair" (60-74) and 3-6 months = "Good" (75-89). This factor should be "neutral" at best, and the overall score should reflect that the HOA is *below* the "Good" threshold for cash reserves.
|
||||
|
||||
**🔧 Tuning Suggestion:** Add explicit guidance in the prompt: "If cash runway is below 3 months, this factor MUST be neutral or negative, regardless of projected future inflows."
|
||||
|
||||
---
|
||||
|
||||
#### Factor 5: "Expense Management" — Impact: Positive
|
||||
> "YTD expenses are $36,313 under budget (4.8% of annual budget spent vs 25% of year elapsed)."
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| YTD expenses $1,850.42 | **Verified** ✅ |
|
||||
| Budget YTD (3 months): ~$38,164 | **Correct** ✅ |
|
||||
| $1,850 / $38,164 = 4.85% | **Math verified** ✅ |
|
||||
| "25% of year elapsed" | **Correct** (month 3 of 12) |
|
||||
| Phrasing "of annual budget" | **Misleading** ⚠️ — it's actually 4.8% of YTD budget, not annual |
|
||||
|
||||
**Concurrence: 70%** — The percentage is correctly calculated against YTD budget, but the phrasing "of annual budget" is incorrect. Also, the low spend is not necessarily positive — only January actuals exist; February hasn't been posted yet, which the AI partially acknowledges with "or delayed billing cycles."
|
||||
|
||||
---
|
||||
|
||||
### Recommendation Assessment
|
||||
|
||||
| # | Recommendation | Priority | Concurrence |
|
||||
|---|---|---|---|
|
||||
| 1 | "Verify the posting schedule for the $20,700 Special Assessment" | Low | **90%** ✅ Valid; assessments are annual, collection timing matters |
|
||||
| 2 | "Investigate the low YTD expense recognition ($1,850 vs $38,164)" | Medium | **95%** ✅ Excellent catch; Feb expenses not posted yet |
|
||||
| 3 | "Consider moving excess cash over $100K in Q2 to interest-bearing account" | Low | **85%** ✅ Sound advice; aligns with HY Savings at 4.09% |
|
||||
|
||||
**Recommendation Concurrence: 90%** — All three recommendations are actionable and data-backed.
|
||||
|
||||
---
|
||||
|
||||
### Score Assessment
|
||||
|
||||
**Is 88 (Good) the right score?**
|
||||
|
||||
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|
||||
|---|---|---|---|
|
||||
| Cash reserves | 3-6 months for "Good" | 2.4 months | ❌ Below threshold |
|
||||
| Income vs expenses | "Roughly matching" for Good | $184K vs $140K (surplus) | ✅ Exceeds |
|
||||
| Delinquency | "Manageable" for Good | 0% | ✅ Excellent |
|
||||
| Budget performance | No major overruns for Good | Under budget (timing) | ✅ Positive |
|
||||
| Projected cash flow | Not explicitly in guidelines | Strong positive trajectory | ✅ Positive |
|
||||
|
||||
The cash runway of 2.4 months is below the stated "Good" (75-89) threshold of 3-6 months and technically falls in the "Fair" (60-74) range of 2-3 months. Earlier AI runs scored this 72-78, which better aligns with the guidelines. The 88 appears to overweight the projected future cash flow (which is speculative) vs the current actual position.
|
||||
|
||||
**Suggested correct score: 74-80** (high end of Fair to low end of Good)
|
||||
|
||||
---
|
||||
|
||||
### Score Consistency Concern
|
||||
|
||||
| Run Date | Score | Label |
|
||||
|---|---|---|
|
||||
| Mar 2 15:07 | 48 | Needs Attention |
|
||||
| Mar 2 15:12 | 78 | Good |
|
||||
| Mar 2 15:36 | 72 | Fair |
|
||||
| Mar 2 17:09 | 78 | Good |
|
||||
| Mar 3 02:03 | 72 | Fair |
|
||||
| Mar 4 19:24 | 88 | Good |
|
||||
|
||||
A **40-point spread** (48-88) across 6 runs with essentially the same data is concerning. Even excluding the outlier first run (which noted a data config issue with "1 units"), the remaining 5 runs span 72-88 (16 points). At temperature 0.3, this suggests the model is not deterministic enough for financial scoring.
|
||||
|
||||
**🔧 Tuning Suggestion:** Consider lowering temperature to 0.1 for health score calculations to improve consistency. Alternatively, implement a moving average of the last 3 scores to smooth volatility.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reserve Fund Health Score
|
||||
|
||||
**Latest Score:** 45 (Needs Attention) — Generated 2026-03-04T19:24:50Z
|
||||
**Score History:** 25 → 48 → 42 → 25 → 45 → 35 → **45** (7 runs, March 2-4)
|
||||
**Overall Concurrence: 85%**
|
||||
|
||||
### Factor-by-Factor Analysis
|
||||
|
||||
#### Factor 1: "Funded Ratio" — Impact: Negative
|
||||
> "Calculated at 0% because no reserve components have been inventoried or assigned replacement costs, making it impossible to measure true funding health against the $152,016 in planned projects."
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| 0 reserve components in DB | **Verified** ✅ |
|
||||
| $152,016 in planned projects | **Verified** ✅ (sum of all `projects` rows) |
|
||||
| 0% funded ratio | **Technically accurate** ✅ (no denominator from components) |
|
||||
| Distinction between components and projects | **Well articulated** ✅ |
|
||||
|
||||
**Concurrence: 95%** — The AI correctly identifies that the 0% is an artifact of missing reserve study data, not a literal lack of funds. It appropriately flags this as a governance failure.
|
||||
|
||||
---
|
||||
|
||||
#### Factor 2: "Projected Cash Flow" — Impact: Positive
|
||||
> "Strong immediate liquidity; cash balance is projected to rise from $10,688 to over $49,000 by May 2026 due to special assessment income covering the $12,500 in urgent 2026 project costs."
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Starting reserve cash $10,688 | **Verified** ✅ |
|
||||
| 2026 project costs: $7K (Mar) + $5.5K (May) = $12,500 | **Verified** ✅ |
|
||||
| Special assessment: $300 × 67 = $20,100/year | **Verified** ✅ |
|
||||
| CD maturities: $8K (Apr), $10K (Jun), $10K (Aug) | **Verified** ✅ |
|
||||
| Projected rise to $49K by May | **Plausible** ✅ (income + maturities - project costs) |
|
||||
|
||||
**Concurrence: 85%** — Math is directionally correct. However, the assessment is annual frequency so the full $20,100 may arrive in a single payment, not spread monthly. The timing assumption is critical.
|
||||
|
||||
---
|
||||
|
||||
#### Factor 3: "Component Tracking" — Impact: Negative
|
||||
> "Critical failure in governance: 'No reserve components tracked' means the association is flying blind on the condition and remaining useful life of major assets like roads and irrigation."
|
||||
|
||||
**Concurrence: 100%** ✅ — Database confirms 0 rows in `reserve_components`. This is objectively a critical gap.
|
||||
|
||||
---
|
||||
|
||||
#### Factor 4: "Annual Contributions" — Impact: Negative
|
||||
> "Recurring annual reserve income is only $300 (plus minimal interest), which is grossly insufficient to fund the $80,000 road sealing project due in 2029."
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Reserve budget income: $1,449.96/yr (interest only) | **Verified** ✅ |
|
||||
| Special assessment: $300/unit × 67 = $20,100/yr | **Verified** ✅ |
|
||||
| "$300" cited as annual reserve income | **Incorrect** ⚠️ |
|
||||
| Road Sealing $80K in June 2029 | **Verified** ✅ |
|
||||
|
||||
**Concurrence: 65%** — The concern about insufficient contributions is valid, but the "$300" figure appears to confuse the per-unit special assessment amount ($300/unit) with the total annual reserve income. Actual annual reserve income = $1,450 (interest) + $20,100 (special assessments) = **$21,550/yr**. Even at $21,550/yr, the 3 years until Road Sealing would accumulate ~$64,650, still short of $80K. So the directional concern is correct, but the magnitude is significantly misstated.
|
||||
|
||||
**🔧 Tuning Suggestion:** The prompt should explicitly label the special assessment income total (not per-unit) in the data context. Currently the data says "$300.00/unit × 67 units (annual)" — the AI should compute $20,100 but sometimes fixates on the $300 per-unit figure. Consider pre-computing and passing the total.
|
||||
|
||||
---
|
||||
|
||||
### Recommendation Assessment
|
||||
|
||||
| # | Recommendation | Priority | Concurrence |
|
||||
|---|---|---|---|
|
||||
| 1 | "Commission a professional Reserve Study to inventory assets and establish funded ratio" | High | **100%** ✅ Critical and universally correct |
|
||||
| 2 | "Develop a long-term funding plan for the $80,000 Road Sealing project (2029)" | High | **90%** ✅ Verified project exists; $80K with 0% funded |
|
||||
| 3 | "Formalize collection of special assessments into the reserve fund vs operating" | Medium | **95%** ✅ Budget shows special assessments in operating income section |
|
||||
|
||||
**Recommendation Concurrence: 95%** — All recommendations are actionable, appropriately prioritized, and backed by database evidence.
|
||||
|
||||
---
|
||||
|
||||
### Score Assessment
|
||||
|
||||
**Is 45 (Needs Attention) the right score?**
|
||||
|
||||
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|
||||
|---|---|---|---|
|
||||
| Percent funded | 20-30% for "Needs Attention" | 0% (no components) | ⬇️ Worse than threshold |
|
||||
| Contributions | "Inadequate" for Needs Attention | $21,550/yr for $152K in projects | ⚠️ Borderline |
|
||||
| Component tracking | "Multiple urgent unfunded" | 0 tracked, 2 due in 2026 | ❌ Critical gap |
|
||||
| Investments | Not scored negatively | 3 CDs earning 3.6-3.67% | ✅ Positive |
|
||||
| Capital readiness | | $12.5K due soon, only $10.7K cash | ⚠️ Tight |
|
||||
|
||||
A score of 45 is reasonable. The 0% funded ratio technically suggests "At Risk" (20-39), but the presence of real assets ($38.7K), active investments, and manageable near-term liquidity justifies bumping it into the "Needs Attention" band. The AI's balancing of the artificial 0% metric against actual fund health shows good judgment.
|
||||
|
||||
**Suggested correct score: 40-50** — the AI's 45 is well-calibrated.
|
||||
|
||||
---
|
||||
|
||||
### Score Consistency Concern
|
||||
|
||||
| Run Date | Score | Label |
|
||||
|---|---|---|
|
||||
| Mar 2 15:06 | 25 | At Risk |
|
||||
| Mar 2 15:13 | 25 | At Risk |
|
||||
| Mar 2 15:37 | 48 | Needs Attention |
|
||||
| Mar 2 17:10 | 42 | Needs Attention |
|
||||
| Mar 3 02:04 | 45 | Needs Attention |
|
||||
| Mar 4 18:49 | 35 | At Risk |
|
||||
| Mar 4 19:24 | 45 | Needs Attention |
|
||||
|
||||
A **23-point spread** (25-48) across 7 runs. The scores oscillate between "At Risk" and "Needs Attention" — the model cannot consistently decide which band this falls into. The most recent 3 runs (35, 45, 45) are more stable.
|
||||
|
||||
**🔧 Tuning Suggestion:** Add boundary guidance to the prompt: "When the score falls within ±5 points of a threshold (40, 60, 75, 90), explicitly justify which side of the boundary the HOA falls on."
|
||||
|
||||
---
|
||||
|
||||
## 3. AI Investment Recommendations
|
||||
|
||||
**Latest Run:** 2026-03-04T19:28:22Z (3 runs saved)
|
||||
**Overall Concurrence: 88%**
|
||||
|
||||
### Overall Assessment
|
||||
> "The HOA has a healthy long-term cash flow outlook with significant surpluses projected by mid-2026, but faces an immediate liquidity pinch in the Reserve Fund for March/April capital projects. The current investment strategy relies on older, lower-yielding CDs (3.60-3.67%) that are maturing soon."
|
||||
|
||||
**Concurrence: 92%** ✅ — Every claim verified:
|
||||
- CDs are at 3.60-3.67% vs market 4.10% (verified)
|
||||
- March project ($7K) vs reserve cash ($10.7K) is tight (verified)
|
||||
- Long-term surplus projected from assessment income (verified from budget)
|
||||
|
||||
---
|
||||
|
||||
### Recommendation-by-Recommendation Analysis
|
||||
|
||||
#### Rec 1: "Critical Reserve Shortfall for March Project" — HIGH / Liquidity Warning
|
||||
|
||||
| Claim | Database Value | Match |
|
||||
|---|---|---|
|
||||
| Reserve cash = $10,688 | $10,688.45 | ✅ Exact |
|
||||
| $7,000 Pond Spillway project due March | Projects table: $7,000, Mar 2026 | ✅ Exact |
|
||||
| Shortfall risk | $10,688 - $7,000 = $3,688 remaining — tight but feasible | ✅ |
|
||||
| Suggested action: expedite special assessment or transfer from operating | Sound advice | ✅ |
|
||||
|
||||
**Concurrence: 90%** — The liquidity concern is real. After paying the $7K project, only $3.7K would remain in reserve cash before the $5.5K May project. The AI correctly flags the timing risk even though the fund is technically solvent.
|
||||
|
||||
---
|
||||
|
||||
#### Rec 2: "Reinvest Maturing CD #2a at Higher Rate" — HIGH / Maturity Action
|
||||
|
||||
| Claim | Database Value | Match |
|
||||
|---|---|---|
|
||||
| CD #2a = $8,000 | $8,000.00 | ✅ Exact |
|
||||
| Current rate = 3.60% | 3.60% | ✅ Exact |
|
||||
| Maturity = April 14, 2026 | 2026-04-14 | ✅ Exact |
|
||||
| Market rate = 4.10% (E*TRADE) | CD rates: E*TRADE 4.10%, 1 year, $0 min | ✅ Exact |
|
||||
| Additional yield: ~$40/year per $8K | $8K × 0.50% = $40 | ✅ Math correct |
|
||||
|
||||
**Concurrence: 95%** ✅ — Textbook-correct recommendation. Every data point verified. The 50 bps improvement is risk-free income.
|
||||
|
||||
---
|
||||
|
||||
#### Rec 3: "Establish 12-Month CD Ladder for Reserves" — MEDIUM / CD Ladder
|
||||
|
||||
| Claim | Database Value | Match |
|
||||
|---|---|---|
|
||||
| ~$38K total reserve portfolio | $38,688.45 | ✅ Exact |
|
||||
| Suggest 4-rung ladder (3/6/9/12 mo) | Standard strategy | ✅ |
|
||||
| Rates up to 4.10% | Market data confirmed | ✅ |
|
||||
| $9K matures every quarter | $38K / 4 = $9.5K per rung | ✅ Approximate |
|
||||
|
||||
**Concurrence: 75%** — Strategy is sound in principle, but the recommendation overlooks two constraints:
|
||||
1. **Immediate project costs ($12.5K in 2026)** must be reserved first, leaving ~$26K for laddering
|
||||
2. **Investing the entire $38K** is aggressive — some cash buffer should remain liquid
|
||||
|
||||
**🔧 Tuning Suggestion:** Add a constraint to the prompt: "When recommending CD ladders, always subtract upcoming project costs (next 12 months) and a minimum emergency reserve (1 month of budgeted reserve expenses) before calculating the investable amount."
|
||||
|
||||
---
|
||||
|
||||
#### Rec 4: "Deploy Excess Operating Cash to High-Yield Savings" — MEDIUM / New Investment
|
||||
|
||||
| Claim | Database Value | Match |
|
||||
|---|---|---|
|
||||
| Operating cash = $27,418 | $27,418.81 | ✅ Exact |
|
||||
| 3-month buffer = ~$35,000 | $11,665 × 3 = $34,995 | ✅ Math correct |
|
||||
| Current cash below buffer | $27.4K < $35K | ✅ Correctly identified |
|
||||
| Openbank 4.09% APY | Market data: Openbank 4.09%, $0.01 min | ✅ Exact |
|
||||
| Trigger: "As soon as balance exceeds $35K" | Sound deferred recommendation | ✅ |
|
||||
|
||||
**Concurrence: 90%** ✅ — The AI correctly identifies the current shortfall and provides a forward-looking trigger. Well-structured advice that respects the liquidity constraint.
|
||||
|
||||
---
|
||||
|
||||
#### Rec 5: "Optimize Reserve Cash Yield Post-Project" — LOW / Reallocation
|
||||
|
||||
| Claim | Database Value | Match |
|
||||
|---|---|---|
|
||||
| Vio Bank Money Market at 4.03% | Market data: Vio Bank 4.03%, $0 min | ✅ Exact |
|
||||
| Post-project reserve cash deployment | Appropriate timing | ✅ |
|
||||
| T+1 liquidity for emergencies | Correct MM account characteristic | ✅ |
|
||||
|
||||
**Concurrence: 85%** ✅ — Reasonable low-priority optimization. Correctly uses market data.
|
||||
|
||||
---
|
||||
|
||||
#### Rec 6: "Formalize Special Assessment Collection for Reserves" — LOW / General
|
||||
|
||||
| Claim | Database Value | Match |
|
||||
|---|---|---|
|
||||
| $300/unit special assessment | Assessment groups: $300.00 special | ✅ Exact |
|
||||
| Risk of commingling with operating | Budget shows special assessments in operating income | ✅ Identified |
|
||||
|
||||
**Concurrence: 90%** ✅ — Important governance recommendation. The budget structure does show special assessments as operating income, which could lead to improper fund commingling.
|
||||
|
||||
---
|
||||
|
||||
### Risk Notes Assessment
|
||||
|
||||
| Risk Note | Verified | Concurrence |
|
||||
|---|---|---|
|
||||
| "Reserve cash ($10.6K) barely sufficient for $7K + $5.5K projects" | ✅ $10,688 vs $12,500 in projects | **95%** |
|
||||
| "Concentration risk: CDs maturing in 4-month window (Apr-Aug)" | ✅ All 3 CDs mature Apr-Aug 2026 | **100%** |
|
||||
| "Operating cash ballooning to $140K+ without investment plan" | ✅ Budget shows large Q2 surplus | **85%** |
|
||||
| "Road Sealing $80K in 2029 needs dedicated savings plan" | ✅ Project exists, 0% funded | **95%** |
|
||||
|
||||
**Risk Notes Concurrence: 94%** — All risk items are data-backed and appropriately flagged.
|
||||
|
||||
---
|
||||
|
||||
### Cross-Run Consistency (Investment Recommendations)
|
||||
|
||||
Three runs were compared. Key observations:
|
||||
- **Core recommendations are highly consistent** across runs: CD reinvestment, HY savings for operating, CD ladder for reserves
|
||||
- **Dollar amounts match exactly** across all runs (same data inputs)
|
||||
- **Bank name recommendations vary slightly** (E*TRADE vs "Top CD Rate") — cosmetic, not substantive
|
||||
- **Priority levels are stable** (HIGH for liquidity warnings, MEDIUM for optimization)
|
||||
|
||||
**Consistency Grade: A-** — Investment recommendations show much better consistency than health scores, likely because the structured data (specific CDs, specific rates) constrains the output more than the subjective health scoring.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Issues
|
||||
|
||||
### Issue 1: Score Volatility (MEDIUM Priority)
|
||||
|
||||
Health scores vary significantly across runs despite identical input data:
|
||||
- Operating: 40-point spread (48-88)
|
||||
- Reserve: 23-point spread (25-48)
|
||||
|
||||
**Root Cause:** Temperature 0.3 allows too much variance for numerical scoring. The model interprets guidelines subjectively.
|
||||
|
||||
**Recommended Fix:**
|
||||
1. Reduce temperature to **0.1** for health score calculations
|
||||
2. Implement a **3-run moving average** to smooth individual run variance
|
||||
3. Add explicit **boundary justification** requirements to prompts
|
||||
|
||||
### Issue 2: YTD Budget Calculation Includes Incomplete Month (LOW Priority)
|
||||
|
||||
The operating health score computes YTD budget through the current month (March), but actual data may only cover a few days. This creates alarming income variances (e.g., "$55K variance") that are pure timing artifacts.
|
||||
|
||||
**Recommended Fix:**
|
||||
- Compute YTD budget through the **prior completed month** (February)
|
||||
- OR pro-rate the current month's budget by days elapsed
|
||||
- Add a note to the prompt: "If the variance is driven by the current incomplete month, flag it as 'timing' and weight it minimally."
|
||||
|
||||
### Issue 3: Per-Unit vs Total Confusion on Special Assessments (LOW Priority)
|
||||
|
||||
The AI sometimes quotes "$300" as the annual reserve income instead of $300 × 67 = $20,100. The data passed says "$300.00/unit × 67 units (annual)" but the model occasionally fixates on the per-unit figure.
|
||||
|
||||
**Recommended Fix:**
|
||||
- Pre-compute and include the total in the data: "Total Annual Special Assessment Income: $20,100.00"
|
||||
- Keep the per-unit breakdown for context but lead with the total
|
||||
|
||||
### Issue 4: Cash Runway Classification Inconsistency (MEDIUM Priority)
|
||||
|
||||
The operating health score rates 2.4 months of cash runway as "positive" despite the scoring guidelines defining 2-3 months as "Fair" territory. This inflates the overall score.
|
||||
|
||||
**Recommended Fix:**
|
||||
- Add explicit prompt guidance: "Cash runway categorization: <2 months = negative, 2-3 months = neutral, 3-6 months = positive, 6+ months = strongly positive. Do NOT rate below-threshold runway as positive based on projected future inflows."
|
||||
|
||||
### Issue 5: Dual Project Tables (INFORMATIONAL)
|
||||
|
||||
The schema contains both `capital_projects` (empty) and `projects` (26 rows). The health score service correctly queries `projects`, but auditors initially checked `capital_projects` and found no data. This dual-table pattern could confuse future developers.
|
||||
|
||||
**Recommended Fix:**
|
||||
- Consolidate into a single table, OR
|
||||
- Add a comment/documentation clarifying the canonical source
|
||||
|
||||
---
|
||||
|
||||
## Concurrence Summary by Recommendation
|
||||
|
||||
### Operating Fund Health — Recommendations
|
||||
| Recommendation | Concurrence |
|
||||
|---|---|
|
||||
| Verify posting schedule for $20,700 Special Assessment | 90% |
|
||||
| Investigate low YTD expense recognition | 95% |
|
||||
| Move excess cash to interest-bearing account | 85% |
|
||||
| **Average** | **90%** |
|
||||
|
||||
### Reserve Fund Health — Recommendations
|
||||
| Recommendation | Concurrence |
|
||||
|---|---|
|
||||
| Commission professional Reserve Study | 100% |
|
||||
| Develop funding plan for $80K Road Sealing | 90% |
|
||||
| Formalize special assessment collection for reserves | 95% |
|
||||
| **Average** | **95%** |
|
||||
|
||||
### Investment Planning — Recommendations
|
||||
| Recommendation | Concurrence |
|
||||
|---|---|
|
||||
| Critical Reserve Shortfall for March Project | 90% |
|
||||
| Reinvest Maturing CD #2a at Higher Rate | 95% |
|
||||
| Establish 12-Month CD Ladder | 75% |
|
||||
| Deploy Operating Cash to HY Savings | 90% |
|
||||
| Optimize Reserve Cash Post-Project | 85% |
|
||||
| Formalize Special Assessment Collection | 90% |
|
||||
| **Average** | **88%** |
|
||||
|
||||
---
|
||||
|
||||
## Final Grades
|
||||
|
||||
| Feature | Score Accuracy | Recommendation Quality | Data Fidelity | Consistency | **Overall** |
|
||||
|---|---|---|---|---|---|
|
||||
| Operating Fund Health | C+ (score ~15 pts high) | A (90%) | B+ (minor math phrasing) | C (16-pt spread) | **72% — B-** |
|
||||
| Reserve Fund Health | A- (well-calibrated) | A (95%) | B (per-unit confusion) | B- (23-pt spread) | **85% — B+** |
|
||||
| Investment Recommendations | N/A (no single score) | A (88%) | A (exact data matches) | A- (stable across runs) | **88% — A-** |
|
||||
|
||||
---
|
||||
|
||||
## Priority Action Items for Tuning
|
||||
|
||||
1. **[HIGH]** Reduce AI temperature from 0.3 → 0.1 for health score calculations to reduce score volatility
|
||||
2. **[MEDIUM]** Add explicit cash-runway-to-impact mapping in operating prompt to prevent misclassification
|
||||
3. **[MEDIUM]** Pre-compute total special assessment income in data context (not just per-unit)
|
||||
4. **[LOW]** Adjust YTD budget calculation to use prior completed month or pro-rate current month
|
||||
5. **[LOW]** Add boundary justification requirement to scoring prompts
|
||||
6. **[LOW]** Consider implementing 3-run moving average for displayed health scores
|
||||
|
||||
---
|
||||
|
||||
*Generated by Claude Opus 4.6 — Automated AI Feature Audit*
|
||||
22
frontend/Dockerfile
Normal file
22
frontend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# ---- Production Dockerfile for React frontend ----
|
||||
# Multi-stage build: compile to static assets, serve with nginx
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the built static files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy a small nginx config for SPA routing
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
# Minimal nginx config for serving the React SPA inside the frontend container.
|
||||
# The outer nginx reverse proxy forwards non-API requests here.
|
||||
|
||||
server {
|
||||
listen 3001;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve static assets with long cache (Vite hashes filenames)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback — any non-file route returns index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
3324
frontend/package-lock.json
generated
Normal file
3324
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "2026.3.2-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,31 +11,32 @@
|
||||
},
|
||||
"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/form": "^7.15.3",
|
||||
"@mantine/hooks": "^7.15.3",
|
||||
"@mantine/modals": "^7.15.3",
|
||||
"@mantine/notifications": "^7.15.3",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
"@tanstack/react-query": "^5.64.2",
|
||||
"axios": "^1.7.9",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-joyride": "^2.9.3",
|
||||
"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"
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-sankey": "^0.12.4",
|
||||
"@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"
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^5.4.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SankeyPage } from './pages/reports/SankeyPage';
|
||||
import { CashFlowPage } from './pages/reports/CashFlowPage';
|
||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||
@@ -29,6 +30,7 @@ import { AdminPage } from './pages/admin/AdminPage';
|
||||
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
@@ -54,8 +56,14 @@ function SuperAdminRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function AuthRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const organizations = useAuthStore((s) => s.organizations);
|
||||
if (token && currentOrg) return <Navigate to="/" replace />;
|
||||
// Platform owner / superadmin with no org memberships → admin panel
|
||||
if (token && user?.isSuperadmin && (!organizations || organizations.length === 0)) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
if (token && !currentOrg) return <Navigate to="/select-org" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -117,6 +125,7 @@ export function App() {
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="investments" element={<InvestmentsPage />} />
|
||||
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
||||
<Route path="investment-planning" element={<InvestmentPlanningPage />} />
|
||||
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
||||
<Route path="cash-flow" element={<CashFlowForecastPage />} />
|
||||
<Route path="monthly-actuals" element={<MonthlyActualsPage />} />
|
||||
@@ -127,6 +136,7 @@ export function App() {
|
||||
<Route path="reports/aging" element={<AgingReportPage />} />
|
||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||
<Route path="org-members" element={<OrgMembersPage />} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar } from '@mantine/core';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconLogout,
|
||||
@@ -7,33 +8,98 @@ import {
|
||||
IconSettings,
|
||||
IconUserCog,
|
||||
IconUsersGroup,
|
||||
IconEyeOff,
|
||||
} from '@tabler/icons-react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { AppTour } from '../onboarding/AppTour';
|
||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||
import logoSrc from '../../assets/logo.svg';
|
||||
|
||||
export function AppLayout() {
|
||||
const [opened, { toggle, close }] = useDisclosure();
|
||||
const { user, currentOrg, logout } = useAuthStore();
|
||||
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isImpersonating = !!impersonationOriginal;
|
||||
|
||||
// ── Onboarding State ──
|
||||
const [showTour, setShowTour] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run for non-impersonating users with an org selected, on dashboard
|
||||
if (isImpersonating || !currentOrg || !user) return;
|
||||
if (!location.pathname.startsWith('/dashboard')) return;
|
||||
// Read-only users (viewers) skip onboarding entirely
|
||||
if (currentOrg.role === 'viewer') return;
|
||||
|
||||
if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) {
|
||||
// Delay to ensure DOM elements are rendered for tour targeting
|
||||
const timer = setTimeout(() => setShowTour(true), 800);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (currentOrg.settings?.onboardingComplete !== true) {
|
||||
setShowWizard(true);
|
||||
}
|
||||
}, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.role, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]);
|
||||
|
||||
const handleTourComplete = () => {
|
||||
setShowTour(false);
|
||||
// After tour, check if onboarding wizard should run
|
||||
if (currentOrg && currentOrg.settings?.onboardingComplete !== true) {
|
||||
// Small delay before showing wizard
|
||||
setTimeout(() => setShowWizard(true), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWizardComplete = () => {
|
||||
setShowWizard(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleStopImpersonation = () => {
|
||||
stopImpersonation();
|
||||
navigate('/admin');
|
||||
};
|
||||
|
||||
// Tenant admins (president role) can manage org members
|
||||
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
header={{ height: isImpersonating ? 100 : 60 }}
|
||||
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
{isImpersonating && (
|
||||
<Group
|
||||
h={40}
|
||||
px="md"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
style={{ backgroundColor: 'var(--mantine-color-orange-6)' }}
|
||||
>
|
||||
<Text size="sm" fw={600} c="white">
|
||||
Impersonating {user?.firstName} {user?.lastName} ({user?.email})
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="white"
|
||||
color="orange"
|
||||
leftSection={<IconEyeOff size={14} />}
|
||||
onClick={handleStopImpersonation}
|
||||
>
|
||||
Stop Impersonating
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
<Group h={60} px="md" justify="space-between">
|
||||
<Group>
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
|
||||
@@ -46,7 +112,7 @@ export function AppLayout() {
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap="xs">
|
||||
<Avatar size="sm" radius="xl" color="blue">
|
||||
<Avatar size="sm" radius="xl" color={isImpersonating ? 'orange' : 'blue'}>
|
||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||
</Avatar>
|
||||
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
|
||||
@@ -55,6 +121,18 @@ export function AppLayout() {
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{isImpersonating && (
|
||||
<>
|
||||
<Menu.Item
|
||||
color="orange"
|
||||
leftSection={<IconEyeOff size={14} />}
|
||||
onClick={handleStopImpersonation}
|
||||
>
|
||||
Stop Impersonating
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Label>Account</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCog size={14} />}
|
||||
@@ -104,6 +182,10 @@ export function AppLayout() {
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
|
||||
{/* ── Onboarding Components ── */}
|
||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
IconCategory,
|
||||
IconChartAreaLine,
|
||||
IconClipboardCheck,
|
||||
IconSparkles,
|
||||
IconHeartRateMonitor,
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
@@ -28,23 +30,23 @@ const navSections = [
|
||||
{
|
||||
label: 'Financials',
|
||||
items: [
|
||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
|
||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
|
||||
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
|
||||
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
|
||||
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
|
||||
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Assessments',
|
||||
items: [
|
||||
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' },
|
||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Transactions',
|
||||
items: [
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||
],
|
||||
@@ -54,6 +56,7 @@ const navSections = [
|
||||
items: [
|
||||
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
||||
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||
],
|
||||
},
|
||||
@@ -63,6 +66,7 @@ const navSections = [
|
||||
{
|
||||
label: 'Reports',
|
||||
icon: IconChartSankey,
|
||||
tourId: 'nav-reports',
|
||||
children: [
|
||||
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
||||
{ label: 'Income Statement', path: '/reports/income-statement' },
|
||||
@@ -71,6 +75,7 @@ const navSections = [
|
||||
{ label: 'Aging Report', path: '/reports/aging' },
|
||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||
{ label: 'Year-End', path: '/reports/year-end' },
|
||||
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -85,14 +90,46 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const organizations = useAuthStore((s) => s.organizations);
|
||||
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
|
||||
|
||||
const go = (path: string) => {
|
||||
navigate(path);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
// When on admin route with no org selected, show admin-only sidebar
|
||||
if (isAdminOnly && user?.isSuperadmin) {
|
||||
return (
|
||||
<ScrollArea p="sm">
|
||||
<Text size="xs" c="dimmed" fw={700} tt="uppercase" px="sm" pb={4}>
|
||||
Platform Administration
|
||||
</Text>
|
||||
<NavLink
|
||||
label="Admin Panel"
|
||||
leftSection={<IconCrown size={18} />}
|
||||
active={location.pathname === '/admin'}
|
||||
onClick={() => go('/admin')}
|
||||
color="red"
|
||||
/>
|
||||
{organizations && organizations.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<NavLink
|
||||
label="Switch to Tenant"
|
||||
leftSection={<IconBuildingBank size={18} />}
|
||||
onClick={() => go('/select-org')}
|
||||
variant="subtle"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea p="sm" data-tour="sidebar-nav">
|
||||
{navSections.map((section, sIdx) => (
|
||||
<div key={sIdx}>
|
||||
{section.label && (
|
||||
@@ -112,6 +149,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
defaultOpened={item.children.some((c: any) =>
|
||||
location.pathname.startsWith(c.path),
|
||||
)}
|
||||
data-tour={item.tourId || undefined}
|
||||
>
|
||||
{item.children.map((child: any) => (
|
||||
<NavLink
|
||||
@@ -129,6 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
leftSection={<item.icon size={18} />}
|
||||
active={location.pathname === item.path}
|
||||
onClick={() => go(item.path!)}
|
||||
data-tour={item.tourId || undefined}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
93
frontend/src/components/onboarding/AppTour.tsx
Normal file
93
frontend/src/components/onboarding/AppTour.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import Joyride, { type CallBackProps, STATUS, ACTIONS, EVENTS } from 'react-joyride';
|
||||
import { TOUR_STEPS } from '../../config/tourSteps';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface AppTourProps {
|
||||
run: boolean;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function AppTour({ run, onComplete }: AppTourProps) {
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const setUserIntroSeen = useAuthStore((s) => s.setUserIntroSeen);
|
||||
|
||||
const handleCallback = useCallback(
|
||||
async (data: CallBackProps) => {
|
||||
const { status, action, type } = data;
|
||||
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
|
||||
|
||||
if (finishedStatuses.includes(status)) {
|
||||
// Mark intro as seen on backend (fire-and-forget)
|
||||
api.patch('/auth/intro-seen').catch(() => {});
|
||||
setUserIntroSeen();
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle step navigation
|
||||
if (type === EVENTS.STEP_AFTER) {
|
||||
setStepIndex((prev) =>
|
||||
action === ACTIONS.PREV ? prev - 1 : prev + 1,
|
||||
);
|
||||
}
|
||||
},
|
||||
[onComplete, setUserIntroSeen],
|
||||
);
|
||||
|
||||
if (!run) return null;
|
||||
|
||||
return (
|
||||
<Joyride
|
||||
steps={TOUR_STEPS}
|
||||
run={run}
|
||||
stepIndex={stepIndex}
|
||||
continuous
|
||||
showProgress
|
||||
showSkipButton
|
||||
scrollToFirstStep
|
||||
disableOverlayClose
|
||||
callback={handleCallback}
|
||||
styles={{
|
||||
options: {
|
||||
primaryColor: '#228be6',
|
||||
zIndex: 10000,
|
||||
arrowColor: '#fff',
|
||||
backgroundColor: '#fff',
|
||||
textColor: '#333',
|
||||
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
tooltip: {
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
padding: 20,
|
||||
},
|
||||
tooltipTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
},
|
||||
buttonNext: {
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
padding: '8px 16px',
|
||||
},
|
||||
buttonBack: {
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonSkip: {
|
||||
fontSize: 13,
|
||||
},
|
||||
}}
|
||||
locale={{
|
||||
back: 'Previous',
|
||||
close: 'Close',
|
||||
last: 'Finish Tour',
|
||||
next: 'Next',
|
||||
skip: 'Skip Tour',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
646
frontend/src/components/onboarding/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,646 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
|
||||
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
|
||||
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBuildingBank, IconUsers, IconFileSpreadsheet,
|
||||
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
opened: boolean;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
interface UnitRow {
|
||||
unitNumber: string;
|
||||
ownerName: string;
|
||||
ownerEmail: string;
|
||||
}
|
||||
|
||||
// ── CSV Parsing (reused from BudgetsPage pattern) ──
|
||||
function parseCSV(text: string): Record<string, string>[] {
|
||||
const lines = text.split('\n').filter((l) => l.trim());
|
||||
if (lines.length < 2) return [];
|
||||
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
||||
return lines.slice(1).map((line) => {
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (const char of line) {
|
||||
if (char === '"') { inQuotes = !inQuotes; }
|
||||
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
|
||||
else { current += char; }
|
||||
}
|
||||
values.push(current.trim());
|
||||
const row: Record<string, string> = {};
|
||||
headers.forEach((h, i) => { row[h] = values[i] || ''; });
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
|
||||
|
||||
// ── Step 1: Account State ──
|
||||
const [accountCreated, setAccountCreated] = useState(false);
|
||||
const [accountName, setAccountName] = useState('Operating Checking');
|
||||
const [accountNumber, setAccountNumber] = useState('1000');
|
||||
const [accountDescription, setAccountDescription] = useState('');
|
||||
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
||||
|
||||
// ── Step 2: Assessment Group State ──
|
||||
const [groupCreated, setGroupCreated] = useState(false);
|
||||
const [groupName, setGroupName] = useState('Standard Assessment');
|
||||
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
||||
const [frequency, setFrequency] = useState('monthly');
|
||||
const [units, setUnits] = useState<UnitRow[]>([]);
|
||||
const [unitsCreated, setUnitsCreated] = useState(false);
|
||||
|
||||
// ── Step 3: Budget State ──
|
||||
const [budgetFile, setBudgetFile] = useState<File | null>(null);
|
||||
const [budgetUploaded, setBudgetUploaded] = useState(false);
|
||||
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// ── Step 1: Create Account ──
|
||||
const handleCreateAccount = async () => {
|
||||
if (!accountName.trim()) {
|
||||
setError('Account name is required');
|
||||
return;
|
||||
}
|
||||
if (!accountNumber.trim()) {
|
||||
setError('Account number is required');
|
||||
return;
|
||||
}
|
||||
const balance = typeof initialBalance === 'string' ? parseFloat(initialBalance) : initialBalance;
|
||||
if (isNaN(balance)) {
|
||||
setError('Initial balance must be a valid number');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post('/accounts', {
|
||||
accountNumber: accountNumber.trim(),
|
||||
name: accountName.trim(),
|
||||
description: accountDescription.trim(),
|
||||
accountType: 'asset',
|
||||
fundType: 'operating',
|
||||
initialBalance: balance,
|
||||
});
|
||||
setAccountCreated(true);
|
||||
notifications.show({
|
||||
title: 'Account Created',
|
||||
message: `${accountName} has been created with an initial balance of $${balance.toLocaleString()}`,
|
||||
color: 'green',
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || 'Failed to create account';
|
||||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 2: Create Assessment Group ──
|
||||
const handleCreateGroup = async () => {
|
||||
if (!groupName.trim()) {
|
||||
setError('Group name is required');
|
||||
return;
|
||||
}
|
||||
const assessment = typeof regularAssessment === 'string' ? parseFloat(regularAssessment) : regularAssessment;
|
||||
if (isNaN(assessment) || assessment <= 0) {
|
||||
setError('Assessment amount must be greater than zero');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data: group } = await api.post('/assessment-groups', {
|
||||
name: groupName.trim(),
|
||||
regularAssessment: assessment,
|
||||
frequency,
|
||||
isDefault: true,
|
||||
});
|
||||
setGroupCreated(true);
|
||||
|
||||
// Create units if any were added
|
||||
if (units.length > 0) {
|
||||
let created = 0;
|
||||
for (const unit of units) {
|
||||
if (!unit.unitNumber.trim()) continue;
|
||||
try {
|
||||
await api.post('/units', {
|
||||
unitNumber: unit.unitNumber.trim(),
|
||||
ownerName: unit.ownerName.trim() || null,
|
||||
ownerEmail: unit.ownerEmail.trim() || null,
|
||||
assessmentGroupId: group.id,
|
||||
});
|
||||
created++;
|
||||
} catch {
|
||||
// Continue even if a unit fails
|
||||
}
|
||||
}
|
||||
setUnitsCreated(true);
|
||||
notifications.show({
|
||||
title: 'Assessment Group Created',
|
||||
message: `${groupName} created with ${created} unit(s)`,
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Assessment Group Created',
|
||||
message: `${groupName} created successfully`,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || 'Failed to create assessment group';
|
||||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 3: Budget Import ──
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const response = await api.get(`/budgets/${currentYear}/template`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `budget_template_${currentYear}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to download template',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadBudget = async () => {
|
||||
if (!budgetFile) {
|
||||
setError('Please select a CSV file');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const text = await budgetFile.text();
|
||||
const rows = parseCSV(text);
|
||||
if (rows.length === 0) {
|
||||
setError('CSV file appears to be empty or invalid');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
|
||||
setBudgetUploaded(true);
|
||||
setBudgetImportResult(data);
|
||||
notifications.show({
|
||||
title: 'Budget Imported',
|
||||
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
|
||||
color: 'green',
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || 'Failed to import budget';
|
||||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Finish Wizard ──
|
||||
const handleFinish = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.patch('/organizations/settings', { onboardingComplete: true });
|
||||
setOrgSettings({ onboardingComplete: true });
|
||||
onComplete();
|
||||
} catch {
|
||||
// Even if API fails, close the wizard — onboarding data is already created
|
||||
onComplete();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Unit Rows ──
|
||||
const addUnit = () => {
|
||||
setUnits([...units, { unitNumber: '', ownerName: '', ownerEmail: '' }]);
|
||||
};
|
||||
|
||||
const updateUnit = (index: number, field: keyof UnitRow, value: string) => {
|
||||
const updated = [...units];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setUnits(updated);
|
||||
};
|
||||
|
||||
const removeUnit = (index: number) => {
|
||||
setUnits(units.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// ── Navigation ──
|
||||
const canGoNext = () => {
|
||||
if (active === 0) return accountCreated;
|
||||
if (active === 1) return groupCreated;
|
||||
if (active === 2) return true; // Budget is optional
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
setError(null);
|
||||
if (active < 3) setActive(active + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}} // Prevent closing without completing
|
||||
withCloseButton={false}
|
||||
size="xl"
|
||||
centered
|
||||
overlayProps={{ opacity: 0.6, blur: 3 }}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box px="xl" pt="xl" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
|
||||
<Group>
|
||||
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
|
||||
<IconRocket size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Set Up Your Organization</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Let's get the essentials configured so you can start managing your HOA finances.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box px="xl" py="lg">
|
||||
<Stepper active={active} size="sm" mb="xl">
|
||||
<Stepper.Step
|
||||
label="Operating Account"
|
||||
description="Set up your primary bank account"
|
||||
icon={<IconBuildingBank size={18} />}
|
||||
completedIcon={<IconCheck size={18} />}
|
||||
/>
|
||||
<Stepper.Step
|
||||
label="Assessment Group"
|
||||
description="Define homeowner assessments"
|
||||
icon={<IconUsers size={18} />}
|
||||
completedIcon={<IconCheck size={18} />}
|
||||
/>
|
||||
<Stepper.Step
|
||||
label="Budget"
|
||||
description="Import your annual budget"
|
||||
icon={<IconFileSpreadsheet size={18} />}
|
||||
completedIcon={<IconCheck size={18} />}
|
||||
/>
|
||||
</Stepper>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md" withCloseButton onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Create Operating Account ── */}
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Card withBorder p="lg">
|
||||
<Text fw={600} mb="xs">Create Your Primary Operating Account</Text>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
This is your HOA's main bank account for day-to-day operations. You can add more accounts later.
|
||||
</Text>
|
||||
|
||||
{accountCreated ? (
|
||||
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||
<Text fw={500}>{accountName} created successfully!</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={2} mb="md">
|
||||
<TextInput
|
||||
label="Account Name"
|
||||
placeholder="e.g. Operating Checking"
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Account Number"
|
||||
placeholder="e.g. 1000"
|
||||
value={accountNumber}
|
||||
onChange={(e) => setAccountNumber(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Optional description"
|
||||
value={accountDescription}
|
||||
onChange={(e) => setAccountDescription(e.currentTarget.value)}
|
||||
mb="md"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Current Balance"
|
||||
description="Enter the current balance of this bank account"
|
||||
placeholder="0.00"
|
||||
value={initialBalance}
|
||||
onChange={setInitialBalance}
|
||||
thousandSeparator=","
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
mb="md"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateAccount}
|
||||
loading={loading}
|
||||
leftSection={<IconBuildingBank size={16} />}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Assessment Group + Units ── */}
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<Card withBorder p="lg">
|
||||
<Text fw={600} mb="xs">Create an Assessment Group</Text>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Assessment groups define how much each homeowner pays and how often. You can create additional groups later for different unit types.
|
||||
</Text>
|
||||
|
||||
{groupCreated ? (
|
||||
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||
<Text fw={500}>{groupName} created successfully!</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
${(typeof regularAssessment === 'number' ? regularAssessment : parseFloat(regularAssessment as string) || 0).toLocaleString()} {frequency}
|
||||
{unitsCreated && ` with ${units.length} unit(s)`}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={3} mb="md">
|
||||
<TextInput
|
||||
label="Group Name"
|
||||
placeholder="e.g. Standard Assessment"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Assessment Amount"
|
||||
placeholder="0.00"
|
||||
value={regularAssessment}
|
||||
onChange={setRegularAssessment}
|
||||
thousandSeparator=","
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Frequency"
|
||||
value={frequency}
|
||||
onChange={(v) => setFrequency(v || 'monthly')}
|
||||
data={[
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'quarterly', label: 'Quarterly' },
|
||||
{ value: 'annual', label: 'Annual' },
|
||||
]}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my="md" label="Add Homeowner Units (Optional)" labelPosition="center" />
|
||||
|
||||
{units.length > 0 && (
|
||||
<Table mb="md" striped withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Unit Number</Table.Th>
|
||||
<Table.Th>Owner Name</Table.Th>
|
||||
<Table.Th>Owner Email</Table.Th>
|
||||
<Table.Th w={40}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{units.map((unit, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="e.g. 101"
|
||||
value={unit.unitNumber}
|
||||
onChange={(e) => updateUnit(idx, 'unitNumber', e.currentTarget.value)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="John Smith"
|
||||
value={unit.ownerName}
|
||||
onChange={(e) => updateUnit(idx, 'ownerName', e.currentTarget.value)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="john@example.com"
|
||||
value={unit.ownerEmail}
|
||||
onChange={(e) => updateUnit(idx, 'ownerEmail', e.currentTarget.value)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon color="red" variant="subtle" size="sm" onClick={() => removeUnit(idx)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Group mb="md">
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={addUnit}
|
||||
>
|
||||
Add Unit
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">You can also import units in bulk later from the Units page.</Text>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateGroup}
|
||||
loading={loading}
|
||||
leftSection={<IconUsers size={16} />}
|
||||
>
|
||||
Create Assessment Group
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Budget Upload ── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<Card withBorder p="lg">
|
||||
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Upload a CSV file with your annual budget. If you don't have one ready, you can download a template
|
||||
or skip this step and set it up later from the Budgets page.
|
||||
</Text>
|
||||
|
||||
{budgetUploaded ? (
|
||||
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||
<Text fw={500}>Budget imported successfully!</Text>
|
||||
{budgetImportResult && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Group mb="md">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
Download CSV Template
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<FileInput
|
||||
label="Upload Budget CSV"
|
||||
placeholder="Click to select a .csv file"
|
||||
accept=".csv"
|
||||
value={budgetFile}
|
||||
onChange={setBudgetFile}
|
||||
mb="md"
|
||||
leftSection={<IconFileSpreadsheet size={16} />}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleUploadBudget}
|
||||
loading={loading}
|
||||
leftSection={<IconFileSpreadsheet size={16} />}
|
||||
disabled={!budgetFile}
|
||||
>
|
||||
Import Budget
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Completion Screen ── */}
|
||||
{active === 3 && (
|
||||
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
||||
<IconCheck size={32} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} mb="xs">You're All Set!</Title>
|
||||
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
||||
Your organization is configured and ready to go. You can always update your accounts,
|
||||
assessment groups, and budgets from the sidebar navigation.
|
||||
</Text>
|
||||
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||
<IconBuildingBank size={16} />
|
||||
</ThemeIcon>
|
||||
<Badge color="green" size="sm">Done</Badge>
|
||||
<Text size="xs" mt={4}>Account</Text>
|
||||
</Card>
|
||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||
<IconUsers size={16} />
|
||||
</ThemeIcon>
|
||||
<Badge color="green" size="sm">Done</Badge>
|
||||
<Text size="xs" mt={4}>Assessments</Text>
|
||||
</Card>
|
||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||
<IconFileSpreadsheet size={16} />
|
||||
</ThemeIcon>
|
||||
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
|
||||
{budgetUploaded ? 'Done' : 'Skipped'}
|
||||
</Badge>
|
||||
<Text size="xs" mt={4}>Budget</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleFinish}
|
||||
loading={loading}
|
||||
leftSection={<IconRocket size={18} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'blue', to: 'cyan' }}
|
||||
>
|
||||
Start Using LedgerIQ
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Navigation Buttons ── */}
|
||||
{active < 3 && (
|
||||
<Group justify="flex-end" mt="xl">
|
||||
{active === 2 && !budgetUploaded && (
|
||||
<Button variant="subtle" onClick={nextStep}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={!canGoNext()}
|
||||
>
|
||||
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
68
frontend/src/config/tourSteps.ts
Normal file
68
frontend/src/config/tourSteps.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* How-To Intro Tour Steps
|
||||
*
|
||||
* Centralized configuration for the react-joyride walkthrough.
|
||||
* Edit the title and content fields below to change tour text.
|
||||
* Steps are ordered to mirror the natural workflow of the platform.
|
||||
*/
|
||||
import type { Step } from 'react-joyride';
|
||||
|
||||
export const TOUR_STEPS: Step[] = [
|
||||
{
|
||||
target: '[data-tour="dashboard-content"]',
|
||||
title: 'Your Financial Dashboard',
|
||||
content:
|
||||
'Welcome to LedgerIQ! This dashboard gives you an at-a-glance view of your HOA\'s financial health — operating funds, reserve funds, receivables, delinquencies, and recent transactions. It updates automatically as you record activity.',
|
||||
placement: 'center',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="sidebar-nav"]',
|
||||
title: 'Navigation',
|
||||
content:
|
||||
'The sidebar organizes all your tools into five sections: Financials, Assessments, Transactions, Planning, and Reports. Click any item to navigate directly to that module.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-accounts"]',
|
||||
title: 'Chart of Accounts',
|
||||
content:
|
||||
'Manage your Chart of Accounts here. Set up operating and reserve fund bank accounts, track balances, record opening balances, and manage your investment accounts — all separated by fund type.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-assessment-groups"]',
|
||||
title: 'Assessments & Homeowners',
|
||||
content:
|
||||
'Create assessment groups to define your monthly, quarterly, or annual HOA dues. Add homeowner units, assign them to groups, and generate invoices automatically based on your assessment schedule.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-transactions"]',
|
||||
title: 'Transactions & Journal Entries',
|
||||
content:
|
||||
'Record all financial activity here through double-entry journal entries. The system also automatically creates entries when you record payments, generate invoices, or set opening balances.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-budgets"]',
|
||||
title: 'Budget Management',
|
||||
content:
|
||||
'Create and manage annual budgets for every income and expense account. You can enter amounts manually by month or import your budget from a CSV file for quick setup.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-reports"]',
|
||||
title: 'Financial Reports',
|
||||
content:
|
||||
'Generate comprehensive reports including Balance Sheet, Income Statement, Cash Flow Statement, Budget vs Actual, Aging Report, and more. All reports are generated in real-time from your journal data.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-investment-planning"]',
|
||||
title: 'AI Investment Planning',
|
||||
content:
|
||||
'Use AI-powered recommendations to optimize your reserve fund investments. The system analyzes current market rates for CDs, money market accounts, and high-yield savings to suggest the best allocation strategy.',
|
||||
placement: 'right',
|
||||
},
|
||||
];
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
||||
|
||||
@@ -126,6 +127,7 @@ export function AccountsPage() {
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
// ── Accounts query ──
|
||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||
@@ -434,14 +436,44 @@ export function AccountsPage() {
|
||||
// Net position = assets + investments - liabilities
|
||||
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
||||
|
||||
// ── Estimated monthly interest across all accounts with rates ──
|
||||
const estMonthlyInterest = accounts
|
||||
// ── Estimated monthly interest across all accounts + investments with rates ──
|
||||
const acctMonthlyInterest = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
||||
.reduce((sum, a) => {
|
||||
const bal = parseFloat(a.balance || '0');
|
||||
const rate = parseFloat(a.interest_rate || '0');
|
||||
return sum + (bal * (rate / 100) / 12);
|
||||
}, 0);
|
||||
const invMonthlyInterest = investments
|
||||
.filter((i) => i.is_active && parseFloat(i.interest_rate || '0') > 0)
|
||||
.reduce((sum, i) => {
|
||||
const val = parseFloat(i.current_value || i.principal || '0');
|
||||
const rate = parseFloat(i.interest_rate || '0');
|
||||
return sum + (val * (rate / 100) / 12);
|
||||
}, 0);
|
||||
const estMonthlyInterest = acctMonthlyInterest + invMonthlyInterest;
|
||||
|
||||
// ── Per-fund cash and interest breakdowns ──
|
||||
const operatingCash = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'operating')
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||
const reserveCash = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'reserve')
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||
const opInvTotal = operatingInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
const resInvTotal = reserveInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
const opMonthlyInterest = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'operating' && parseFloat(a.interest_rate || '0') > 0)
|
||||
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
|
||||
+ operatingInvestments
|
||||
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
|
||||
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
|
||||
const resMonthlyInterest = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'reserve' && parseFloat(a.interest_rate || '0') > 0)
|
||||
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
|
||||
+ reserveInvestments
|
||||
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
|
||||
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
|
||||
|
||||
// ── Adjust modal: current balance from trial balance ──
|
||||
const adjustCurrentBalance = adjustingAccount
|
||||
@@ -472,37 +504,35 @@ export function AccountsPage() {
|
||||
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
Add Account
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Cash on Hand</Text>
|
||||
<Text fw={700} size="sm" c="green">{fmt(totalsByType['asset'] || 0)}</Text>
|
||||
<Text size="xs" c="dimmed">Operating Fund</Text>
|
||||
<Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
|
||||
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
|
||||
</Card>
|
||||
{investmentTotal > 0 && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Investments</Text>
|
||||
<Text fw={700} size="sm" c="teal">{fmt(investmentTotal)}</Text>
|
||||
<Text size="xs" c="dimmed">Reserve Fund</Text>
|
||||
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
|
||||
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
|
||||
</Card>
|
||||
)}
|
||||
{(totalsByType['liability'] || 0) > 0 && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Liabilities</Text>
|
||||
<Text fw={700} size="sm" c="red">{fmt(totalsByType['liability'] || 0)}</Text>
|
||||
</Card>
|
||||
)}
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Net Position</Text>
|
||||
<Text size="xs" c="dimmed">Total All Funds</Text>
|
||||
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
||||
<Text size="xs" c="dimmed">Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)}</Text>
|
||||
</Card>
|
||||
{estMonthlyInterest > 0 && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
||||
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
||||
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
@@ -552,7 +582,7 @@ export function AccountsPage() {
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
{investments.filter(i => i.is_active).length > 0 && (
|
||||
<>
|
||||
@@ -570,7 +600,7 @@ export function AccountsPage() {
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
{operatingInvestments.length > 0 && (
|
||||
<>
|
||||
@@ -588,7 +618,7 @@ export function AccountsPage() {
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
{reserveInvestments.length > 0 && (
|
||||
<>
|
||||
@@ -606,7 +636,7 @@ export function AccountsPage() {
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
|
||||
isReadOnly={isReadOnly}
|
||||
isArchivedView
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
@@ -908,6 +938,7 @@ function AccountTable({
|
||||
onArchive,
|
||||
onSetPrimary,
|
||||
onAdjustBalance,
|
||||
isReadOnly = false,
|
||||
isArchivedView = false,
|
||||
}: {
|
||||
accounts: Account[];
|
||||
@@ -915,6 +946,7 @@ function AccountTable({
|
||||
onArchive: (a: Account) => void;
|
||||
onSetPrimary: (id: string) => void;
|
||||
onAdjustBalance: (a: Account) => void;
|
||||
isReadOnly?: boolean;
|
||||
isArchivedView?: boolean;
|
||||
}) {
|
||||
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
||||
@@ -1003,6 +1035,7 @@ function AccountTable({
|
||||
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!isReadOnly && (
|
||||
<Group gap={4}>
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||
@@ -1039,6 +1072,7 @@ function AccountTable({
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
@@ -1090,6 +1124,7 @@ function InvestmentMiniTable({
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Institution</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Principal</Table.Th>
|
||||
<Table.Th ta="right">Current Value</Table.Th>
|
||||
<Table.Th ta="right">Rate</Table.Th>
|
||||
@@ -1103,7 +1138,7 @@ function InvestmentMiniTable({
|
||||
<Table.Tbody>
|
||||
{investments.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={11}>
|
||||
<Table.Td colSpan={12}>
|
||||
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -1117,6 +1152,11 @@ function InvestmentMiniTable({
|
||||
{inv.investment_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||
{inv.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td>
|
||||
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
|
||||
MultiSelect,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface AssessmentGroup {
|
||||
id: string;
|
||||
@@ -20,6 +22,8 @@ interface AssessmentGroup {
|
||||
special_assessment: string;
|
||||
unit_count: number;
|
||||
frequency: string;
|
||||
due_months: number[];
|
||||
due_day: number;
|
||||
actual_unit_count: string;
|
||||
monthly_operating_income: string;
|
||||
monthly_reserve_income: string;
|
||||
@@ -48,10 +52,34 @@ const frequencyColors: Record<string, string> = {
|
||||
annual: 'violet',
|
||||
};
|
||||
|
||||
const MONTH_OPTIONS = [
|
||||
{ value: '1', label: 'January' },
|
||||
{ value: '2', label: 'February' },
|
||||
{ value: '3', label: 'March' },
|
||||
{ value: '4', label: 'April' },
|
||||
{ value: '5', label: 'May' },
|
||||
{ value: '6', label: 'June' },
|
||||
{ value: '7', label: 'July' },
|
||||
{ value: '8', label: 'August' },
|
||||
{ value: '9', label: 'September' },
|
||||
{ value: '10', label: 'October' },
|
||||
{ value: '11', label: 'November' },
|
||||
{ value: '12', label: 'December' },
|
||||
];
|
||||
|
||||
const MONTH_ABBREV = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const DEFAULT_DUE_MONTHS: Record<string, string[]> = {
|
||||
monthly: ['1','2','3','4','5','6','7','8','9','10','11','12'],
|
||||
quarterly: ['1','4','7','10'],
|
||||
annual: ['1'],
|
||||
};
|
||||
|
||||
export function AssessmentGroupsPage() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
||||
queryKey: ['assessment-groups'],
|
||||
@@ -71,18 +99,31 @@ export function AssessmentGroupsPage() {
|
||||
specialAssessment: 0,
|
||||
unitCount: 0,
|
||||
frequency: 'monthly',
|
||||
dueMonths: DEFAULT_DUE_MONTHS.monthly,
|
||||
dueDay: 1,
|
||||
},
|
||||
validate: {
|
||||
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
|
||||
dueMonths: (v, values) => {
|
||||
if (values.frequency === 'quarterly' && v.length !== 4) return 'Quarterly requires exactly 4 months';
|
||||
if (values.frequency === 'annual' && v.length !== 1) return 'Annual requires exactly 1 month';
|
||||
return null;
|
||||
},
|
||||
dueDay: (v) => (v >= 1 && v <= 28 ? null : 'Must be 1-28'),
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: any) =>
|
||||
editing
|
||||
? api.put(`/assessment-groups/${editing.id}`, values)
|
||||
: api.post('/assessment-groups', values),
|
||||
mutationFn: (values: any) => {
|
||||
const payload = {
|
||||
...values,
|
||||
dueMonths: values.dueMonths.map(Number),
|
||||
};
|
||||
return editing
|
||||
? api.put(`/assessment-groups/${editing.id}`, payload)
|
||||
: api.post('/assessment-groups', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
|
||||
@@ -119,6 +160,9 @@ export function AssessmentGroupsPage() {
|
||||
|
||||
const handleEdit = (group: AssessmentGroup) => {
|
||||
setEditing(group);
|
||||
const dueMonths = group.due_months
|
||||
? group.due_months.map(String)
|
||||
: DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly;
|
||||
form.setValues({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
@@ -126,6 +170,8 @@ export function AssessmentGroupsPage() {
|
||||
specialAssessment: parseFloat(group.special_assessment || '0'),
|
||||
unitCount: group.unit_count || 0,
|
||||
frequency: group.frequency || 'monthly',
|
||||
dueMonths,
|
||||
dueDay: group.due_day || 1,
|
||||
});
|
||||
open();
|
||||
};
|
||||
@@ -136,6 +182,12 @@ export function AssessmentGroupsPage() {
|
||||
open();
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (value: string | null) => {
|
||||
if (!value) return;
|
||||
form.setFieldValue('frequency', value);
|
||||
form.setFieldValue('dueMonths', DEFAULT_DUE_MONTHS[value] || DEFAULT_DUE_MONTHS.monthly);
|
||||
};
|
||||
|
||||
const fmt = (v: string | number) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
@@ -147,6 +199,11 @@ export function AssessmentGroupsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDueMonths = (months: number[], frequency: string) => {
|
||||
if (!months || frequency === 'monthly') return 'Every month';
|
||||
return months.map((m) => MONTH_ABBREV[m]).join(', ');
|
||||
};
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
return (
|
||||
@@ -156,9 +213,11 @@ export function AssessmentGroupsPage() {
|
||||
<Title order={2}>Assessment Groups</Title>
|
||||
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
Add Group
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||
@@ -215,6 +274,7 @@ export function AssessmentGroupsPage() {
|
||||
<Table.Th>Group Name</Table.Th>
|
||||
<Table.Th ta="center">Units</Table.Th>
|
||||
<Table.Th>Frequency</Table.Th>
|
||||
<Table.Th>Due Months</Table.Th>
|
||||
<Table.Th ta="right">Regular Assessment</Table.Th>
|
||||
<Table.Th ta="right">Special Assessment</Table.Th>
|
||||
<Table.Th ta="right">Monthly Equiv.</Table.Th>
|
||||
@@ -225,7 +285,7 @@ export function AssessmentGroupsPage() {
|
||||
<Table.Tbody>
|
||||
{groups.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8}>
|
||||
<Table.Td colSpan={9}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
|
||||
</Text>
|
||||
@@ -259,6 +319,9 @@ export function AssessmentGroupsPage() {
|
||||
{frequencyLabels[g.frequency] || 'Monthly'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{formatDueMonths(g.due_months, g.frequency)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
|
||||
</Table.Td>
|
||||
@@ -274,6 +337,7 @@ export function AssessmentGroupsPage() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!isReadOnly && (
|
||||
<Group gap={4}>
|
||||
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
|
||||
<ActionIcon
|
||||
@@ -296,6 +360,7 @@ export function AssessmentGroupsPage() {
|
||||
<IconArchive size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -316,8 +381,22 @@ export function AssessmentGroupsPage() {
|
||||
{ value: 'quarterly', label: 'Quarterly' },
|
||||
{ value: 'annual', label: 'Annual' },
|
||||
]}
|
||||
{...form.getInputProps('frequency')}
|
||||
value={form.values.frequency}
|
||||
onChange={handleFrequencyChange}
|
||||
/>
|
||||
{form.values.frequency !== 'monthly' && (
|
||||
<MultiSelect
|
||||
label={form.values.frequency === 'quarterly' ? 'Billing Quarters (select 4 months)' : 'Due Month'}
|
||||
description={form.values.frequency === 'quarterly'
|
||||
? 'Select the first month of each quarter when assessments are due'
|
||||
: 'Select the month when the annual assessment is due'}
|
||||
data={MONTH_OPTIONS}
|
||||
value={form.values.dueMonths}
|
||||
onChange={(v) => form.setFieldValue('dueMonths', v)}
|
||||
error={form.errors.dueMonths}
|
||||
maxValues={form.values.frequency === 'annual' ? 1 : 4}
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
|
||||
@@ -334,7 +413,16 @@ export function AssessmentGroupsPage() {
|
||||
{...form.getInputProps('specialAssessment')}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
|
||||
<NumberInput
|
||||
label="Due Day of Month"
|
||||
description="Day invoices are due (1-28)"
|
||||
min={1}
|
||||
max={28}
|
||||
{...form.getInputProps('dueDay')}
|
||||
/>
|
||||
</Group>
|
||||
<Button type="submit" loading={saveMutation.isPending}>
|
||||
{editing ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
|
||||
@@ -38,8 +38,11 @@ export function LoginPage() {
|
||||
try {
|
||||
const { data } = await api.post('/auth/login', values);
|
||||
setAuth(data.accessToken, data.user, data.organizations);
|
||||
// Platform owner / superadmin with no orgs → admin panel
|
||||
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
||||
navigate('/admin');
|
||||
} else if (data.organizations.length >= 1) {
|
||||
// Always go through org selection to ensure correct JWT with orgSchema
|
||||
if (data.organizations.length >= 1) {
|
||||
navigate('/select-org');
|
||||
} else {
|
||||
navigate('/');
|
||||
|
||||
@@ -49,6 +49,11 @@ export function SelectOrgPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Filter out suspended/archived organizations (defense in depth)
|
||||
const activeOrganizations = (organizations || []).filter(
|
||||
(org: any) => !org.status || !['suspended', 'archived'].includes(org.status),
|
||||
);
|
||||
|
||||
const handleSelect = async (org: any) => {
|
||||
try {
|
||||
const { data } = await api.post('/auth/switch-org', {
|
||||
@@ -90,8 +95,15 @@ export function SelectOrgPage() {
|
||||
Choose an HOA to manage or create a new one
|
||||
</Text>
|
||||
|
||||
<Stack mt={30}>
|
||||
{organizations.map((org) => (
|
||||
{/* Filter out suspended/archived orgs (defense in depth — backend also filters) */}
|
||||
{organizations.length > activeOrganizations.length && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light" mt="md">
|
||||
Some organizations are currently suspended or archived and are not shown.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack mt={organizations.length > activeOrganizations.length ? 'sm' : 30}>
|
||||
{activeOrganizations.map((org) => (
|
||||
<Card
|
||||
key={org.id}
|
||||
shadow="sm"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { notifications } from '@mantine/notifications';
|
||||
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface BudgetLine {
|
||||
account_id: string;
|
||||
@@ -96,6 +97,7 @@ export function BudgetsPage() {
|
||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { isLoading } = useQuery<BudgetLine[]>({
|
||||
queryKey: ['budgets', year],
|
||||
@@ -236,8 +238,12 @@ export function BudgetsPage() {
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
||||
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
|
||||
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
|
||||
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
|
||||
const totalIncome = incomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
const totalIncome = totalOperatingIncome + totalReserveIncome;
|
||||
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
|
||||
return (
|
||||
@@ -253,6 +259,7 @@ export function BudgetsPage() {
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
{!isReadOnly && (<>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
@@ -271,6 +278,7 @@ export function BudgetsPage() {
|
||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||
Save Budget
|
||||
</Button>
|
||||
</>)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -284,17 +292,23 @@ export function BudgetsPage() {
|
||||
|
||||
<Group>
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Total Income</Text>
|
||||
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
|
||||
<Text size="xs" c="dimmed">Operating Income</Text>
|
||||
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
|
||||
</Card>
|
||||
{totalReserveIncome > 0 && (
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Reserve Income</Text>
|
||||
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</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 size="xs" c="dimmed">Net (Operating)</Text>
|
||||
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
||||
{fmt(totalOperatingIncome - totalExpense)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Group>
|
||||
@@ -384,6 +398,7 @@ export function BudgetsPage() {
|
||||
hideControls
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
disabled={isReadOnly}
|
||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
</Table.Td>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
@@ -29,7 +30,7 @@ interface Project {
|
||||
fund_source: string;
|
||||
funded_percentage: string;
|
||||
planned_date: string;
|
||||
target_year: number;
|
||||
target_year: number | null;
|
||||
target_month: number;
|
||||
status: string;
|
||||
priority: number;
|
||||
@@ -37,6 +38,7 @@ interface Project {
|
||||
}
|
||||
|
||||
const FUTURE_YEAR = 9999;
|
||||
const UNSCHEDULED = -1; // sentinel for projects with no target_year
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
planned: 'blue', approved: 'green', in_progress: 'yellow',
|
||||
@@ -48,7 +50,8 @@ const priorityColor = (p: number) => (p <= 2 ? 'red' : p <= 3 ? 'yellow' : 'gray
|
||||
const fmt = (v: string | number) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year));
|
||||
const yearLabel = (year: number) =>
|
||||
year === FUTURE_YEAR ? 'Future' : year === UNSCHEDULED ? 'Unscheduled' : String(year);
|
||||
|
||||
const formatPlannedDate = (d: string | null | undefined) => {
|
||||
if (!d) return null;
|
||||
@@ -73,6 +76,9 @@ interface KanbanCardProps {
|
||||
|
||||
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
||||
const plannedLabel = formatPlannedDate(project.planned_date);
|
||||
// For projects in the Future bucket with a specific year, show the year
|
||||
const currentYear = new Date().getFullYear();
|
||||
const isBeyondWindow = project.target_year !== null && project.target_year > currentYear + 4 && project.target_year !== FUTURE_YEAR;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -104,6 +110,11 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
||||
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
|
||||
P{project.priority}
|
||||
</Badge>
|
||||
{isBeyondWindow && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{project.target_year}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text size="xs" ff="monospace" fw={500} mb={4}>
|
||||
@@ -144,19 +155,26 @@ function KanbanColumn({
|
||||
isDragOver, onDragOverHandler, onDragLeave,
|
||||
}: KanbanColumnProps) {
|
||||
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||
const isFuture = year === FUTURE_YEAR;
|
||||
const isUnscheduled = year === UNSCHEDULED;
|
||||
const useWideLayout = (isFuture || isUnscheduled) && projects.length > 3;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
miw={280}
|
||||
maw={320}
|
||||
miw={useWideLayout ? 580 : 280}
|
||||
maw={useWideLayout ? 640 : 320}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: isDragOver ? 'var(--mantine-color-blue-0)' : undefined,
|
||||
backgroundColor: isDragOver
|
||||
? 'var(--mantine-color-blue-0)'
|
||||
: isUnscheduled
|
||||
? 'var(--mantine-color-orange-0)'
|
||||
: undefined,
|
||||
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
||||
transition: 'background-color 150ms ease, border 150ms ease',
|
||||
}}
|
||||
@@ -166,8 +184,13 @@ function KanbanColumn({
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={5}>{yearLabel(year)}</Title>
|
||||
<Group gap={6}>
|
||||
{isUnscheduled && projects.length > 0 && (
|
||||
<Badge size="xs" variant="light" color="orange">needs scheduling</Badge>
|
||||
)}
|
||||
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{projects.length} project{projects.length !== 1 ? 's' : ''}
|
||||
@@ -178,6 +201,16 @@ function KanbanColumn({
|
||||
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||
Drop projects here
|
||||
</Text>
|
||||
) : useWideLayout ? (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 'var(--mantine-spacing-xs)',
|
||||
}}>
|
||||
{projects.map((p) => (
|
||||
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
||||
@@ -215,6 +248,7 @@ export function CapitalProjectsPage() {
|
||||
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
||||
const printModeRef = useRef(false);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
@@ -287,10 +321,10 @@ export function CapitalProjectsPage() {
|
||||
});
|
||||
|
||||
const moveProjectMutation = useMutation({
|
||||
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number; target_month: number }) => {
|
||||
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number | null; target_month: number }) => {
|
||||
const payload: Record<string, unknown> = { target_year };
|
||||
// Derive planned_date based on the new year
|
||||
if (target_year === FUTURE_YEAR) {
|
||||
if (target_year === null || target_year === FUTURE_YEAR) {
|
||||
payload.planned_date = null;
|
||||
} else {
|
||||
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
|
||||
@@ -329,7 +363,7 @@ export function CapitalProjectsPage() {
|
||||
form.setValues({
|
||||
status: p.status || 'planned',
|
||||
priority: p.priority || 3,
|
||||
target_year: p.target_year,
|
||||
target_year: p.target_year ?? currentYear,
|
||||
target_month: p.target_month || 6,
|
||||
planned_date: p.planned_date || '',
|
||||
notes: p.notes || '',
|
||||
@@ -352,7 +386,7 @@ export function CapitalProjectsPage() {
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
id: project.id,
|
||||
source_year: project.target_year,
|
||||
source_year: project.target_year ?? UNSCHEDULED,
|
||||
target_month: project.target_month,
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
@@ -376,7 +410,7 @@ export function CapitalProjectsPage() {
|
||||
if (payload.source_year !== targetYear) {
|
||||
moveProjectMutation.mutate({
|
||||
id: payload.id,
|
||||
target_year: targetYear,
|
||||
target_year: targetYear === UNSCHEDULED ? null : targetYear,
|
||||
target_month: payload.target_month || 6,
|
||||
});
|
||||
}
|
||||
@@ -389,15 +423,20 @@ export function CapitalProjectsPage() {
|
||||
|
||||
// Always show current year through current+4, plus FUTURE_YEAR if any projects have it
|
||||
const baseYears = Array.from({ length: 5 }, (_, i) => currentYear + i);
|
||||
const projectYears = [...new Set(projects.map((p) => p.target_year))];
|
||||
const projectYears = [...new Set(projects.map((p) => p.target_year).filter((y): y is number => y !== null))];
|
||||
const hasFutureProjects = projectYears.includes(FUTURE_YEAR);
|
||||
const hasUnscheduledProjects = projects.some((p) => p.target_year === null);
|
||||
|
||||
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
|
||||
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
|
||||
const years = hasFutureProjects ? [...regularYears, FUTURE_YEAR] : regularYears;
|
||||
const years = [
|
||||
...regularYears,
|
||||
...(hasFutureProjects ? [FUTURE_YEAR] : []),
|
||||
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
|
||||
];
|
||||
|
||||
// Kanban columns: always current..current+4 plus Future
|
||||
const kanbanYears = [...baseYears, FUTURE_YEAR];
|
||||
// Kanban columns: current..current+4 + Future + Unscheduled (rightmost)
|
||||
const kanbanYears = [...baseYears, FUTURE_YEAR, UNSCHEDULED];
|
||||
|
||||
// ---- Loading state ----
|
||||
|
||||
@@ -417,12 +456,11 @@ export function CapitalProjectsPage() {
|
||||
<Stack align="center" gap="md" maw={420}>
|
||||
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
|
||||
<Title order={3} c="dimmed" ta="center">
|
||||
No projects in the capital plan
|
||||
No projects yet
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center" size="sm">
|
||||
Capital Planning displays projects that have a target year assigned.
|
||||
Head over to the Projects page to define your reserve and operating
|
||||
projects, then assign target years to see them here.
|
||||
projects. They'll appear here for capital planning and scheduling.
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -448,7 +486,9 @@ export function CapitalProjectsPage() {
|
||||
</Text>
|
||||
) : (
|
||||
years.map((year) => {
|
||||
const yearProjects = projects.filter((p) => p.target_year === year);
|
||||
const yearProjects = year === UNSCHEDULED
|
||||
? projects.filter((p) => p.target_year === null)
|
||||
: projects.filter((p) => p.target_year === year);
|
||||
if (yearProjects.length === 0) return null;
|
||||
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||
return (
|
||||
@@ -479,7 +519,9 @@ export function CapitalProjectsPage() {
|
||||
<Table.Td fw={500}>{p.name}</Table.Td>
|
||||
<Table.Td>{p.category || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
{p.target_year === FUTURE_YEAR
|
||||
{p.target_year === null
|
||||
? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
|
||||
: p.target_year === FUTURE_YEAR
|
||||
? 'Future'
|
||||
: (
|
||||
<>
|
||||
@@ -511,9 +553,9 @@ export function CapitalProjectsPage() {
|
||||
</Table.Td>
|
||||
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||
{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</ActionIcon>}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -528,11 +570,20 @@ export function CapitalProjectsPage() {
|
||||
|
||||
// ---- Render: Kanban view ----
|
||||
|
||||
const maxPlannedYear = currentYear + 4; // last year in the 5-year window
|
||||
|
||||
const renderKanbanView = () => (
|
||||
<ScrollArea type="auto" offsetScrollbars>
|
||||
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
|
||||
{kanbanYears.map((year) => {
|
||||
const yearProjects = projects.filter((p) => p.target_year === year);
|
||||
// Unscheduled: projects with no target_year
|
||||
// Future: projects with target_year === 9999 OR beyond the 5-year window
|
||||
// Otherwise: exact year match
|
||||
const yearProjects = year === UNSCHEDULED
|
||||
? projects.filter((p) => p.target_year === null)
|
||||
: year === FUTURE_YEAR
|
||||
? projects.filter((p) => p.target_year === FUTURE_YEAR || (p.target_year !== null && p.target_year > maxPlannedYear))
|
||||
: projects.filter((p) => p.target_year === year);
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={year}
|
||||
|
||||
@@ -1,17 +1,296 @@
|
||||
import {
|
||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||
Badge, Loader, Center,
|
||||
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
|
||||
Popover, List,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCash,
|
||||
IconFileInvoice,
|
||||
IconShieldCheck,
|
||||
IconAlertTriangle,
|
||||
IconBuildingBank,
|
||||
IconTrendingUp,
|
||||
IconTrendingDown,
|
||||
IconMinus,
|
||||
IconHeartbeat,
|
||||
IconRefresh,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface HealthScore {
|
||||
id: string;
|
||||
score_type: string;
|
||||
score: number;
|
||||
previous_score: number | null;
|
||||
trajectory: string | null;
|
||||
label: string;
|
||||
summary: string;
|
||||
factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>;
|
||||
recommendations: Array<{ priority: string; text: string }>;
|
||||
missing_data: string[] | null;
|
||||
status: string;
|
||||
response_time_ms: number | null;
|
||||
calculated_at: string;
|
||||
}
|
||||
|
||||
interface HealthScoresData {
|
||||
operating: HealthScore | null;
|
||||
reserve: HealthScore | null;
|
||||
operating_last_failed?: boolean;
|
||||
reserve_last_failed?: boolean;
|
||||
}
|
||||
|
||||
function getScoreColor(score: number): string {
|
||||
if (score >= 75) return 'green';
|
||||
if (score >= 60) return 'yellow';
|
||||
if (score >= 40) return 'orange';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
|
||||
if (trajectory === 'improving') return <IconTrendingUp size={16} color="var(--mantine-color-green-6)" />;
|
||||
if (trajectory === 'declining') return <IconTrendingDown size={16} color="var(--mantine-color-red-6)" />;
|
||||
if (trajectory === 'stable') return <IconMinus size={16} color="var(--mantine-color-gray-6)" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function HealthScoreCard({
|
||||
score,
|
||||
title,
|
||||
icon,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
lastFailed,
|
||||
}: {
|
||||
score: HealthScore | null;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
lastFailed?: boolean;
|
||||
}) {
|
||||
// No score at all yet
|
||||
if (!score) {
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||
<Group gap={6}>
|
||||
{onRefresh && (
|
||||
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{icon}
|
||||
</Group>
|
||||
</Group>
|
||||
<Center h={100}>
|
||||
<Text c="dimmed" size="sm">No health score yet</Text>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Pending — missing data, can't calculate
|
||||
if (score.status === 'pending') {
|
||||
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
|
||||
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||
<Group gap={6}>
|
||||
{onRefresh && (
|
||||
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{icon}
|
||||
</Group>
|
||||
</Group>
|
||||
<Center>
|
||||
<Stack align="center" gap="xs">
|
||||
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
||||
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
|
||||
{missingItems.map((item: string, i: number) => (
|
||||
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// For error status, we still render the score data (cached from the previous
|
||||
// successful run) rather than blanking the card with "Error calculating score".
|
||||
// A small watermark under the timestamp tells the user it's stale.
|
||||
const showAsError = score.status === 'error' && score.score === 0 && !score.summary;
|
||||
|
||||
// Pure error with no cached data to fall back on
|
||||
if (showAsError) {
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||
<Group gap={6}>
|
||||
{onRefresh && (
|
||||
<Tooltip label={`Retry ${title.toLowerCase()} score`}>
|
||||
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||
loading={isRefreshing} onClick={onRefresh}>Retry</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{icon}
|
||||
</Group>
|
||||
</Group>
|
||||
<Center h={100}>
|
||||
<Stack align="center" gap={4}>
|
||||
<Badge color="red" variant="light">Error calculating score</Badge>
|
||||
<Text size="xs" c="dimmed">Click Retry to try again</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal display — works for both 'complete' and 'error' (with cached data)
|
||||
const color = getScoreColor(score.score);
|
||||
const factors = Array.isArray(score.factors) ? score.factors :
|
||||
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
|
||||
const recommendations = Array.isArray(score.recommendations) ? score.recommendations :
|
||||
(typeof score.recommendations === 'string' ? JSON.parse(score.recommendations) : []);
|
||||
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||
<Group gap={6}>
|
||||
{onRefresh && (
|
||||
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{icon}
|
||||
</Group>
|
||||
</Group>
|
||||
<Group align="flex-start" gap="lg">
|
||||
<RingProgress
|
||||
size={120}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: score.score, color }]}
|
||||
label={
|
||||
<Stack align="center" gap={0}>
|
||||
<Text fw={700} size="xl" ta="center" lh={1}>{score.score}</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">/100</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6}>
|
||||
<Badge color={color} variant="light" size="sm">{score.label}</Badge>
|
||||
{score.trajectory && (
|
||||
<Tooltip label={`Trend: ${score.trajectory}`}>
|
||||
<Group gap={2}>
|
||||
<TrajectoryIcon trajectory={score.trajectory} />
|
||||
<Text size="xs" c="dimmed">{score.trajectory}</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
)}
|
||||
{score.previous_score !== null && (
|
||||
<Text size="xs" c="dimmed">(prev: {score.previous_score})</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" lineClamp={2}>{score.summary}</Text>
|
||||
<Group gap={4} mt={2}>
|
||||
{factors.slice(0, 3).map((f: any, i: number) => (
|
||||
<Tooltip key={i} label={f.detail} multiline w={280}>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||
>
|
||||
{f.name}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
{(factors.length > 3 || recommendations.length > 0) && (
|
||||
<Popover width={350} position="bottom" shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge size="xs" variant="light" color="blue" style={{ cursor: 'pointer' }}>
|
||||
<IconInfoCircle size={10} /> Details
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
{factors.length > 0 && (
|
||||
<>
|
||||
<Text fw={600} size="xs">Factors</Text>
|
||||
{factors.map((f: any, i: number) => (
|
||||
<Group key={i} gap={6} wrap="nowrap">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{f.name}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">{f.detail}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{recommendations.length > 0 && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<Text fw={600} size="xs">Recommendations</Text>
|
||||
<List size="xs" spacing={4}>
|
||||
{recommendations.map((r: any, i: number) => (
|
||||
<List.Item key={i}>
|
||||
<Badge size="xs" color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'} variant="light" mr={4}>
|
||||
{r.priority}
|
||||
</Badge>
|
||||
{r.text}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
{score.calculated_at && (
|
||||
<Text size="xs" c="dimmed" ta="right" mt={4}>
|
||||
Updated: {new Date(score.calculated_at).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
{score.calculated_at && (
|
||||
<Stack gap={0} mt={6} align="flex-end">
|
||||
<Text size="10px" c="dimmed" style={{ opacity: 0.7 }}>
|
||||
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
{lastFailed && (
|
||||
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
||||
last analysis failed — showing cached data
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
total_cash: string;
|
||||
total_receivables: string;
|
||||
@@ -20,10 +299,23 @@ interface DashboardData {
|
||||
recent_transactions: {
|
||||
id: string; entry_date: string; description: string; entry_type: string; amount: string;
|
||||
}[];
|
||||
// Enhanced split data
|
||||
operating_cash: string;
|
||||
reserve_cash: string;
|
||||
operating_investments: string;
|
||||
reserve_investments: string;
|
||||
est_monthly_interest: string;
|
||||
interest_earned_ytd: string;
|
||||
planned_capital_spend: string;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Track whether last refresh attempt failed (per score type)
|
||||
const [operatingFailed, setOperatingFailed] = useState(false);
|
||||
const [reserveFailed, setReserveFailed] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery<DashboardData>({
|
||||
queryKey: ['dashboard'],
|
||||
@@ -31,15 +323,43 @@ export function DashboardPage() {
|
||||
enabled: !!currentOrg,
|
||||
});
|
||||
|
||||
const { data: healthScores } = useQuery<HealthScoresData>({
|
||||
queryKey: ['health-scores'],
|
||||
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||
enabled: !!currentOrg,
|
||||
});
|
||||
|
||||
// Separate mutations for each score type
|
||||
const recalcOperatingMutation = useMutation({
|
||||
mutationFn: () => api.post('/health-scores/calculate/operating'),
|
||||
onSuccess: () => {
|
||||
setOperatingFailed(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||
},
|
||||
onError: () => {
|
||||
setOperatingFailed(true);
|
||||
// Still refresh to get whatever the backend saved (could be cached data)
|
||||
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||
},
|
||||
});
|
||||
|
||||
const recalcReserveMutation = useMutation({
|
||||
mutationFn: () => api.post('/health-scores/calculate/reserve'),
|
||||
onSuccess: () => {
|
||||
setReserveFailed(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||
},
|
||||
onError: () => {
|
||||
setReserveFailed(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||
},
|
||||
});
|
||||
|
||||
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 opInv = parseFloat(data?.operating_investments || '0');
|
||||
const resInv = parseFloat(data?.reserve_investments || '0');
|
||||
|
||||
const entryTypeColors: Record<string, string> = {
|
||||
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
||||
@@ -47,13 +367,8 @@ export function DashboardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
||||
<Stack data-tour="dashboard-content">
|
||||
<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">
|
||||
@@ -66,24 +381,81 @@ export function DashboardPage() {
|
||||
<Center h={200}><Loader /></Center>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<HealthScoreCard
|
||||
score={healthScores?.operating || null}
|
||||
title="Operating Fund"
|
||||
icon={
|
||||
<ThemeIcon color="green" variant="light" size={36} radius="md">
|
||||
<IconHeartbeat size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
isRefreshing={recalcOperatingMutation.isPending}
|
||||
onRefresh={() => recalcOperatingMutation.mutate()}
|
||||
lastFailed={operatingFailed || !!healthScores?.operating_last_failed}
|
||||
/>
|
||||
<HealthScoreCard
|
||||
score={healthScores?.reserve || null}
|
||||
title="Reserve Fund"
|
||||
icon={
|
||||
<ThemeIcon color="violet" variant="light" size={36} radius="md">
|
||||
<IconHeartbeat size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
isRefreshing={recalcReserveMutation.isPending}
|
||||
onRefresh={() => recalcReserveMutation.mutate()}
|
||||
lastFailed={reserveFailed || !!healthScores?.reserve_last_failed}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title} withBorder padding="lg" radius="md">
|
||||
<Card 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>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
|
||||
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
|
||||
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
|
||||
</div>
|
||||
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
|
||||
<stat.icon size={28} />
|
||||
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||
<IconCash size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Fund</Text>
|
||||
<Text fw={700} size="xl">{fmt(data?.reserve_cash || '0')}</Text>
|
||||
{resInv > 0 && <Text size="xs" c="teal">Investments: {fmt(resInv)}</Text>}
|
||||
</div>
|
||||
<ThemeIcon color="violet" variant="light" size={48} radius="md">
|
||||
<IconShieldCheck size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Receivables</Text>
|
||||
<Text fw={700} size="xl">{fmt(data?.total_receivables || '0')}</Text>
|
||||
</div>
|
||||
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
||||
<IconFileInvoice size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Delinquent Accounts</Text>
|
||||
<Text fw={700} size="xl">{String(data?.delinquent_units || 0)}</Text>
|
||||
</div>
|
||||
<ThemeIcon color="orange" variant="light" size={48} radius="md">
|
||||
<IconAlertTriangle size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
@@ -120,17 +492,31 @@ export function DashboardPage() {
|
||||
<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>
|
||||
<Text size="sm" c="dimmed">Operating Cash</Text>
|
||||
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Reserve Cash</Text>
|
||||
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
|
||||
</Group>
|
||||
<Divider my={4} />
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
|
||||
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
|
||||
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Planned Capital Spend</Text>
|
||||
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
||||
</Group>
|
||||
<Divider my={4} />
|
||||
<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'}>
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Stack,
|
||||
Card,
|
||||
SimpleGrid,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
Divider,
|
||||
Accordion,
|
||||
Paper,
|
||||
Tabs,
|
||||
Collapse,
|
||||
ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBulb,
|
||||
IconCash,
|
||||
IconBuildingBank,
|
||||
IconChartAreaLine,
|
||||
IconAlertTriangle,
|
||||
IconSparkles,
|
||||
IconRefresh,
|
||||
IconCoin,
|
||||
IconPigMoney,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface FinancialSummary {
|
||||
operating_cash: number;
|
||||
reserve_cash: number;
|
||||
operating_investments: number;
|
||||
reserve_investments: number;
|
||||
total_operating: number;
|
||||
total_reserve: number;
|
||||
total_all: number;
|
||||
}
|
||||
|
||||
interface FinancialSnapshot {
|
||||
summary: FinancialSummary;
|
||||
investment_accounts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
investment_type: string;
|
||||
fund_type: string;
|
||||
principal: string;
|
||||
interest_rate: string;
|
||||
maturity_date: string | null;
|
||||
current_value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MarketRate {
|
||||
bank_name: string;
|
||||
apy: string;
|
||||
min_deposit: string | null;
|
||||
term: string;
|
||||
term_months: number | null;
|
||||
rate_type: string;
|
||||
fetched_at: string;
|
||||
}
|
||||
|
||||
interface MarketRatesResponse {
|
||||
cd: MarketRate[];
|
||||
money_market: MarketRate[];
|
||||
high_yield_savings: MarketRate[];
|
||||
}
|
||||
|
||||
interface Recommendation {
|
||||
type: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
summary: string;
|
||||
details: string;
|
||||
fund_type: string;
|
||||
suggested_amount?: number;
|
||||
suggested_term?: string;
|
||||
suggested_rate?: number;
|
||||
bank_name?: string;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
interface AIResponse {
|
||||
recommendations: Recommendation[];
|
||||
overall_assessment: string;
|
||||
risk_notes: string[];
|
||||
}
|
||||
|
||||
interface SavedRecommendation {
|
||||
id: string;
|
||||
recommendations: Recommendation[];
|
||||
overall_assessment: string;
|
||||
risk_notes: string[];
|
||||
response_time_ms: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const fmt = (v: number) =>
|
||||
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
high: 'red',
|
||||
medium: 'yellow',
|
||||
low: 'blue',
|
||||
};
|
||||
|
||||
const typeIcons: Record<string, any> = {
|
||||
cd_ladder: IconChartAreaLine,
|
||||
new_investment: IconBuildingBank,
|
||||
reallocation: IconRefresh,
|
||||
maturity_action: IconCash,
|
||||
liquidity_warning: IconAlertTriangle,
|
||||
general: IconBulb,
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
cd_ladder: 'CD Ladder',
|
||||
new_investment: 'New Investment',
|
||||
reallocation: 'Reallocation',
|
||||
maturity_action: 'Maturity Action',
|
||||
liquidity_warning: 'Liquidity',
|
||||
general: 'General',
|
||||
};
|
||||
|
||||
// ── Rate Table Component ──
|
||||
|
||||
function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean }) {
|
||||
if (rates.length === 0) {
|
||||
return (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No rates available. Run the market rate fetcher to populate data.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Bank</Table.Th>
|
||||
<Table.Th ta="right">APY</Table.Th>
|
||||
{showTerm && <Table.Th>Term</Table.Th>}
|
||||
<Table.Th ta="right">Min Deposit</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rates.map((r, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td fw={500}>{r.bank_name}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} c="green">
|
||||
{parseFloat(r.apy).toFixed(2)}%
|
||||
</Table.Td>
|
||||
{showTerm && <Table.Td>{r.term}</Table.Td>}
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{r.min_deposit
|
||||
? `$${parseFloat(r.min_deposit).toLocaleString()}`
|
||||
: '-'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recommendations Display Component ──
|
||||
|
||||
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
|
||||
return (
|
||||
<Stack>
|
||||
{/* Last Updated timestamp */}
|
||||
{lastUpdated && (
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Overall Assessment */}
|
||||
<Alert color="blue" variant="light" title="Overall Assessment">
|
||||
<Text size="sm">{aiResult.overall_assessment}</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Risk Notes */}
|
||||
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
title="Risk Notes"
|
||||
icon={<IconAlertTriangle />}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{aiResult.risk_notes.map((note, i) => (
|
||||
<Text key={i} size="sm">
|
||||
{note}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Recommendation Cards */}
|
||||
{aiResult.recommendations.length > 0 ? (
|
||||
<Accordion variant="separated">
|
||||
{aiResult.recommendations.map((rec, i) => {
|
||||
const Icon = typeIcons[rec.type] || IconBulb;
|
||||
return (
|
||||
<Accordion.Item key={i} value={`rec-${i}`}>
|
||||
<Accordion.Control>
|
||||
<Group>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={priorityColors[rec.priority] || 'gray'}
|
||||
size="md"
|
||||
>
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{rec.title}</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={priorityColors[rec.priority]}
|
||||
>
|
||||
{rec.priority}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light">
|
||||
{typeLabels[rec.type] || rec.type}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dot"
|
||||
color={
|
||||
rec.fund_type === 'reserve'
|
||||
? 'violet'
|
||||
: rec.fund_type === 'operating'
|
||||
? 'blue'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
{rec.fund_type}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{rec.summary}
|
||||
</Text>
|
||||
</div>
|
||||
{rec.suggested_amount != null && (
|
||||
<Text fw={700} ff="monospace" c="green" size="lg">
|
||||
{fmt(rec.suggested_amount)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{rec.details}</Text>
|
||||
|
||||
{(rec.suggested_term ||
|
||||
rec.suggested_rate != null ||
|
||||
rec.bank_name) && (
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
{rec.suggested_term && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Suggested Term
|
||||
</Text>
|
||||
<Text fw={600}>{rec.suggested_term}</Text>
|
||||
</div>
|
||||
)}
|
||||
{rec.suggested_rate != null && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Target Rate
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{rec.suggested_rate}% APY
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{rec.bank_name && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Bank
|
||||
</Text>
|
||||
<Text fw={600}>{rec.bank_name}</Text>
|
||||
</div>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Alert variant="light" color="gray" title="Rationale">
|
||||
<Text size="sm">{rec.rationale}</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No specific recommendations at this time.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function InvestmentPlanningPage() {
|
||||
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||
|
||||
// Load financial snapshot on mount
|
||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||
queryKey: ['investment-planning-snapshot'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/investment-planning/snapshot');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Load market rates (all types) on mount
|
||||
const { data: marketRates, isLoading: ratesLoading } = useQuery<MarketRatesResponse>({
|
||||
queryKey: ['investment-planning-market-rates'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/investment-planning/market-rates');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Load saved recommendation on mount
|
||||
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
||||
queryKey: ['investment-planning-saved-recommendation'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Populate AI results from saved recommendation on load
|
||||
useEffect(() => {
|
||||
if (savedRec && !aiResult) {
|
||||
setAiResult({
|
||||
recommendations: savedRec.recommendations,
|
||||
overall_assessment: savedRec.overall_assessment,
|
||||
risk_notes: savedRec.risk_notes,
|
||||
});
|
||||
setLastUpdated(savedRec.created_at);
|
||||
}
|
||||
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// AI recommendation (on-demand)
|
||||
const aiMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 });
|
||||
return data as AIResponse;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAiResult(data);
|
||||
setLastUpdated(new Date().toISOString());
|
||||
if (data.recommendations.length > 0) {
|
||||
notifications.show({
|
||||
message: `Generated ${data.recommendations.length} investment recommendations`,
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message: err.response?.data?.message || 'Failed to get AI recommendations',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (snapshotLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const s = snapshot?.summary;
|
||||
|
||||
// Determine the latest fetched_at timestamp across all rate types
|
||||
const allRatesList = [
|
||||
...(marketRates?.cd || []),
|
||||
...(marketRates?.money_market || []),
|
||||
...(marketRates?.high_yield_savings || []),
|
||||
];
|
||||
const latestFetchedAt = allRatesList.length > 0
|
||||
? allRatesList.reduce((latest, r) =>
|
||||
new Date(r.fetched_at) > new Date(latest.fetched_at) ? r : latest,
|
||||
).fetched_at
|
||||
: null;
|
||||
|
||||
const totalRateCount =
|
||||
(marketRates?.cd?.length || 0) +
|
||||
(marketRates?.money_market?.length || 0) +
|
||||
(marketRates?.high_yield_savings?.length || 0);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Page Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={2}>Investment Planning</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Account overview, market rates, and AI-powered investment recommendations
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* ── Section 1: Financial Snapshot Cards ── */}
|
||||
{s && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="blue" size="sm">
|
||||
<IconCash size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Operating Cash
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(s.operating_cash)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Investments: {fmt(s.operating_investments)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="violet" size="sm">
|
||||
<IconPigMoney size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Reserve Cash
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(s.reserve_cash)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Investments: {fmt(s.reserve_investments)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="teal" size="sm">
|
||||
<IconChartAreaLine size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Total All Funds
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(s.total_all)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Operating: {fmt(s.total_operating)} | Reserve: {fmt(s.total_reserve)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="green" size="sm">
|
||||
<IconCoin size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Total Invested
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{fmt(s.operating_investments + s.reserve_investments)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Earning interest across all accounts
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* ── Section 2: Current Investments Table ── */}
|
||||
{snapshot?.investment_accounts && snapshot.investment_accounts.length > 0 && (
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Current Investments
|
||||
</Title>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Institution</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Principal</Table.Th>
|
||||
<Table.Th ta="right">Rate</Table.Th>
|
||||
<Table.Th>Maturity</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{snapshot.investment_accounts.map((inv) => (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td fw={500}>{inv.name}</Table.Td>
|
||||
<Table.Td>{inv.institution || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{inv.investment_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}
|
||||
>
|
||||
{inv.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{fmt(parseFloat(inv.principal))}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{parseFloat(inv.interest_rate || '0').toFixed(2)}%
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{inv.maturity_date
|
||||
? new Date(inv.maturity_date).toLocaleDateString()
|
||||
: '-'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Section 3: Today's Market Rates (Collapsible with Tabs) ── */}
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb={ratesExpanded ? 'md' : 0}>
|
||||
<Group gap="xs">
|
||||
<Title order={4}>Today's Market Rates</Title>
|
||||
{totalRateCount > 0 && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{totalRateCount} rates
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{latestFetchedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Last fetched: {new Date(latestFetchedAt).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setRatesExpanded((v) => !v)}
|
||||
title={ratesExpanded ? 'Collapse rates' : 'Expand rates'}
|
||||
>
|
||||
{ratesExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Collapse in={ratesExpanded}>
|
||||
{ratesLoading ? (
|
||||
<Center py="lg">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<Tabs defaultValue="cd">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="cd">
|
||||
CDs {(marketRates?.cd?.length || 0) > 0 && (
|
||||
<Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
|
||||
)}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="money_market">
|
||||
Money Market {(marketRates?.money_market?.length || 0) > 0 && (
|
||||
<Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
|
||||
)}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="high_yield_savings">
|
||||
High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
|
||||
<Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
|
||||
)}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="cd" pt="sm">
|
||||
<RateTable rates={marketRates?.cd || []} showTerm={true} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="money_market" pt="sm">
|
||||
<RateTable rates={marketRates?.money_market || []} showTerm={false} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="high_yield_savings" pt="sm">
|
||||
<RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Section 4: AI Investment Recommendations ── */}
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="grape" size="md">
|
||||
<IconSparkles size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={4}>AI Investment Recommendations</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
Powered by AI analysis of your complete financial picture
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => aiMutation.mutate()}
|
||||
loading={aiMutation.isPending}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'grape', to: 'violet' }}
|
||||
>
|
||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Loading State */}
|
||||
{aiMutation.isPending && (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader size="lg" type="dots" />
|
||||
<Text c="dimmed" size="sm">
|
||||
Analyzing your financial data and market rates...
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
This may take a few minutes for complex tenant data
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{aiResult && !aiMutation.isPending && (
|
||||
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!aiResult && !aiMutation.isPending && (
|
||||
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
||||
<IconSparkles size={28} />
|
||||
</ThemeIcon>
|
||||
<Text fw={500} mb={4}>
|
||||
AI-Powered Investment Analysis
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" maw={500} mx="auto">
|
||||
Click "Get AI Recommendations" to analyze your accounts, cash flow,
|
||||
budget, and capital projects against current market rates. The AI will
|
||||
suggest specific investment moves to maximize interest income while
|
||||
maintaining adequate liquidity.
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface Investment {
|
||||
id: string; name: string; institution: string; account_number_last4: string;
|
||||
@@ -25,6 +26,7 @@ export function InvestmentsPage() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editing, setEditing] = useState<Investment | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
||||
queryKey: ['investments'],
|
||||
@@ -76,6 +78,11 @@ export function InvestmentsPage() {
|
||||
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
const totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0);
|
||||
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
|
||||
const projectedInterest = investments.reduce((s, i) => {
|
||||
const value = parseFloat(i.current_value || i.principal || '0');
|
||||
const rate = parseFloat(i.interest_rate || '0');
|
||||
return s + (value * rate / 100);
|
||||
}, 0);
|
||||
|
||||
const daysRemainingColor = (days: number | null) => {
|
||||
if (days === null) return 'gray';
|
||||
@@ -90,12 +97,13 @@ export function InvestmentsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Investment Accounts</Title>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
|
||||
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>}
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 4 }}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3, lg: 5 }}>
|
||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
|
||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
|
||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</Text></Card>
|
||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Projected Annual Interest</Text><Text fw={700} size="xl" c="blue">{fmt(projectedInterest)}</Text></Card>
|
||||
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
|
||||
</SimpleGrid>
|
||||
<Table striped highlightOnHover>
|
||||
@@ -133,7 +141,7 @@ export function InvestmentsPage() {
|
||||
) : '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
||||
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, Badge, Modal,
|
||||
NumberInput, Select, Loader, Center, Card,
|
||||
NumberInput, Select, Loader, Center, Card, Alert,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFileInvoice, IconSend } from '@tabler/icons-react';
|
||||
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
@@ -15,15 +14,55 @@ interface Invoice {
|
||||
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
||||
invoice_date: string; due_date: string; invoice_type: string;
|
||||
description: string; amount: string; amount_paid: string; balance_due: string;
|
||||
status: string;
|
||||
status: string; period_start: string; period_end: string;
|
||||
assessment_group_name: string; frequency: string; owner_name: string;
|
||||
}
|
||||
|
||||
interface PreviewGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
frequency: string;
|
||||
active_units: number;
|
||||
regular_assessment: string;
|
||||
special_assessment: string;
|
||||
is_billing_month: boolean;
|
||||
total_amount: number;
|
||||
period_description: string;
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
month: number;
|
||||
year: number;
|
||||
month_name: string;
|
||||
groups: PreviewGroup[];
|
||||
summary: {
|
||||
total_groups_billing: number;
|
||||
total_invoices: number;
|
||||
total_amount: number;
|
||||
};
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
|
||||
draft: 'gray', pending: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
|
||||
};
|
||||
|
||||
const frequencyColors: Record<string, string> = {
|
||||
monthly: 'blue', quarterly: 'teal', annual: 'violet',
|
||||
};
|
||||
|
||||
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
/** Extract last name from "First Last" format */
|
||||
const getLastName = (ownerName: string | null) => {
|
||||
if (!ownerName) return '-';
|
||||
const parts = ownerName.trim().split(/\s+/);
|
||||
return parts.length > 1 ? parts[parts.length - 1] : ownerName;
|
||||
};
|
||||
|
||||
export function InvoicesPage() {
|
||||
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
@@ -35,13 +74,36 @@ export function InvoicesPage() {
|
||||
initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() },
|
||||
});
|
||||
|
||||
// Fetch preview when month/year changes
|
||||
const fetchPreview = async (month: number, year: number) => {
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const { data } = await api.post('/invoices/generate-preview', { month, year });
|
||||
setPreview(data);
|
||||
} catch {
|
||||
setPreview(null);
|
||||
}
|
||||
setPreviewLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (bulkOpened) {
|
||||
fetchPreview(bulkForm.values.month, bulkForm.values.year);
|
||||
}
|
||||
}, [bulkOpened, bulkForm.values.month, bulkForm.values.year]);
|
||||
|
||||
const bulkMutation = useMutation({
|
||||
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
|
||||
notifications.show({ message: `Generated ${res.data.created} invoices`, color: 'green' });
|
||||
const groupInfo = res.data.groups?.map((g: any) => `${g.group_name}: ${g.invoices_created}`).join(', ') || '';
|
||||
notifications.show({
|
||||
message: `Generated ${res.data.created} invoices${groupInfo ? ` (${groupInfo})` : ''}`,
|
||||
color: 'green',
|
||||
});
|
||||
closeBulk();
|
||||
setPreview(null);
|
||||
},
|
||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||
});
|
||||
@@ -54,8 +116,6 @@ export function InvoicesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0);
|
||||
@@ -66,18 +126,20 @@ export function InvoicesPage() {
|
||||
<Title order={2}>Invoices</Title>
|
||||
<Group>
|
||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Monthly Invoices</Button>
|
||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group>
|
||||
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
||||
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(String(totalOutstanding))}</Text></Card>
|
||||
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(totalOutstanding)}</Text></Card>
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Date</Table.Th>
|
||||
<Table.Th>Due</Table.Th><Table.Th>Type</Table.Th><Table.Th ta="right">Amount</Table.Th>
|
||||
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Group</Table.Th><Table.Th>Date</Table.Th>
|
||||
<Table.Th>Due</Table.Th><Table.Th>Period</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
<Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@@ -86,27 +148,104 @@ export function InvoicesPage() {
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td fw={500}>{i.invoice_number}</Table.Td>
|
||||
<Table.Td>{i.unit_number}</Table.Td>
|
||||
<Table.Td>{getLastName(i.owner_name)}</Table.Td>
|
||||
<Table.Td>
|
||||
{i.assessment_group_name ? (
|
||||
<Badge size="sm" variant="light" color={frequencyColors[i.frequency] || 'gray'}>
|
||||
{i.assessment_group_name}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light">{i.invoice_type}</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light">{i.invoice_type}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
{i.period_start && i.period_end ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(i.period_start).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}
|
||||
{i.period_start !== i.period_end && (
|
||||
<> - {new Date(i.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}</>
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">-</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td>
|
||||
<Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={9}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
|
||||
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Modal opened={bulkOpened} onClose={closeBulk} title="Generate Monthly Assessments">
|
||||
|
||||
<Modal opened={bulkOpened} onClose={() => { closeBulk(); setPreview(null); }} title="Generate Assessments" size="lg">
|
||||
<form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}>
|
||||
<Stack>
|
||||
<Group grow>
|
||||
<Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} />
|
||||
<NumberInput label="Year" {...bulkForm.getInputProps('year')} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">This will generate invoices for all active units based on their monthly assessment amount.</Text>
|
||||
<Button type="submit" loading={bulkMutation.isPending}>Generate Invoices</Button>
|
||||
|
||||
{previewLoading && <Center py="md"><Loader size="sm" /></Center>}
|
||||
|
||||
{preview && !previewLoading && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>Billing Preview for {preview.month_name} {preview.year}</Text>
|
||||
|
||||
{preview.groups.map((g) => (
|
||||
<Card key={g.id} withBorder p="xs" style={{ opacity: g.is_billing_month ? 1 : 0.5 }}>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
{g.is_billing_month && g.active_units > 0
|
||||
? <IconCheck size={16} color="green" />
|
||||
: <IconX size={16} color="gray" />
|
||||
}
|
||||
<div>
|
||||
<Group gap={6}>
|
||||
<Text size="sm" fw={500}>{g.name}</Text>
|
||||
<Badge size="xs" color={frequencyColors[g.frequency]} variant="light">
|
||||
{g.frequency}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{g.is_billing_month
|
||||
? `${g.active_units} units - ${g.period_description}`
|
||||
: `Not a billing month for this group`
|
||||
}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{g.is_billing_month && (
|
||||
<Text size="sm" fw={500} ff="monospace">{fmt(g.total_amount)}</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{preview.summary.total_invoices > 0 ? (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
Will generate {preview.summary.total_invoices} invoices across{' '}
|
||||
{preview.summary.total_groups_billing} group(s) totaling {fmt(preview.summary.total_amount)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">
|
||||
No assessment groups have billing scheduled for {preview.month_name}. No invoices will be generated.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={bulkMutation.isPending}
|
||||
disabled={!preview || preview.summary.total_invoices === 0}
|
||||
>
|
||||
Generate {preview?.summary.total_invoices || 0} Invoices
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||
|
||||
interface ActualLine {
|
||||
@@ -64,6 +65,7 @@ export function MonthlyActualsPage() {
|
||||
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
||||
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||
const y = new Date().getFullYear() - 2 + i;
|
||||
@@ -204,6 +206,7 @@ export function MonthlyActualsPage() {
|
||||
hideControls
|
||||
decimalScale={2}
|
||||
allowNegative
|
||||
disabled={isReadOnly}
|
||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
</Table.Td>
|
||||
@@ -229,6 +232,7 @@ export function MonthlyActualsPage() {
|
||||
<Group>
|
||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
@@ -237,6 +241,7 @@ export function MonthlyActualsPage() {
|
||||
>
|
||||
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface OrgMember {
|
||||
id: string;
|
||||
@@ -52,6 +52,7 @@ export function OrgMembersPage() {
|
||||
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { user, currentOrg } = useAuthStore();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
||||
queryKey: ['org-members'],
|
||||
@@ -162,9 +163,11 @@ export function OrgMembersPage() {
|
||||
<Title order={2}>Organization Members</Title>
|
||||
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
||||
Add Member
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
@@ -259,6 +262,7 @@ export function OrgMembersPage() {
|
||||
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!isReadOnly && (
|
||||
<Group gap={4}>
|
||||
<Tooltip label="Change role">
|
||||
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
|
||||
@@ -273,6 +277,7 @@ export function OrgMembersPage() {
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, Badge, Modal,
|
||||
NumberInput, Select, TextInput, Loader, Center,
|
||||
NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface Payment {
|
||||
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
||||
invoice_number: string; payment_date: string; amount: string;
|
||||
payment_method: string; reference_number: string; status: string;
|
||||
payment_method: string; reference_number: string; status: string; notes: string;
|
||||
}
|
||||
|
||||
export function PaymentsPage() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editing, setEditing] = useState<Payment | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
||||
queryKey: ['payments'],
|
||||
@@ -37,10 +41,18 @@ export function PaymentsPage() {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
invoice_id: '', amount: 0, payment_method: 'check',
|
||||
reference_number: '', payment_date: new Date(),
|
||||
reference_number: '', payment_date: new Date(), notes: '',
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: any) => {
|
||||
const inv = invoices.find((i: any) => i.id === values.invoice_id);
|
||||
@@ -51,22 +63,88 @@ export function PaymentsPage() {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
invalidateAll();
|
||||
notifications.show({ message: 'Payment recorded', color: 'green' });
|
||||
close(); form.reset();
|
||||
close(); setEditing(null); form.reset();
|
||||
},
|
||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (values: any) => {
|
||||
return api.put(`/payments/${editing!.id}`, {
|
||||
payment_date: values.payment_date.toISOString().split('T')[0],
|
||||
amount: values.amount,
|
||||
payment_method: values.payment_method,
|
||||
reference_number: values.reference_number,
|
||||
notes: values.notes,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
notifications.show({ message: 'Payment updated', color: 'green' });
|
||||
close(); setEditing(null); form.reset();
|
||||
},
|
||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/payments/${id}`),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
notifications.show({ message: 'Payment deleted', color: 'orange' });
|
||||
setDeleteConfirm(null);
|
||||
close(); setEditing(null); form.reset();
|
||||
},
|
||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||
});
|
||||
|
||||
const handleEdit = (payment: Payment) => {
|
||||
setEditing(payment);
|
||||
form.setValues({
|
||||
invoice_id: payment.invoice_id || '',
|
||||
amount: parseFloat(payment.amount || '0'),
|
||||
payment_method: payment.payment_method || 'check',
|
||||
reference_number: payment.reference_number || '',
|
||||
payment_date: new Date(payment.payment_date),
|
||||
notes: payment.notes || '',
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditing(null);
|
||||
form.reset();
|
||||
open();
|
||||
};
|
||||
|
||||
const handleSubmit = (values: any) => {
|
||||
if (editing) {
|
||||
updateMutation.mutate(values);
|
||||
} else {
|
||||
createMutation.mutate(values);
|
||||
}
|
||||
};
|
||||
|
||||
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const invoiceOptions = invoices.map((i: any) => ({
|
||||
const formatPeriod = (inv: any) => {
|
||||
if (inv.period_start && inv.period_end) {
|
||||
const start = new Date(inv.period_start).toLocaleDateString(undefined, { month: 'short' });
|
||||
const end = new Date(inv.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
||||
return inv.period_start === inv.period_end ? start : `${start}-${end}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const invoiceOptions = invoices.map((i: any) => {
|
||||
const period = formatPeriod(i);
|
||||
const periodStr = period ? ` - ${period}` : '';
|
||||
return {
|
||||
value: i.id,
|
||||
label: `${i.invoice_number} - ${i.unit_number || 'Unit'} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
|
||||
}));
|
||||
label: `${i.invoice_number} - ${i.unit_number || 'Unit'}${periodStr} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
|
||||
};
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
@@ -74,7 +152,7 @@ export function PaymentsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Payments</Title>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>
|
||||
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Record Payment</Button>}
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
@@ -82,6 +160,7 @@ export function PaymentsPage() {
|
||||
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
|
||||
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
|
||||
{!isReadOnly && <Table.Th></Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -94,18 +173,34 @@ export function PaymentsPage() {
|
||||
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
|
||||
<Table.Td>{p.reference_number}</Table.Td>
|
||||
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
|
||||
{!isReadOnly && (
|
||||
<Table.Td>
|
||||
<Tooltip label="Edit payment">
|
||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
{payments.length === 0 && (
|
||||
<Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
|
||||
<Table.Tr><Table.Td colSpan={isReadOnly ? 7 : 8}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Modal opened={opened} onClose={close} title="Record Payment">
|
||||
<form onSubmit={form.onSubmit((v) => createMutation.mutate(v))}>
|
||||
|
||||
{/* Create / Edit Payment Modal */}
|
||||
<Modal opened={opened} onClose={() => { close(); setEditing(null); form.reset(); }} title={editing ? 'Edit Payment' : 'Record Payment'}>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
{!editing && (
|
||||
<Select label="Invoice" required data={invoiceOptions} searchable
|
||||
{...form.getInputProps('invoice_id')} />
|
||||
)}
|
||||
{editing && (
|
||||
<TextInput label="Invoice" value={editing.invoice_number || 'N/A'} disabled />
|
||||
)}
|
||||
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
|
||||
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
|
||||
{...form.getInputProps('amount')} />
|
||||
@@ -116,10 +211,60 @@ export function PaymentsPage() {
|
||||
]} {...form.getInputProps('payment_method')} />
|
||||
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
|
||||
{...form.getInputProps('reference_number')} />
|
||||
<Button type="submit" loading={createMutation.isPending}>Record Payment</Button>
|
||||
<TextInput label="Notes" placeholder="Optional notes"
|
||||
{...form.getInputProps('notes')} />
|
||||
|
||||
<Group justify="space-between">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => setDeleteConfirm(editing)}
|
||||
>
|
||||
Delete Payment
|
||||
</Button>
|
||||
<Button type="submit" loading={updateMutation.isPending}>
|
||||
Update Payment
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" fullWidth loading={createMutation.isPending}>Record Payment</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
opened={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
title="Delete Payment"
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
Are you sure you want to delete this payment of{' '}
|
||||
<Text span fw={700}>{deleteConfirm ? fmt(deleteConfirm.amount) : ''}</Text>{' '}
|
||||
for unit {deleteConfirm?.unit_number}?
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
This will also remove the associated journal entry and recalculate the invoice balance.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||
>
|
||||
Delete Payment
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen }
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
@@ -78,6 +79,7 @@ export function ProjectsPage() {
|
||||
const [editing, setEditing] = useState<Project | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
@@ -331,6 +333,7 @@ export function ProjectsPage() {
|
||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
|
||||
Export CSV
|
||||
</Button>
|
||||
{!isReadOnly && (<>
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}>
|
||||
Import CSV
|
||||
@@ -339,6 +342,7 @@ export function ProjectsPage() {
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
+ Add Project
|
||||
</Button>
|
||||
</>)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -451,9 +455,11 @@ export function ProjectsPage() {
|
||||
</Table.Td>
|
||||
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
|
||||
<Table.Td>
|
||||
{!isReadOnly && (
|
||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconCash, IconArrowUpRight, IconArrowDownRight,
|
||||
IconWallet, IconReportMoney, IconSearch,
|
||||
IconWallet, IconReportMoney, IconSearch, IconHeartRateMonitor,
|
||||
} from '@tabler/icons-react';
|
||||
import api from '../../services/api';
|
||||
|
||||
@@ -58,6 +58,16 @@ export function CashFlowPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: aiRec } = useQuery<{ overall_assessment?: string; risk_notes?: string[] } | null>({
|
||||
queryKey: ['saved-recommendation'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||
return data;
|
||||
} catch { return null; }
|
||||
},
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
setQueryFrom(fromDate);
|
||||
setQueryTo(toDate);
|
||||
@@ -68,6 +78,10 @@ export function CashFlowPage() {
|
||||
|
||||
const totalOperating = parseFloat(data?.total_operating || '0');
|
||||
const totalReserve = parseFloat(data?.total_reserve || '0');
|
||||
const opInflows = (data?.operating_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0);
|
||||
const opOutflows = Math.abs((data?.operating_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0));
|
||||
const resInflows = (data?.reserve_activities || []).filter(a => a.amount > 0).reduce((s, a) => s + a.amount, 0);
|
||||
const resOutflows = Math.abs((data?.reserve_activities || []).filter(a => a.amount < 0).reduce((s, a) => s + a.amount, 0));
|
||||
const beginningCash = parseFloat(data?.beginning_cash || '0');
|
||||
const endingCash = parseFloat(data?.ending_cash || '0');
|
||||
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
||||
@@ -132,10 +146,14 @@ export function CashFlowPage() {
|
||||
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
|
||||
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Operating</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Activity</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
|
||||
{fmt(totalOperating)}
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="green">In: {fmt(opInflows)}</Text>
|
||||
<Text size="xs" c="red">Out: {fmt(opOutflows)}</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="lg" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
|
||||
{totalOperating >= 0 ? '+' : ''}{fmt(totalOperating)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
@@ -143,20 +161,31 @@ export function CashFlowPage() {
|
||||
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
|
||||
<IconReportMoney size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Reserve</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Activity</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
|
||||
{fmt(totalReserve)}
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="green">In: {fmt(resInflows)}</Text>
|
||||
<Text size="xs" c="red">Out: {fmt(resOutflows)}</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="lg" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
|
||||
{totalReserve >= 0 ? '+' : ''}{fmt(totalReserve)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="teal" size="sm">
|
||||
<IconCash size={14} />
|
||||
<ThemeIcon variant="light" color={aiRec?.overall_assessment ? 'teal' : 'gray'} size="sm">
|
||||
<IconHeartRateMonitor size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending {balanceLabel}</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Financial Health</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
|
||||
{aiRec?.overall_assessment ? (
|
||||
<Text fw={600} size="sm" lineClamp={3}>{aiRec.overall_assessment}</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text fw={700} size="xl" c="dimmed">TBD</Text>
|
||||
<Text size="xs" c="dimmed">Pending AI Analysis</Text>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
|
||||
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Stack, Text, Card, Loader, Center,
|
||||
Badge, SimpleGrid, Select, ThemeIcon, Alert,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
||||
} from '@tabler/icons-react';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface BudgetVsActualItem {
|
||||
account_id: string;
|
||||
account_number: string;
|
||||
name: string;
|
||||
account_type: string;
|
||||
fund_type: string;
|
||||
quarter_budget: number;
|
||||
quarter_actual: number;
|
||||
quarter_variance: number;
|
||||
ytd_budget: number;
|
||||
ytd_actual: number;
|
||||
ytd_variance: number;
|
||||
variance_pct?: string;
|
||||
}
|
||||
|
||||
interface IncomeStatement {
|
||||
income: { name: string; amount: string; fund_type: string }[];
|
||||
expenses: { name: string; amount: string; fund_type: string }[];
|
||||
total_income: string;
|
||||
total_expenses: string;
|
||||
net_income: string;
|
||||
}
|
||||
|
||||
interface QuarterlyData {
|
||||
year: number;
|
||||
quarter: number;
|
||||
quarter_label: string;
|
||||
date_range: { from: string; to: string };
|
||||
quarter_income_statement: IncomeStatement;
|
||||
ytd_income_statement: IncomeStatement;
|
||||
budget_vs_actual: BudgetVsActualItem[];
|
||||
over_budget_items: BudgetVsActualItem[];
|
||||
}
|
||||
|
||||
export function QuarterlyReportPage() {
|
||||
const now = new Date();
|
||||
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
||||
const defaultQuarter = currentQuarter;
|
||||
const defaultYear = now.getFullYear();
|
||||
|
||||
const [year, setYear] = useState(String(defaultYear));
|
||||
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
||||
|
||||
const { data, isLoading } = useQuery<QuarterlyData>({
|
||||
queryKey: ['quarterly-report', year, quarter],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/reports/quarterly?year=${year}&quarter=${quarter}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const fmt = (v: string | number) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||
const y = now.getFullYear() - 2 + i;
|
||||
return { value: String(y), label: String(y) };
|
||||
});
|
||||
|
||||
const quarterOptions = [
|
||||
{ value: '1', label: 'Q1 (Jan-Mar)' },
|
||||
{ value: '2', label: 'Q2 (Apr-Jun)' },
|
||||
{ value: '3', label: 'Q3 (Jul-Sep)' },
|
||||
{ value: '4', label: 'Q4 (Oct-Dec)' },
|
||||
];
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
const qIS = data?.quarter_income_statement;
|
||||
const ytdIS = data?.ytd_income_statement;
|
||||
const bva = data?.budget_vs_actual || [];
|
||||
const overBudget = data?.over_budget_items || [];
|
||||
|
||||
const qRevenue = parseFloat(qIS?.total_income || '0');
|
||||
const qExpenses = parseFloat(qIS?.total_expenses || '0');
|
||||
const qNet = parseFloat(qIS?.net_income || '0');
|
||||
const ytdNet = parseFloat(ytdIS?.net_income || '0');
|
||||
|
||||
const incomeItems = bva.filter((b) => b.account_type === 'income');
|
||||
const expenseItems = bva.filter((b) => b.account_type === 'expense');
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Quarterly Financial Report</Title>
|
||||
<Group>
|
||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||
<Select data={quarterOptions} value={quarter} onChange={(v) => v && setQuarter(v)} w={160} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{data && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.quarter_label} · {new Date(data.date_range.from + 'T00:00:00').toLocaleDateString()} – {new Date(data.date_range.to + 'T00:00:00').toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="green" size="sm"><IconTrendingUp size={14} /></ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Revenue</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c="green">{fmt(qRevenue)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="red" size="sm"><IconTrendingDown size={14} /></ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Expenses</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c="red">{fmt(qExpenses)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color={qNet >= 0 ? 'green' : 'red'} size="sm">
|
||||
<IconChartBar size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Net</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c={qNet >= 0 ? 'green' : 'red'}>{fmt(qNet)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color={ytdNet >= 0 ? 'green' : 'red'} size="sm">
|
||||
<IconChartBar size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>YTD Net</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c={ytdNet >= 0 ? 'green' : 'red'}>{fmt(ytdNet)}</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Over-Budget Alert */}
|
||||
{overBudget.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Group mb="md">
|
||||
<IconAlertTriangle size={20} color="var(--mantine-color-orange-6)" />
|
||||
<Title order={4}>Over-Budget Items ({overBudget.length})</Title>
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Account</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Budget</Table.Th>
|
||||
<Table.Th ta="right">Actual</Table.Th>
|
||||
<Table.Th ta="right">Over By</Table.Th>
|
||||
<Table.Th ta="right">% Over</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{overBudget.map((item) => (
|
||||
<Table.Tr key={item.account_id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>{item.name}</Text>
|
||||
<Text size="xs" c="dimmed">{item.account_number}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||
{item.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_actual)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_variance)}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Badge color="red" variant="light" size="sm">+{item.variance_pct}%</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Budget vs Actuals */}
|
||||
<Card withBorder>
|
||||
<Title order={4} mb="md">Budget vs Actuals</Title>
|
||||
{bva.length === 0 ? (
|
||||
<Alert variant="light" color="blue">No budget or actual data for this quarter.</Alert>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover style={{ minWidth: 900 }}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Account</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Q Budget</Table.Th>
|
||||
<Table.Th ta="right">Q Actual</Table.Th>
|
||||
<Table.Th ta="right">Q Variance</Table.Th>
|
||||
<Table.Th ta="right">YTD Budget</Table.Th>
|
||||
<Table.Th ta="right">YTD Actual</Table.Th>
|
||||
<Table.Th ta="right">YTD Variance</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{incomeItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{incomeItems.map((item) => (
|
||||
<BVARow key={item.account_id} item={item} isExpense={false} />
|
||||
))}
|
||||
{incomeItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{expenseItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{expenseItems.map((item) => (
|
||||
<BVARow key={item.account_id} item={item} isExpense={true} />
|
||||
))}
|
||||
{expenseItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BVARow({ item, isExpense }: { item: BudgetVsActualItem; isExpense: boolean }) {
|
||||
const fmt = (v: number) =>
|
||||
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
// For expenses, over budget (positive variance) is bad (red)
|
||||
// For income, under budget (negative variance) is bad (red)
|
||||
const qVarianceColor = isExpense
|
||||
? (item.quarter_variance > 0 ? 'red' : 'green')
|
||||
: (item.quarter_variance < 0 ? 'red' : 'green');
|
||||
const ytdVarianceColor = isExpense
|
||||
? (item.ytd_variance > 0 ? 'red' : 'green')
|
||||
: (item.ytd_variance < 0 ? 'red' : 'green');
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Text size="sm">{item.name}</Text>
|
||||
<Text size="xs" c="dimmed">{item.account_number}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||
{item.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_actual)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={item.quarter_variance !== 0 ? qVarianceColor : undefined}>
|
||||
{fmt(item.quarter_variance)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_budget)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_actual)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={item.ytd_variance !== 0 ? ytdVarianceColor : undefined}>
|
||||
{fmt(item.ytd_variance)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid,
|
||||
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid, SegmentedControl,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -52,6 +52,8 @@ export function SankeyPage() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [source, setSource] = useState('actuals');
|
||||
const [fundFilter, setFundFilter] = useState('all');
|
||||
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||
const y = new Date().getFullYear() - 2 + i;
|
||||
@@ -59,9 +61,12 @@ export function SankeyPage() {
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery<CashFlowData>({
|
||||
queryKey: ['sankey', year],
|
||||
queryKey: ['sankey', year, source, fundFilter],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/reports/cash-flow-sankey?year=${year}`);
|
||||
const params = new URLSearchParams({ year });
|
||||
if (source !== 'actuals') params.set('source', source);
|
||||
if (fundFilter !== 'all') params.set('fundType', fundFilter);
|
||||
const { data } = await api.get(`/reports/cash-flow-sankey?${params}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
@@ -191,6 +196,31 @@ export function SankeyPage() {
|
||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Text size="sm" fw={500}>Data source:</Text>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={source}
|
||||
onChange={setSource}
|
||||
data={[
|
||||
{ label: 'Actuals', value: 'actuals' },
|
||||
{ label: 'Budget', value: 'budget' },
|
||||
{ label: 'Forecast', value: 'forecast' },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" fw={500} ml="md">Fund:</Text>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={fundFilter}
|
||||
onChange={setFundFilter}
|
||||
data={[
|
||||
{ label: 'All Funds', value: 'all' },
|
||||
{ label: 'Operating', value: 'operating' },
|
||||
{ label: 'Reserve', value: 'reserve' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface ReserveComponent {
|
||||
id: string; name: string; category: string; description: string;
|
||||
@@ -26,6 +27,7 @@ export function ReservesPage() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
||||
queryKey: ['reserve-components'],
|
||||
@@ -89,7 +91,7 @@ export function ReservesPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Reserve Components</Title>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>
|
||||
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>}
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Card withBorder p="md">
|
||||
@@ -139,7 +141,7 @@ export function ReservesPage() {
|
||||
{c.condition_rating}/10
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
||||
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SettingsPage() {
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Version</Text>
|
||||
<Badge variant="light">0.2.0 MVP_P2</Badge>
|
||||
<Badge variant="light">2026.3.2 (beta)</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">API</Text>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
|
||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface JournalEntryLine {
|
||||
id?: string;
|
||||
@@ -48,6 +49,7 @@ export function TransactionsPage() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
||||
queryKey: ['journal-entries'],
|
||||
@@ -164,9 +166,11 @@ export function TransactionsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Journal Entries</Title>
|
||||
{!isReadOnly && (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={open}>
|
||||
New Entry
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
@@ -216,14 +220,14 @@ export function TransactionsPage() {
|
||||
<IconEye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{!e.is_posted && !e.is_void && (
|
||||
{!isReadOnly && !e.is_posted && !e.is_void && (
|
||||
<Tooltip label="Post">
|
||||
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
|
||||
<IconCheck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{e.is_posted && !e.is_void && (
|
||||
{!isReadOnly && e.is_posted && !e.is_void && (
|
||||
<Tooltip label="Void">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
|
||||
<IconX size={16} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface Unit {
|
||||
id: string;
|
||||
@@ -42,6 +43,7 @@ export function UnitsPage() {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||
queryKey: ['units'],
|
||||
@@ -163,6 +165,7 @@ export function UnitsPage() {
|
||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
|
||||
Export CSV
|
||||
</Button>
|
||||
{!isReadOnly && (<>
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}>
|
||||
Import CSV
|
||||
@@ -175,6 +178,7 @@ export function UnitsPage() {
|
||||
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -224,6 +228,7 @@ export function UnitsPage() {
|
||||
</Table.Td>
|
||||
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
{!isReadOnly && (
|
||||
<Group gap={4}>
|
||||
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
|
||||
<IconEdit size={16} />
|
||||
@@ -234,6 +239,7 @@ export function UnitsPage() {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
23
frontend/src/pages/vendors/VendorsPage.tsx
vendored
23
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -3,18 +3,21 @@ import {
|
||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||
Switch, Badge, ActionIcon, Text, Loader, Center,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||
|
||||
interface Vendor {
|
||||
id: string; name: string; contact_name: string; email: string; phone: string;
|
||||
address_line1: string; city: string; state: string; zip_code: string;
|
||||
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
|
||||
last_negotiated: string | null;
|
||||
}
|
||||
|
||||
export function VendorsPage() {
|
||||
@@ -23,6 +26,7 @@ export function VendorsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||
queryKey: ['vendors'],
|
||||
@@ -34,12 +38,19 @@ export function VendorsPage() {
|
||||
name: '', contact_name: '', email: '', phone: '',
|
||||
address_line1: '', city: '', state: '', zip_code: '',
|
||||
tax_id: '', is_1099_eligible: false,
|
||||
last_negotiated: null as Date | null,
|
||||
},
|
||||
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: any) => editing ? api.put(`/vendors/${editing.id}`, values) : api.post('/vendors', values),
|
||||
mutationFn: (values: any) => {
|
||||
const payload = {
|
||||
...values,
|
||||
last_negotiated: values.last_negotiated ? values.last_negotiated.toISOString().split('T')[0] : null,
|
||||
};
|
||||
return editing ? api.put(`/vendors/${editing.id}`, payload) : api.post('/vendors', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
||||
notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' });
|
||||
@@ -91,6 +102,7 @@ export function VendorsPage() {
|
||||
phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '',
|
||||
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
|
||||
is_1099_eligible: v.is_1099_eligible,
|
||||
last_negotiated: v.last_negotiated ? new Date(v.last_negotiated) : null,
|
||||
});
|
||||
open();
|
||||
};
|
||||
@@ -107,12 +119,14 @@ export function VendorsPage() {
|
||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
|
||||
Export CSV
|
||||
</Button>
|
||||
{!isReadOnly && (<>
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}>
|
||||
Import CSV
|
||||
</Button>
|
||||
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
|
||||
</>)}
|
||||
</Group>
|
||||
</Group>
|
||||
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
|
||||
@@ -122,6 +136,7 @@ export function VendorsPage() {
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
|
||||
<Table.Th>Phone</Table.Th><Table.Th>1099</Table.Th>
|
||||
<Table.Th>Last Negotiated</Table.Th>
|
||||
<Table.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@@ -133,11 +148,12 @@ export function VendorsPage() {
|
||||
<Table.Td>{v.email}</Table.Td>
|
||||
<Table.Td>{v.phone}</Table.Td>
|
||||
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
|
||||
<Table.Td>{v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
|
||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
||||
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
|
||||
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
||||
@@ -157,6 +173,7 @@ export function VendorsPage() {
|
||||
</Group>
|
||||
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
|
||||
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
|
||||
<DateInput label="Last Negotiated" clearable placeholder="Select date" {...form.getInputProps('last_negotiated')} />
|
||||
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -21,6 +21,16 @@ api.interceptors.response.use(
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
// Handle org suspended/archived — redirect to org selection
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
typeof error.response?.data?.message === 'string' &&
|
||||
error.response.data.message.includes('has been')
|
||||
) {
|
||||
const store = useAuthStore.getState();
|
||||
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
|
||||
window.location.href = '/select-org';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ interface Organization {
|
||||
name: string;
|
||||
role: string;
|
||||
schemaName?: string;
|
||||
status?: string;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -14,6 +16,15 @@ interface User {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isSuperadmin?: boolean;
|
||||
isPlatformOwner?: boolean;
|
||||
hasSeenIntro?: boolean;
|
||||
}
|
||||
|
||||
interface ImpersonationOriginal {
|
||||
token: string;
|
||||
user: User;
|
||||
organizations: Organization[];
|
||||
currentOrg: Organization | null;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
@@ -21,18 +32,27 @@ interface AuthState {
|
||||
user: User | null;
|
||||
organizations: Organization[];
|
||||
currentOrg: Organization | null;
|
||||
impersonationOriginal: ImpersonationOriginal | null;
|
||||
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
||||
setCurrentOrg: (org: Organization, token?: string) => void;
|
||||
setUserIntroSeen: () => void;
|
||||
setOrgSettings: (settings: Record<string, any>) => void;
|
||||
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
||||
stopImpersonation: () => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
/** Hook to check if the current user has read-only (viewer) access */
|
||||
export const useIsReadOnly = () => useAuthStore((s) => s.currentOrg?.role === 'viewer');
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
organizations: [],
|
||||
currentOrg: null,
|
||||
impersonationOriginal: null,
|
||||
setAuth: (token, user, organizations) =>
|
||||
set({
|
||||
token,
|
||||
@@ -46,22 +66,61 @@ export const useAuthStore = create<AuthState>()(
|
||||
currentOrg: org,
|
||||
token: token || state.token,
|
||||
})),
|
||||
setUserIntroSeen: () =>
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, hasSeenIntro: true } : null,
|
||||
})),
|
||||
setOrgSettings: (settings) =>
|
||||
set((state) => ({
|
||||
currentOrg: state.currentOrg
|
||||
? { ...state.currentOrg, settings: { ...(state.currentOrg.settings || {}), ...settings } }
|
||||
: null,
|
||||
})),
|
||||
startImpersonation: (token, user, organizations) => {
|
||||
const state = get();
|
||||
set({
|
||||
impersonationOriginal: {
|
||||
token: state.token!,
|
||||
user: state.user!,
|
||||
organizations: state.organizations,
|
||||
currentOrg: state.currentOrg,
|
||||
},
|
||||
token,
|
||||
user,
|
||||
organizations,
|
||||
currentOrg: null,
|
||||
});
|
||||
},
|
||||
stopImpersonation: () => {
|
||||
const { impersonationOriginal } = get();
|
||||
if (impersonationOriginal) {
|
||||
set({
|
||||
token: impersonationOriginal.token,
|
||||
user: impersonationOriginal.user,
|
||||
organizations: impersonationOriginal.organizations,
|
||||
currentOrg: impersonationOriginal.currentOrg,
|
||||
impersonationOriginal: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
logout: () =>
|
||||
set({
|
||||
token: null,
|
||||
user: null,
|
||||
organizations: [],
|
||||
currentOrg: null,
|
||||
impersonationOriginal: null,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'ledgeriq-auth',
|
||||
version: 3,
|
||||
version: 5,
|
||||
migrate: () => ({
|
||||
token: null,
|
||||
user: null,
|
||||
organizations: [],
|
||||
currentOrg: null,
|
||||
impersonationOriginal: null,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: ['app.hoaledgeriq.com'],
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
18
nginx/certbot-init.conf
Normal file
18
nginx/certbot-init.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
# Temporary nginx config — used ONLY during the initial certbot certificate
|
||||
# request. Once the cert is obtained, switch to ssl.conf and restart nginx.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Certbot ACME challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Return 503 for everything else so it's obvious this is not the real app
|
||||
location / {
|
||||
return 503 "SSL certificate is being provisioned. Try again in a minute.\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,22 @@ server {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
|
||||
location /api/investment-planning/recommendations {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
# Everything else -> Vite dev server (frontend)
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
|
||||
99
nginx/host-production.conf
Normal file
99
nginx/host-production.conf
Normal file
@@ -0,0 +1,99 @@
|
||||
# HOA LedgerIQ — Host-level nginx config (production)
|
||||
#
|
||||
# Copy this file to /etc/nginx/sites-available/app.yourdomain.com
|
||||
# and symlink to /etc/nginx/sites-enabled/:
|
||||
#
|
||||
# sudo cp nginx/host-production.conf /etc/nginx/sites-available/app.yourdomain.com
|
||||
# sudo ln -s /etc/nginx/sites-available/app.yourdomain.com /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Then obtain an SSL certificate:
|
||||
# sudo certbot --nginx -d app.yourdomain.com
|
||||
#
|
||||
# Replace "app.yourdomain.com" with your actual hostname throughout this file.
|
||||
|
||||
# --- Rate limiting ---
|
||||
# 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs)
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
|
||||
# --- HTTP → HTTPS redirect ---
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.yourdomain.com;
|
||||
|
||||
# Let certbot answer ACME challenges
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Everything else → HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# --- Main HTTPS server ---
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.yourdomain.com;
|
||||
|
||||
# SSL certificates (managed by certbot)
|
||||
ssl_certificate /etc/letsencrypt/live/app.yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.yourdomain.com/privkey.pem;
|
||||
|
||||
# Modern TLS settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# --- Proxy defaults ---
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Buffer settings — prevent 502s when backend is slow to respond
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
|
||||
# --- API routes → NestJS backend (port 3000) ---
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=30 nodelay;
|
||||
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 15s;
|
||||
}
|
||||
|
||||
# AI endpoints — longer timeouts (LLM calls can take minutes)
|
||||
location /api/investment-planning/recommendations {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
location /api/health-scores/calculate {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
# --- Frontend → React SPA served by nginx (port 3001) ---
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_read_timeout 10s;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
65
nginx/production.conf
Normal file
65
nginx/production.conf
Normal file
@@ -0,0 +1,65 @@
|
||||
upstream backend {
|
||||
server backend:3000;
|
||||
keepalive 32; # reuse connections to backend
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:3001;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# Shared proxy settings
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection ""; # enable keepalive to upstreams
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Buffer settings — prevent 502s when backend is slow to respond
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
|
||||
# Rate limit zone (10 req/s per IP for API)
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
|
||||
# HTTP server — SSL termination is handled by the host reverse proxy
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# --- API routes → backend ---
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=30 nodelay;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 15s;
|
||||
}
|
||||
|
||||
# AI endpoints → longer timeouts
|
||||
location /api/investment-planning/recommendations {
|
||||
proxy_pass http://backend;
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
location /api/health-scores/calculate {
|
||||
proxy_pass http://backend;
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
# --- Static frontend → built React assets ---
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_read_timeout 10s;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
106
nginx/ssl.conf
Normal file
106
nginx/ssl.conf
Normal file
@@ -0,0 +1,106 @@
|
||||
upstream backend {
|
||||
server backend:3000;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:3001;
|
||||
}
|
||||
|
||||
# Redirect all HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Let certbot answer ACME challenges over HTTP
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Everything else -> HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl;
|
||||
# Replace with your actual hostname:
|
||||
server_name staging.example.com;
|
||||
|
||||
# --- TLS certificates (managed by certbot) ---
|
||||
ssl_certificate /etc/letsencrypt/live/staging.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/staging.example.com/privkey.pem;
|
||||
|
||||
# --- Modern TLS settings ---
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# --- Security headers ---
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
# --- Proxy routes (same as default.conf) ---
|
||||
|
||||
# API requests -> NestJS backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
|
||||
location /api/investment-planning/recommendations {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
# AI health-score endpoint also needs a longer timeout
|
||||
location /api/health-scores/calculate {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 180s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
# Everything else -> Vite dev server (frontend)
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
47
scripts/README.md
Normal file
47
scripts/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# HOA LedgerIQ - Scripts
|
||||
|
||||
Standalone scripts for data fetching, maintenance, and automation tasks.
|
||||
|
||||
## CD Rate Fetcher
|
||||
|
||||
Scrapes the top 25 CD rates from [Bankrate.com](https://www.bankrate.com/banking/cds/cd-rates/) and stores them in the `shared.cd_rates` PostgreSQL table.
|
||||
|
||||
**Note:** Bankrate renders rate data dynamically via JavaScript, so this script uses Puppeteer (headless Chrome) to fully render the page before extracting data.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- PostgreSQL with the `shared.cd_rates` table (created by `db/init/00-init.sql` or `db/migrations/005-cd-rates.sql`)
|
||||
- A `.env` file at the project root with `DATABASE_URL`
|
||||
|
||||
### Manual Execution
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
npm install
|
||||
npx tsx fetch-cd-rates.ts
|
||||
```
|
||||
|
||||
### Cron Setup
|
||||
|
||||
To run daily at 6:00 AM:
|
||||
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
|
||||
# Add this line (adjust path to your project directory):
|
||||
0 6 * * * cd /path/to/HOA_Financial_Platform/scripts && /usr/local/bin/npx tsx fetch-cd-rates.ts >> /var/log/hoa-cd-rates.log 2>&1
|
||||
```
|
||||
|
||||
For Docker-based deployments, you can use a host cron job that executes into the container:
|
||||
|
||||
```bash
|
||||
0 6 * * * docker exec hoa-backend sh -c "cd /app/scripts && npx tsx fetch-cd-rates.ts" >> /var/log/hoa-cd-rates.log 2>&1
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **0 rates extracted**: Bankrate likely changed their page structure. Inspect the page DOM in a browser and update the CSS selectors in `fetch-cd-rates.ts`.
|
||||
- **Database connection error**: Verify `DATABASE_URL` in `.env` points to the correct PostgreSQL instance. For local development (outside Docker), use `localhost:5432` instead of `postgres:5432`.
|
||||
- **Puppeteer launch error**: Ensure Chromium dependencies are installed. On Ubuntu: `apt-get install -y libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libasound2`
|
||||
296
scripts/db-backup.sh
Executable file
296
scripts/db-backup.sh
Executable file
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# db-backup.sh — Backup & restore the HOA LedgerIQ PostgreSQL database
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/db-backup.sh backup [--dir /path/to/backups] [--keep N]
|
||||
# ./scripts/db-backup.sh restore <file.sql.gz | file.dump.gz>
|
||||
# ./scripts/db-backup.sh list [--dir /path/to/backups]
|
||||
#
|
||||
# Backup produces a gzipped custom-format dump with a timestamped filename:
|
||||
# backups/hoafinance_2026-03-02_140530.dump.gz
|
||||
#
|
||||
# Cron example (daily at 2 AM, keep 30 days):
|
||||
# 0 2 * * * cd /opt/hoa-ledgeriq && ./scripts/db-backup.sh backup --keep 30
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- Defaults ----
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BACKUP_DIR="$PROJECT_DIR/backups"
|
||||
KEEP_DAYS=0 # 0 = keep forever
|
||||
DB_USER="${POSTGRES_USER:-hoafinance}"
|
||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
||||
COMPOSE_CMD="docker compose"
|
||||
|
||||
# If running with the SSL override, detect it
|
||||
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
|
||||
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
|
||||
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
|
||||
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
|
||||
fi
|
||||
|
||||
# ---- Colors ----
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
die() { err "$@"; exit 1; }
|
||||
|
||||
# ---- Helpers ----
|
||||
|
||||
ensure_postgres_running() {
|
||||
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
|
||||
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
|
||||
fi
|
||||
}
|
||||
|
||||
format_size() {
|
||||
local bytes=$1
|
||||
if (( bytes >= 1073741824 )); then printf "%.1f GB" "$(echo "$bytes / 1073741824" | bc -l)"
|
||||
elif (( bytes >= 1048576 )); then printf "%.1f MB" "$(echo "$bytes / 1048576" | bc -l)"
|
||||
elif (( bytes >= 1024 )); then printf "%.1f KB" "$(echo "$bytes / 1024" | bc -l)"
|
||||
else printf "%d B" "$bytes"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- BACKUP ----
|
||||
|
||||
do_backup() {
|
||||
ensure_postgres_running
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
local timestamp
|
||||
timestamp="$(date +%Y-%m-%d_%H%M%S)"
|
||||
local filename="${DB_NAME}_${timestamp}.dump.gz"
|
||||
local filepath="$BACKUP_DIR/$filename"
|
||||
|
||||
info "Starting backup of database '${DB_NAME}' ..."
|
||||
|
||||
# pg_dump inside the container, stream through gzip on the host
|
||||
$COMPOSE_CMD exec -T postgres pg_dump \
|
||||
-U "$DB_USER" \
|
||||
-d "$DB_NAME" \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
--format=custom \
|
||||
| gzip -9 > "$filepath"
|
||||
|
||||
local size
|
||||
size=$(wc -c < "$filepath" | tr -d ' ')
|
||||
|
||||
if [ "$size" -lt 100 ]; then
|
||||
rm -f "$filepath"
|
||||
die "Backup file is suspiciously small — something went wrong. Check docker compose logs postgres."
|
||||
fi
|
||||
|
||||
ok "Backup complete: ${filepath} ($(format_size "$size"))"
|
||||
|
||||
# ---- Prune old backups ----
|
||||
if [ "$KEEP_DAYS" -gt 0 ]; then
|
||||
local pruned=0
|
||||
while IFS= read -r old_file; do
|
||||
rm -f "$old_file"
|
||||
((pruned++))
|
||||
done < <(find "$BACKUP_DIR" -name "${DB_NAME}_*.dump.gz" -mtime +"$KEEP_DAYS" -type f 2>/dev/null)
|
||||
if [ "$pruned" -gt 0 ]; then
|
||||
info "Pruned $pruned backup(s) older than $KEEP_DAYS days"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- RESTORE ----
|
||||
|
||||
do_restore() {
|
||||
local file="$1"
|
||||
|
||||
# Resolve relative path
|
||||
if [[ "$file" != /* ]]; then
|
||||
file="$(pwd)/$file"
|
||||
fi
|
||||
|
||||
[ -f "$file" ] || die "File not found: $file"
|
||||
|
||||
ensure_postgres_running
|
||||
|
||||
echo ""
|
||||
warn "This will DESTROY the current '${DB_NAME}' database and replace it"
|
||||
warn "with the contents of: $(basename "$file")"
|
||||
echo ""
|
||||
read -rp "Type 'yes' to continue: " confirm
|
||||
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
|
||||
|
||||
echo ""
|
||||
info "Step 1/4 — Terminating active connections ..."
|
||||
$COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d postgres -c "
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
|
||||
" > /dev/null 2>&1 || true
|
||||
|
||||
info "Step 2/4 — Dropping and recreating database ..."
|
||||
$COMPOSE_CMD exec -T postgres dropdb -U "$DB_USER" --if-exists "$DB_NAME"
|
||||
$COMPOSE_CMD exec -T postgres createdb -U "$DB_USER" "$DB_NAME"
|
||||
|
||||
info "Step 3/4 — Restoring from $(basename "$file") ..."
|
||||
|
||||
if [[ "$file" == *.dump.gz ]]; then
|
||||
# Custom-format dump, gzipped → decompress and pipe to pg_restore
|
||||
gunzip -c "$file" | $COMPOSE_CMD exec -T postgres pg_restore \
|
||||
-U "$DB_USER" \
|
||||
-d "$DB_NAME" \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
--exit-on-error 2>&1 | tail -5 || true
|
||||
|
||||
elif [[ "$file" == *.sql.gz ]]; then
|
||||
# Plain SQL dump, gzipped → decompress and pipe to psql
|
||||
gunzip -c "$file" | $COMPOSE_CMD exec -T postgres psql \
|
||||
-U "$DB_USER" \
|
||||
-d "$DB_NAME" \
|
||||
--quiet 2>&1 | tail -5 || true
|
||||
|
||||
elif [[ "$file" == *.dump ]]; then
|
||||
# Custom-format dump, not compressed
|
||||
$COMPOSE_CMD exec -T postgres pg_restore \
|
||||
-U "$DB_USER" \
|
||||
-d "$DB_NAME" \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
--exit-on-error < "$file" 2>&1 | tail -5 || true
|
||||
|
||||
elif [[ "$file" == *.sql ]]; then
|
||||
# Plain SQL dump, not compressed
|
||||
$COMPOSE_CMD exec -T postgres psql \
|
||||
-U "$DB_USER" \
|
||||
-d "$DB_NAME" \
|
||||
--quiet < "$file" 2>&1 | tail -5 || true
|
||||
|
||||
else
|
||||
die "Unsupported file format. Expected .dump.gz, .sql.gz, .dump, or .sql"
|
||||
fi
|
||||
|
||||
info "Step 4/4 — Restarting backend ..."
|
||||
$COMPOSE_CMD restart backend > /dev/null 2>&1
|
||||
|
||||
echo ""
|
||||
ok "Restore complete. Backend restarted."
|
||||
|
||||
# Quick sanity check
|
||||
local tenant_count
|
||||
tenant_count=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
|
||||
-t -c "SELECT count(*) FROM shared.organizations WHERE status = 'active';" 2>/dev/null | tr -d ' ')
|
||||
info "Active tenants found: ${tenant_count:-0}"
|
||||
}
|
||||
|
||||
# ---- LIST ----
|
||||
|
||||
do_list() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
local count=0
|
||||
echo ""
|
||||
printf " %-42s %10s %s\n" "FILENAME" "SIZE" "DATE"
|
||||
printf " %-42s %10s %s\n" "--------" "----" "----"
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
local size
|
||||
size=$(wc -c < "$f" | tr -d ' ')
|
||||
local mod_date
|
||||
mod_date=$(date -r "$f" "+%Y-%m-%d %H:%M" 2>/dev/null || stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)
|
||||
printf " %-42s %10s %s\n" "$(basename "$f")" "$(format_size "$size")" "$mod_date"
|
||||
((count++))
|
||||
done < <(find "$BACKUP_DIR" -name "${DB_NAME}_*" -type f 2>/dev/null | sort)
|
||||
|
||||
echo ""
|
||||
if [ "$count" -eq 0 ]; then
|
||||
info "No backups found in $BACKUP_DIR"
|
||||
else
|
||||
info "$count backup(s) in $BACKUP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- CLI ----
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
HOA LedgerIQ Database Backup & Restore
|
||||
|
||||
Usage:
|
||||
$(basename "$0") backup [--dir DIR] [--keep DAYS] Create a timestamped gzipped backup
|
||||
$(basename "$0") restore FILE Restore from a backup file
|
||||
$(basename "$0") list [--dir DIR] List available backups
|
||||
|
||||
Options:
|
||||
--dir DIR Backup directory (default: ./backups)
|
||||
--keep DAYS Auto-delete backups older than DAYS (default: keep all)
|
||||
|
||||
Supported restore formats:
|
||||
.dump.gz Custom-format pg_dump, gzipped (default backup format)
|
||||
.sql.gz Plain SQL dump, gzipped
|
||||
.dump Custom-format pg_dump, uncompressed
|
||||
.sql Plain SQL dump, uncompressed
|
||||
|
||||
Cron example (daily at 2 AM, retain 30 days):
|
||||
0 2 * * * cd /opt/hoa-ledgeriq && ./scripts/db-backup.sh backup --keep 30
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse command
|
||||
COMMAND="${1:-}"
|
||||
shift 2>/dev/null || true
|
||||
|
||||
[ -z "$COMMAND" ] && usage
|
||||
|
||||
# Parse flags
|
||||
RESTORE_FILE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) BACKUP_DIR="$2"; shift 2 ;;
|
||||
--keep) KEEP_DAYS="$2"; shift 2 ;;
|
||||
--help) usage ;;
|
||||
*)
|
||||
if [ "$COMMAND" = "restore" ] && [ -z "$RESTORE_FILE" ]; then
|
||||
RESTORE_FILE="$1"; shift
|
||||
else
|
||||
die "Unknown argument: $1"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Load .env if present (for POSTGRES_USER / POSTGRES_DB)
|
||||
if [ -f "$PROJECT_DIR/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$PROJECT_DIR/.env"
|
||||
set +a
|
||||
DB_USER="${POSTGRES_USER:-hoafinance}"
|
||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
||||
fi
|
||||
|
||||
case "$COMMAND" in
|
||||
backup)
|
||||
do_backup
|
||||
;;
|
||||
restore)
|
||||
[ -z "$RESTORE_FILE" ] && die "Usage: $(basename "$0") restore <file>"
|
||||
do_restore "$RESTORE_FILE"
|
||||
;;
|
||||
list)
|
||||
do_list
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
die "Unknown command: $COMMAND (try: backup, restore, list)"
|
||||
;;
|
||||
esac
|
||||
483
scripts/fetch-cd-rates.ts
Normal file
483
scripts/fetch-cd-rates.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Market Rate Fetcher Script
|
||||
*
|
||||
* Scrapes the top CD, Money Market, and High Yield Savings rates from
|
||||
* Bankrate.com and stores them in the shared.cd_rates table in PostgreSQL.
|
||||
* Designed to run standalone via cron (once per day).
|
||||
*
|
||||
* Historical data is preserved — each fetch adds new rows with the current
|
||||
* timestamp. The application queries only the latest batch per rate type.
|
||||
*
|
||||
* Usage:
|
||||
* cd scripts
|
||||
* npm install
|
||||
* npx tsx fetch-cd-rates.ts
|
||||
*
|
||||
* Environment:
|
||||
* DATABASE_URL - PostgreSQL connection string (reads from ../.env)
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { Pool } from 'pg';
|
||||
import puppeteer, { type Browser, type Page } from 'puppeteer';
|
||||
|
||||
// Load .env from project root
|
||||
dotenv.config({ path: resolve(__dirname, '..', '.env') });
|
||||
|
||||
const MAX_RATES = 25;
|
||||
|
||||
// Rate source configurations
|
||||
const RATE_SOURCES = [
|
||||
{
|
||||
type: 'cd',
|
||||
label: 'CD Rates',
|
||||
url: 'https://www.bankrate.com/banking/cds/cd-rates/',
|
||||
},
|
||||
{
|
||||
type: 'high_yield_savings',
|
||||
label: 'High Yield Savings',
|
||||
url: 'https://www.bankrate.com/banking/savings/best-high-yield-interests-savings-accounts/',
|
||||
},
|
||||
{
|
||||
type: 'money_market',
|
||||
label: 'Money Market',
|
||||
url: 'https://www.bankrate.com/banking/money-market/rates/',
|
||||
},
|
||||
];
|
||||
|
||||
interface MarketRate {
|
||||
bank_name: string;
|
||||
apy: number;
|
||||
min_deposit: number | null;
|
||||
term: string;
|
||||
term_months: number | null;
|
||||
rate_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a term string like "3 months", "1 year", "18 months" into a month count.
|
||||
*/
|
||||
function parseTermMonths(term: string): number | null {
|
||||
const lower = term.toLowerCase().trim();
|
||||
const monthMatch = lower.match(/(\d+)\s*mo(?:nth)?/);
|
||||
if (monthMatch) return parseInt(monthMatch[1], 10);
|
||||
// Handle fractional years like "1.5 years" or "1.5 yr"
|
||||
const fracYearMatch = lower.match(/([\d.]+)\s*y(?:ear|r)/);
|
||||
if (fracYearMatch) return Math.round(parseFloat(fracYearMatch[1]) * 12);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a currency string like "$500", "$1,000", "$0", "No minimum" into a number or null.
|
||||
*/
|
||||
function parseMinDeposit(raw: string): number | null {
|
||||
if (!raw) return null;
|
||||
const cleaned = raw.replace(/[^0-9.]/g, '');
|
||||
if (!cleaned) return null;
|
||||
const val = parseFloat(cleaned);
|
||||
return isNaN(val) ? null : val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an APY string like "4.50%", "4.50% APY" into a number.
|
||||
* Handles edge cases like ".4.50%" (leading period from adjacent text).
|
||||
*/
|
||||
function parseApy(raw: string): number {
|
||||
// Extract the first valid decimal number (digit-leading) from the string
|
||||
const match = raw.match(/(\d+\.?\d*)/);
|
||||
if (!match) return 0;
|
||||
const val = parseFloat(match[1]);
|
||||
return isNaN(val) ? 0 : val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause execution for a given number of milliseconds.
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a Bankrate URL and scrape rate data from individual bank offer cards.
|
||||
*
|
||||
* Bankrate uses a card-based layout with two sections:
|
||||
* - .wrt-RateSections-sponsoredoffers (sponsored bank offers)
|
||||
* - .wrt-RateSections-additionaloffers (additional bank offers)
|
||||
*
|
||||
* Each card (.rounded-md) contains:
|
||||
* - Bank name in img[alt] (the logo)
|
||||
* - APY after "APY as of" text
|
||||
* - Min. deposit (CDs) or Min. balance for APY (savings/MM)
|
||||
* - Term (CDs only): e.g. "1yr", "14mo"
|
||||
*
|
||||
* The page also has a summary table (.wealth-product-rate-list) with "best rates"
|
||||
* per term but NO bank names — we explicitly skip this table.
|
||||
*/
|
||||
async function fetchRatesFromPage(
|
||||
browser: Browser,
|
||||
sourceUrl: string,
|
||||
rateType: string,
|
||||
label: string,
|
||||
): Promise<MarketRate[]> {
|
||||
const page: Page = await browser.newPage();
|
||||
await page.setUserAgent(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
);
|
||||
|
||||
try {
|
||||
console.log(`\n--- Fetching ${label} ---`);
|
||||
console.log(`Navigating to ${sourceUrl}...`);
|
||||
await page.goto(sourceUrl, {
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
// Wait for rate card sections to render
|
||||
console.log('Waiting for rate cards to render...');
|
||||
await page.waitForSelector(
|
||||
'.wrt-RateSections-sponsoredoffers .rounded-md, .wrt-RateSections-additionaloffers .rounded-md',
|
||||
{ timeout: 30000 },
|
||||
).catch(() => {
|
||||
console.log('Bankrate card selectors not found, will try fallback...');
|
||||
});
|
||||
|
||||
// Extra wait for dynamic content
|
||||
await sleep(3000);
|
||||
|
||||
// Scroll down to load all content
|
||||
console.log('Scrolling to load all content...');
|
||||
await page.evaluate(async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
window.scrollBy(0, 800);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await sleep(2000);
|
||||
|
||||
// Extract rate data from individual bank offer cards
|
||||
const rawRates = await page.evaluate((maxRates: number) => {
|
||||
const results: Array<{
|
||||
bank_name: string;
|
||||
apy_raw: string;
|
||||
min_deposit_raw: string;
|
||||
term_raw: string;
|
||||
}> = [];
|
||||
|
||||
// Primary strategy: extract from Bankrate offer cards
|
||||
// Both sponsored and additional offer sections use the same card structure
|
||||
const cards = [
|
||||
...document.querySelectorAll('.wrt-RateSections-sponsoredoffers > .rounded-md'),
|
||||
...document.querySelectorAll('.wrt-RateSections-additionaloffers > .rounded-md'),
|
||||
];
|
||||
|
||||
for (const card of cards) {
|
||||
const text = card.textContent || '';
|
||||
|
||||
// Bank name: from the logo img alt attribute (most reliable)
|
||||
const img = card.querySelector('img[alt]');
|
||||
let bankName = img ? (img as HTMLImageElement).alt.trim() : '';
|
||||
|
||||
// Fallback: extract from text before "Add to compare"
|
||||
if (!bankName) {
|
||||
const addIdx = text.indexOf('Add to compare');
|
||||
if (addIdx > 0) {
|
||||
bankName = text.substring(0, addIdx)
|
||||
.replace(/Editor's pick/gi, '')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract from product name pattern (e.g. "NexBank CD")
|
||||
if (!bankName) {
|
||||
const productMatch = text.match(/^(?:Editor's pick)?\s*([A-Z][\w\s®*.'&-]+?(?:CD|Account|Savings|Money Market))/);
|
||||
if (productMatch) bankName = productMatch[1].trim();
|
||||
}
|
||||
|
||||
if (!bankName || bankName.length < 2) continue;
|
||||
|
||||
// APY: find the percentage that appears after "APY as of" context.
|
||||
// Avoid picking up the Bankrate score (e.g. "4.5 Bankrate CD score").
|
||||
// Use \b or (?<!\d) to avoid capturing leading periods from adjacent text.
|
||||
let apyRaw = '';
|
||||
const apySection = text.match(/APY as of[\s\S]*?(\d+\.?\d*)\s*%/);
|
||||
if (apySection) {
|
||||
apyRaw = apySection[1] + '%';
|
||||
} else {
|
||||
// Broader fallback: find "X.XX% APY" or just "X.XX%"
|
||||
const apyMatch = text.match(/(\d+\.?\d*)\s*%\s*(?:APY)?/);
|
||||
if (apyMatch) apyRaw = apyMatch[1] + '%';
|
||||
}
|
||||
if (!apyRaw) continue;
|
||||
|
||||
// Min. deposit: CDs use "Min. deposit $X", savings/MM use "Min. balance for APY$X"
|
||||
let minDepositRaw = '';
|
||||
const minDepMatch = text.match(/Min\.\s*deposit\s*\$\s*([\d,]+)/i);
|
||||
const minBalMatch = text.match(/Min\.\s*balance\s*for\s*APY\s*\$\s*([\d,.]+)/i);
|
||||
const noMin = /No minimum/i.test(text);
|
||||
if (noMin) {
|
||||
minDepositRaw = '$0';
|
||||
} else if (minDepMatch) {
|
||||
minDepositRaw = '$' + minDepMatch[1];
|
||||
} else if (minBalMatch) {
|
||||
minDepositRaw = '$' + minBalMatch[1];
|
||||
}
|
||||
|
||||
// Term: CDs have terms like "1yr", "14mo", "1.5yr"
|
||||
let termRaw = '';
|
||||
const termMatch = text.match(/Term\s*([\d.]+)\s*(yr|mo|year|month)s?/i);
|
||||
if (termMatch) {
|
||||
const num = termMatch[1];
|
||||
const unit = termMatch[2].toLowerCase();
|
||||
if (unit === 'yr' || unit === 'year') {
|
||||
termRaw = `${num} year${num === '1' ? '' : 's'}`;
|
||||
} else {
|
||||
termRaw = `${num} month${num === '1' ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
bank_name: bankName,
|
||||
apy_raw: apyRaw,
|
||||
min_deposit_raw: minDepositRaw,
|
||||
term_raw: termRaw,
|
||||
});
|
||||
|
||||
if (results.length >= maxRates) break;
|
||||
}
|
||||
|
||||
// Fallback strategy: if card-based extraction found nothing,
|
||||
// scan for any elements with bank-like names and APY percentages.
|
||||
// This guards against future Bankrate layout changes.
|
||||
if (results.length === 0) {
|
||||
const fallbackCards = document.querySelectorAll(
|
||||
'[class*="product"], [class*="offer"], [class*="rate-card"], [class*="ComparisonRow"]',
|
||||
);
|
||||
for (const card of fallbackCards) {
|
||||
const text = card.textContent || '';
|
||||
if (text.length < 20 || text.length > 2000) continue;
|
||||
|
||||
const apyMatch = text.match(/(\d+\.?\d*)\s*%\s*(?:APY)?/);
|
||||
if (!apyMatch) continue;
|
||||
|
||||
const nameEl = card.querySelector('img[alt], h2, h3, h4, h5, [class*="name"], [class*="bank"]');
|
||||
const bankName = (nameEl as HTMLImageElement)?.alt
|
||||
|| nameEl?.textContent?.trim()
|
||||
|| '';
|
||||
if (!bankName || bankName.length < 2 || /^\d/.test(bankName) || bankName.includes('%')) continue;
|
||||
|
||||
const depositMatch = text.match(/\$[\d,]+/);
|
||||
const termMatch = text.match(/(\d+)\s*(?:month|year)s?/i);
|
||||
|
||||
results.push({
|
||||
bank_name: bankName,
|
||||
apy_raw: apyMatch[1] + '%',
|
||||
min_deposit_raw: depositMatch?.[0] || '',
|
||||
term_raw: termMatch?.[0] || '',
|
||||
});
|
||||
|
||||
if (results.length >= maxRates) break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}, MAX_RATES);
|
||||
|
||||
console.log(`Raw extraction found ${rawRates.length} rate entries.`);
|
||||
|
||||
// Parse and normalize
|
||||
const isTermProduct = rateType === 'cd';
|
||||
|
||||
const parsed: MarketRate[] = rawRates
|
||||
.map((r) => {
|
||||
let bankName = r.bank_name
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/Editor's pick/gi, '')
|
||||
.trim();
|
||||
|
||||
// Strip trailing product suffixes to normalize bank name
|
||||
// e.g. "Marcus by Goldman Sachs CD" → "Marcus by Goldman Sachs"
|
||||
bankName = bankName
|
||||
.replace(/\s+(CD|Certificate of Deposit|Money Market|Savings|High[- ]Yield Savings)\s*$/i, '')
|
||||
.trim();
|
||||
|
||||
const term = isTermProduct ? (r.term_raw || 'N/A') : 'N/A';
|
||||
|
||||
// Skip entries where bank_name still looks like a term or number (not a real bank)
|
||||
if (
|
||||
/^\d+\s*(month|year)/i.test(bankName) ||
|
||||
/^\$/.test(bankName) ||
|
||||
bankName.length < 2
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
bank_name: bankName,
|
||||
apy: parseApy(r.apy_raw),
|
||||
min_deposit: parseMinDeposit(r.min_deposit_raw),
|
||||
term,
|
||||
term_months: isTermProduct ? parseTermMonths(r.term_raw) : null,
|
||||
rate_type: rateType,
|
||||
};
|
||||
})
|
||||
.filter((r): r is MarketRate => r !== null && r.bank_name.length > 0 && r.apy > 0 && r.apy <= 20);
|
||||
|
||||
// Deduplicate by bank name + term (keep highest APY)
|
||||
const seen = new Map<string, MarketRate>();
|
||||
for (const rate of parsed) {
|
||||
const key = `${rate.bank_name}|${rate.term}`;
|
||||
const existing = seen.get(key);
|
||||
if (!existing || rate.apy > existing.apy) {
|
||||
seen.set(key, rate);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(seen.values())
|
||||
.sort((a, b) => b.apy - a.apy)
|
||||
.slice(0, MAX_RATES);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store scraped rates into shared.cd_rates.
|
||||
* Historical data is preserved — we no longer delete previous rows.
|
||||
* Each fetch batch shares a common fetched_at timestamp per rate_type.
|
||||
*/
|
||||
async function storeRates(rates: MarketRate[], sourceUrl: string): Promise<void> {
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
|
||||
|
||||
const pool = new Pool({ connectionString });
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const rate of rates) {
|
||||
await client.query(
|
||||
`INSERT INTO shared.cd_rates
|
||||
(bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at, source_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
rate.bank_name,
|
||||
rate.apy,
|
||||
rate.min_deposit,
|
||||
rate.term,
|
||||
rate.term_months,
|
||||
rate.rate_type,
|
||||
now,
|
||||
sourceUrl,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log(` Stored ${rates.length} ${rates[0]?.rate_type || ''} rates at ${now}`);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
*/
|
||||
async function main() {
|
||||
console.log('=== Market Rate Fetcher ===');
|
||||
console.log(`Fetching rates from Bankrate.com...`);
|
||||
console.log(`Time: ${new Date().toISOString()}`);
|
||||
console.log(`Rate types: ${RATE_SOURCES.map((s) => s.label).join(', ')}`);
|
||||
|
||||
let browser: Browser | null = null;
|
||||
|
||||
try {
|
||||
// Use system Chromium if PUPPETEER_EXECUTABLE_PATH is set,
|
||||
// or auto-detect common locations on Linux servers.
|
||||
const executablePath =
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome'].find(
|
||||
(p) => { try { require('fs').accessSync(p); return true; } catch { return false; } },
|
||||
) ||
|
||||
undefined;
|
||||
|
||||
console.log('\nLaunching headless browser...');
|
||||
if (executablePath) console.log(`Using browser: ${executablePath}`);
|
||||
browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
],
|
||||
});
|
||||
|
||||
let totalStored = 0;
|
||||
|
||||
for (let i = 0; i < RATE_SOURCES.length; i++) {
|
||||
const source = RATE_SOURCES[i];
|
||||
|
||||
// Pause between fetches to avoid rate limiting (skip for first)
|
||||
if (i > 0) {
|
||||
const pauseSeconds = 8 + Math.floor(Math.random() * 5); // 8-12 seconds
|
||||
console.log(`\nPausing ${pauseSeconds} seconds before next fetch...`);
|
||||
await sleep(pauseSeconds * 1000);
|
||||
}
|
||||
|
||||
try {
|
||||
const rates = await fetchRatesFromPage(browser, source.url, source.type, source.label);
|
||||
|
||||
if (rates.length === 0) {
|
||||
console.warn(`\nWARNING: No ${source.label} rates were extracted.`);
|
||||
console.warn('This may mean Bankrate changed their page structure.');
|
||||
continue; // Don't abort the whole run — try other rate types
|
||||
}
|
||||
|
||||
console.log(`\nExtracted ${rates.length} ${source.label}:`);
|
||||
console.log('\u2500'.repeat(80));
|
||||
for (const r of rates) {
|
||||
const termStr = r.term !== 'N/A' ? r.term.padEnd(15) : ''.padEnd(15);
|
||||
console.log(
|
||||
` ${r.bank_name.padEnd(35)} ${String(r.apy + '%').padEnd(8)} ${termStr} ${r.min_deposit != null ? '$' + r.min_deposit.toLocaleString() : 'N/A'}`,
|
||||
);
|
||||
}
|
||||
console.log('\u2500'.repeat(80));
|
||||
|
||||
console.log(`\nStoring ${source.label} to database...`);
|
||||
await storeRates(rates, source.url);
|
||||
totalStored += rates.length;
|
||||
} catch (err: any) {
|
||||
console.error(`\nERROR fetching ${source.label}: ${err.message}`);
|
||||
// Continue to next rate type
|
||||
}
|
||||
}
|
||||
|
||||
if (totalStored === 0) {
|
||||
console.warn('\nWARNING: No rates were stored for any type.');
|
||||
console.warn('Review Bankrate page structure and update selectors.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nDone. Total rates stored: ${totalStored}`);
|
||||
} catch (err) {
|
||||
console.error('\nFATAL ERROR:', err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user