35 Commits

Author SHA1 Message Date
827eef4f49 Merge pull request 'feat: add shadow AI benchmarking for admin model comparison' (#12) from feature-shadowAI into main
Reviewed-on: #12
2026-04-05 07:54:11 -04:00
JoeBot
4797669591 feat: add shadow AI benchmarking for admin model comparison
Add a new admin-only feature that allows the platform owner to benchmark
the production AI model against up to 2 alternate models (any OpenAI-compatible
API) using real tenant data, without impacting users.

Backend:
- Shared AI caller utility (ai-caller.ts) for OpenAI-compatible endpoints
- Shadow AI module with service, controller, and 3 entities
- 6 admin API endpoints for model config CRUD, run trigger, and history
- Auto-creates shadow_ai_models, shadow_runs, shadow_run_results tables
- Exposes health-scores and investment-planning prompt builders for reuse

Frontend:
- New admin page at /admin/shadow-ai with 3 tabs:
  - Model Configuration (production + 2 alternate slots)
  - Run Comparison (tenant select, feature select, side-by-side results)
  - History (filterable run log with detail drill-down)
- Full side-by-side output display with diff highlighting
- Sidebar navigation link for AI Benchmarking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 07:50:59 -04:00
629d112850 Merge pull request 'Add k6 load testing suite and CLAUDE.md' (#9) from claude/beautiful-gauss into main
Reviewed-on: #9
2026-04-02 17:42:35 -04:00
32506d6a2e Merge branch 'main' into claude/beautiful-gauss 2026-04-02 17:42:24 -04:00
9a60970837 Updated Version 2026-04-02 17:41:49 -04:00
1ade446187 Merge pull request 'ideation-feature' (#11) from ideation-feature into main
Reviewed-on: #11
2026-04-02 17:39:32 -04:00
JoeBot
d430b96b51 feat: add admin ideas management page with private notes
Adds a dedicated super admin page for managing idea submissions across
all tenants. Includes status summary cards, filterable/searchable table,
detail modal with status updates, and private admin notes for internal
tracking (sprint refs, thoughts, follow-ups). Notes are not visible to
tenant users.

- Database: admin_note column on shared.ideas (019 migration)
- Backend: PUT /admin/ideas/:id/note endpoint
- Frontend: AdminIdeasPage with table, filters, detail modal
- Sidebar: "Idea Submissions" nav link in admin sections
- Routing: /admin/ideas route under SuperAdminRoute guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:35:30 -04:00
JoeBot
140cd7acb7 feat: add ideation feature with per-tenant toggle
Adds idea submission capability gated by a per-tenant feature flag.
Super admins can enable/disable ideation for specific tenants via the
admin tenant detail drawer. Users see a lightbulb icon in the header
when enabled, opening a modal to submit ideas (title + description).
Ideas are stored in shared schema for cross-tenant backlog querying.

- Database: shared.ideas table (018-ideas.sql migration)
- Backend: Ideas NestJS module (entity, service, controller)
- Admin API: GET /admin/ideas, PUT /admin/ideas/:id/status,
  PUT /admin/organizations/:id/settings
- Frontend: IdeaModal component, lightbulb ActionIcon in header
- Admin UI: Feature Toggles card with ideation Switch in drawer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:20:37 -04:00
2f6297ae68 fix: reserve fund health AI prompt uses planned dates instead of remaining life years
Remaining life years is documentation-only reference info. The board's
planned project date is the authoritative timeline for urgency assessment.
Updates data gathering, prompt construction, and system instructions to
base all urgency on target_year/target_month instead of remaining_life_years.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 14:28:37 -04:00
121b8138e3 fix: investment scenario detail blank screen and auto-renew refresh
Move useMemo hook above early returns to satisfy React Rules of Hooks,
fixing blank screen when navigating to scenario detail. Also re-fetch
scenario after projection updates so auto-renew renewal records appear
automatically without requiring manual navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:04:44 -04:00
2b331bb3ef feat: investment chart alignment, auto-renew records, fund transfers, capital planning report, and upcoming activities (v2026.3.24)
- Lock InvestmentTimeline and ProjectionChart to shared X axis range
- Auto-create renewal scenario_investments records when auto_renew is true
- Add fund transfer mechanism between asset accounts with journal entries
- Add Capital Planning Report (5-year forecast grouped by category)
- Add Upcoming Investment Activities dashboard card (maturities + planned purchases)
- Bump version to 2026.3.24

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 14:41:17 -04:00
ae856bfb2f Upload files to "load-tests" 2026-03-19 16:12:09 -04:00
31f8274b8d Upload files to "load-tests"
load test files
2026-03-19 16:11:32 -04:00
06bc0181f8 feat: add k6 load testing suite, NRQL query library, and CLAUDE.md
Add comprehensive load testing infrastructure:
- k6 auth-dashboard flow (login → profile → dashboard KPIs → widgets → refresh → logout)
- k6 CRUD flow (units, vendors, journal entries, payments, reports)
- Environment configs with staging/production/local thresholds
- Parameterized user pool CSV matching app roles
- New Relic NRQL query library (25+ queries for perf analysis)
- Empty baseline.json structure for all tested endpoints
- CLAUDE.md documenting full stack, auth, route map, and conventions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:49:22 -04:00
66e2f87a96 feat: UX enhancements, member limits, forecast fix, and menu cleanup (v2026.3.19)
- Onboarding wizard: add Reserve Account step between Operating and Assessments,
  redirect to Budget Planning on completion
- Dashboard: health score pending state shows clickable links to set up missing items
- Projects/Vendors: rich empty-state wizard screens with real-world examples and CTAs
- Investment Planning: auto-refresh AI recommendations when empty or stale (>30 days)
- Hide Invoices and Payments menus (see PARKING-LOT.md for re-enablement)
- Send welcome email via Resend when new members are added to a tenant
- Enforce 5-member limit for Starter/Standard/Professional plans (Enterprise unlimited)
- Cash flow forecast: only mark months as "Actual" when journal entries exist,
  fixing the issue where months without data showed as actuals
- Bump version to 2026.3.19

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:47:04 -04:00
db8b520009 fix: billing portal error, onboarding wizard improvements, budget empty state
- Fix "Manage Billing" button error for trial orgs without Stripe customer;
  add fallback to retrieve customer from subscription, show helpful message
  for trial users, and surface real error messages in the UI
- Add "Balance As-Of Date" field to onboarding wizard so opening balance
  journal entries use the correct statement date instead of today
- Add "Total Unit Count" field to onboarding wizard assessment group step
  so cash flow projections work immediately
- Remove broken budget upload step from onboarding wizard (was using legacy
  budgets endpoint); replace with guidance to use Budget Planning page
- Replace bare "No budget plan lines" text with rich onboarding-style card
  featuring download template and upload CSV action buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 09:43:49 -04:00
e2d72223c8 feat: add test data cleanup utility script
Interactive CLI for managing test organizations, users, and tenant schemas.
Supports list, delete-org, delete-user, purge-all, and reseed commands
with dry-run mode and safety guards for platform owner protection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 08:59:27 -04:00
a996208cb8 feat: add annual billing, free trial, upgrade/downgrade, and ACH invoice support
- Add monthly/annual billing toggle with 25% annual discount on pricing page
- Implement 14-day no-card free trial (server-side Stripe subscription creation)
- Enable upgrade/downgrade via Stripe Customer Portal
- Add admin-initiated ACH/invoice billing for enterprise customers
- Add billing card to Settings page with plan info and Manage Billing button
- Handle past_due status with read-only grace period access
- Add trial ending and trial expired email templates
- Add DB migration for billing_interval and collection_method columns
- Update ONBOARDING-AND-AUTH.md documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 08:04:51 -04:00
5845334454 fix: remove cash flow summary cards and restore area chart shading
Remove the 4 summary cards from the Cash Flow page as they don't
properly represent the story over time. Increase gradient opacity
on stacked area charts (cash flow and investment scenarios) from
0.3-0.4/0-0.05 to 0.6/0.15 for better visual shading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:41:13 -04:00
170461c359 Merge branch 'claude/reverent-moore' - Resend email integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:33:17 -04:00
6b12fcd7d7 Merge branch 'claude/reverent-moore' 2026-03-17 18:04:14 -04:00
c2e52bee64 Merge pull request 'feat: enterprise pricing shows "Request Quote" linking to interest form' (#8) from claude/reverent-moore into main
Reviewed-on: #8
2026-03-17 07:53:16 -04:00
8abab40778 Merge pull request 'Security hardening: v2 assessment remediation' (#7) from claude/tender-murdock into main 2026-03-17 07:46:56 -04:00
19fb2c037c feat(security): address findings from v2 security assessment
- L2: Add server_tokens off to nginx configs to hide version
- M1: Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy headers to all nginx routes
- L3: Add global NoCacheInterceptor (Cache-Control: no-store) on all
  API responses to prevent caching of sensitive financial data
- C1: Disable open registration by default (ALLOW_OPEN_REGISTRATION env)
- H3: Add logout endpoint with correct HTTP 200 status code
- M2: Implement full password reset flow (forgot-password, reset-password,
  change-password) with hashed tokens, 15-min expiry, single-use
- Reduce JWT access token expiry from 24h to 1h
- Add EmailService stub (logs to shared.email_log)
- Add DB migration 016 for password_reset_tokens table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 07:46:11 -04:00
e62f3e7b07 Merge pull request 'claude/reverent-moore' (#6) from claude/reverent-moore into main
Reviewed-on: #6
2026-03-17 06:55:45 -04:00
e3022f20c5 Merge pull request 'claude/SSOMFASTRIPE' (#5) from claude/reverent-moore into main
Reviewed-on: #5
2026-03-16 21:22:34 -04:00
9cd20a1867 Merge branch 'ai-improvements' 2026-03-16 16:34:11 -04:00
420227d70c Merge branch 'feature/invoice-billing-frequency'
# Conflicts:
#	frontend/src/pages/invoices/InvoicesPage.tsx
2026-03-16 16:34:04 -04:00
e893319cfe Merge branch 'fix/viewer-readonly-audit'
# Conflicts:
#	frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx
2026-03-16 16:33:24 -04:00
93eeacfe8f Merge branch 'claude/reverent-moore' into feature/board-planning 2026-03-16 16:28:52 -04:00
267d92933e chore: reorganize sidebar navigation and bump version to 2026.03.16
Remove the Planning section. Move Projects and Capital Planning (as
sub-item) into Board Planning. Move Investment Planning with Investment
Scenarios as sub-item into Board Planning. Move Vendors into new Board
Reference section. Board Planning order: Budget Planning, Projects >
Capital Planning, Assessment Scenarios, Investment Planning > Investment
Scenarios, Compare Scenarios. Sidebar now supports parent items with
their own route plus nested children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:21:58 -04:00
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:59:20 -04:00
2b83defbc3 fix: resolve 5 invoice/payment issues from user feedback
- Replace misleading 'sent' status with 'pending' (no email capability)
- Show assessment group name instead of raw 'regular_assessment' type
- Add owner last name to invoice table
- Fix payment creation Internal Server Error (PostgreSQL $2 type cast)
- Add edit/delete capability for payment records with invoice recalc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:53:54 -05:00
a59dac7fe1 Merge remote-tracking branch 'origin/feature/invoice-billing-frequency' into ai-improvements 2026-03-06 19:18:11 -05:00
1e31595d7f feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly,
annual) with configurable due months and due day. Invoice generation
respects each group's schedule - only generating invoices when the
selected month is a billing month for that group. Adds a generation
preview showing which groups will be billed, period tracking on
invoices, and billing period context in the payments UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:08:56 -05:00
91 changed files with 9157 additions and 926 deletions

View File

@@ -13,6 +13,30 @@ AI_MODEL=qwen/qwen3.5-397b-a17b
# Set to 'true' to enable detailed AI prompt/response logging
AI_DEBUG=false
# Stripe Billing
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Stripe Price IDs (Monthly)
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=price_professional_monthly
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
# Stripe Price IDs (Annual — 25% discount)
STRIPE_STARTER_ANNUAL_PRICE_ID=price_starter_annual
STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=price_professional_annual
STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=price_enterprise_annual
# Trial configuration
REQUIRE_PAYMENT_METHOD_FOR_TRIAL=false
# Email (Resend)
RESEND_API_KEY=re_your_resend_api_key
# Application
APP_URL=http://localhost
INVITE_TOKEN_SECRET=dev-invite-secret
# 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

229
CLAUDE.md Normal file
View File

@@ -0,0 +1,229 @@
# CLAUDE.md HOA Financial Platform (HOALedgerIQ)
## Project Overview
Multi-tenant SaaS platform for HOA (Homeowners Association) financial management. Handles chart of accounts, journal entries, budgets, invoices, payments, reserve planning, and board scenario planning.
---
## Stack & Framework
| Layer | Technology |
| --------- | --------------------------------------------------- |
| Backend | **NestJS 10** (TypeScript), runs on port 3000 |
| Frontend | **React 18** + Vite 5 + Mantine UI + Zustand |
| Database | **PostgreSQL** via **TypeORM 0.3** |
| Cache | **Redis** (BullMQ for queues) |
| Auth | **Passport.js** JWT access + httpOnly refresh |
| Payments | **Stripe** (checkout, subscriptions, webhooks) |
| Email | **Resend** |
| AI | NVIDIA API (Qwen model) for investment advisor |
| Monitoring| **New Relic** APM (app name: `HOALedgerIQ_App`) |
| Infra | Docker Compose (dev + prod), Nginx reverse proxy |
---
## Auth Pattern
- **Access token**: JWT, 1-hour TTL, payload `{ sub, email, orgId, role, isSuperadmin }`
- **Refresh token**: 64-byte random, SHA256-hashed in DB, 30-day TTL, sent as httpOnly cookie `ledgeriq_rt`
- **MFA**: TOTP via `otplib`, challenge token (5-min TTL), recovery codes
- **Passkeys**: WebAuthn via `@simplewebauthn/server`
- **SSO**: Google OAuth 2.0, Azure AD
- **Password hashing**: bcryptjs, cost 12
- **Rate limiting**: 100 req/min global (Throttler), custom per endpoint
### Guards & Middleware
- `TenantMiddleware` extracts `orgId` from JWT, sets tenant schema (60s cache)
- `JwtAuthGuard` Passport JWT guard on all protected routes
- `WriteAccessGuard` blocks write ops for `viewer` role and `past_due` orgs
- `@AllowViewer()` decorator exempts read endpoints from WriteAccessGuard
### Roles
`president`, `treasurer`, `secretary`, `member_at_large`, `manager`, `homeowner`, `admin`, `viewer`
---
## Multi-Tenant Architecture
- **Shared schema** (`shared`): users, organizations, user_organizations, refresh_tokens, invite_tokens, login_history, cd_rates
- **Tenant schemas** (dynamic, per org): accounts, journal_entries, budgets, invoices, payments, units, vendors, etc.
- Schema name stored in `shared.organizations.schema_name`
---
## Route Map (180+ endpoints)
### Auth (`/api/auth`)
| Method | Path | Purpose |
| ------ | ----------------------- | -------------------------------- |
| POST | /login | Email/password login |
| POST | /refresh | Refresh access token (cookie) |
| POST | /logout | Revoke refresh token |
| POST | /logout-everywhere | Revoke all sessions |
| GET | /profile | Current user profile |
| POST | /register | Register (disabled by default) |
| POST | /activate | Activate invited user |
| POST | /forgot-password | Request password reset |
| POST | /reset-password | Reset with token |
| PATCH | /change-password | Change password (authed) |
| POST | /switch-org | Switch active organization |
### Auth MFA (`/api/auth/mfa`)
| POST | /setup | POST /enable | POST /verify | POST /disable | GET /status |
### Auth Passkeys (`/api/auth/passkeys`)
| POST /register-options | POST /register | POST /login-options | POST /login | GET / | DELETE /:id |
### Admin (`/api/admin`) superadmin only
| GET /metrics | GET /users | GET /organizations | PUT /organizations/:id/subscription | POST /impersonate/:userId | POST /tenants |
### Organizations (`/api/organizations`)
| POST / | GET / | PATCH /settings | GET /members | POST /members | PUT /members/:id/role | DELETE /members/:id |
### Accounts (`/api/accounts`)
| GET / | GET /trial-balance | POST / | PUT /:id | PUT /:id/set-primary | POST /bulk-opening-balances | POST /:id/opening-balance | POST /:id/adjust-balance |
### Journal Entries (`/api/journal-entries`)
| GET / | GET /:id | POST / | POST /:id/post | POST /:id/void |
### Budgets (`/api/budgets`)
| GET /:year | PUT /:year | GET /:year/vs-actual | POST /:year/import | GET /:year/template |
### Invoices (`/api/invoices`)
| GET / | GET /:id | POST /generate-preview | POST /generate-bulk | POST /apply-late-fees |
### Payments (`/api/payments`)
| GET / | GET /:id | POST / | PUT /:id | DELETE /:id |
### Units (`/api/units`)
| GET / | GET /:id | POST / | PUT /:id | DELETE /:id | GET /export | POST /import |
### Vendors (`/api/vendors`)
| GET / | GET /:id | POST / | PUT /:id | GET /export | POST /import | GET /1099-data |
### Reports (`/api/reports`)
| GET /dashboard | GET /balance-sheet | GET /income-statement | GET /cash-flow | GET /cash-flow-sankey | GET /aging | GET /year-end | GET /cash-flow-forecast | GET /quarterly |
### Board Planning (`/api/board-planning`)
Scenarios CRUD, scenario investments, scenario assessments, projections, budget plans 28 endpoints total.
### Other Modules
- `/api/fiscal-periods` list, close, lock
- `/api/reserve-components` CRUD
- `/api/capital-projects` CRUD
- `/api/projects` CRUD + planning + import/export
- `/api/assessment-groups` CRUD + summary + default
- `/api/monthly-actuals` GET/POST /:year/:month
- `/api/health-scores` latest + calculate
- `/api/investment-planning` snapshot, market-rates, recommendations
- `/api/investment-accounts` CRUD
- `/api/attachments` upload, list, download, delete (10MB limit)
- `/api/onboarding` progress get/patch
- `/api/billing` trial, checkout, webhook, subscription, portal
---
## Database
- **Connection pool**: min 5, max 30, 30s idle, 5s connect timeout
- **Migrations**: SQL files in `db/migrations/` (manual execution, no ORM runner)
- **Init script**: `db/init/00-init.sql` (shared schema DDL)
---
## Key File Paths
| Purpose | Path |
| ---------------------- | ------------------------------------------------- |
| NestJS bootstrap | `backend/src/main.ts` |
| Root module | `backend/src/app.module.ts` |
| Auth controller | `backend/src/modules/auth/auth.controller.ts` |
| Auth service | `backend/src/modules/auth/auth.service.ts` |
| Refresh token svc | `backend/src/modules/auth/refresh-token.service.ts` |
| JWT strategy | `backend/src/modules/auth/strategies/jwt.strategy.ts` |
| Tenant middleware | `backend/src/database/tenant.middleware.ts` |
| Write-access guard | `backend/src/common/guards/write-access.guard.ts` |
| DB schema init | `db/init/00-init.sql` |
| Env example | `.env.example` |
| Docker compose (dev) | `docker-compose.yml` |
| Frontend entry | `frontend/src/main.tsx` |
| Frontend pages | `frontend/src/pages/` |
---
## Environment Variables (critical)
```
DATABASE_URL PostgreSQL connection string
REDIS_URL Redis connection
JWT_SECRET JWT signing key
INVITE_TOKEN_SECRET Invite token signing
STRIPE_SECRET_KEY Stripe API key
STRIPE_WEBHOOK_SECRET Stripe webhook verification
RESEND_API_KEY Email service
NEW_RELIC_APP_NAME "HOALedgerIQ_App"
NEW_RELIC_LICENSE_KEY New Relic license
APP_URL Base URL for email links
```
---
## New Relic
- **App name**: `HOALedgerIQ_App` (env: `NEW_RELIC_APP_NAME`)
- Enabled via `NEW_RELIC_ENABLED=true`
- NRQL query library: `load-tests/analysis/nrql-queries.sql`
---
## Load Testing
### Run k6 scenarios
```bash
# Auth + Dashboard flow (staging)
k6 run --env TARGET_ENV=staging load-tests/scenarios/auth-dashboard-flow.js
# CRUD flow (staging)
k6 run --env TARGET_ENV=staging load-tests/scenarios/crud-flow.js
# Local dev
k6 run --env TARGET_ENV=local load-tests/scenarios/auth-dashboard-flow.js
```
### Conventions
- Scenarios live in `load-tests/scenarios/`
- Config in `load-tests/config/environments.json` (staging/production/local thresholds)
- Test users parameterized from `load-tests/config/user-pool.csv`
- Baseline results stored in `load-tests/analysis/baseline.json`
- NRQL queries for New Relic in `load-tests/analysis/nrql-queries.sql`
- All k6 scripts use `SharedArray` for user pool, `http.batch()` for parallel requests
- Custom metrics: `*_duration` trends + `*_error_rate` rates per journey
- Thresholds: p95 latency + error rate per environment
### User Pool CSV Format
```
email,password,orgId,role
```
Roles match the app: `treasurer`, `admin`, `president`, `manager`, `member_at_large`, `viewer`, `homeowner`
---
## Fix Conventions
- Backend tests: `npm run test` (Jest, `*.spec.ts` co-located with source)
- E2E tests: `npm run test:e2e`
- Backend build: `npm run build` (NestJS CLI)
- Frontend dev: `npm run dev` (Vite, port 5173)
- Frontend build: `npm run build`
- Always run `npm run build` in `backend/` after changes to verify compilation
- TypeORM entities use decorators (`@Entity`, `@Column`, etc.)
- Multi-tenant: any new module touching tenant data must use `TenantService` to get the correct schema connection
- New endpoints need `@UseGuards(JwtAuthGuard)` and should respect `WriteAccessGuard`
- Use `@AllowViewer()` on read-only endpoints

View File

@@ -1,8 +1,8 @@
# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide
> **Version:** 2026.03.17
> **Last updated:** March 17, 2026
> **Migration:** `db/migrations/015-saas-onboarding-auth.sql`
> **Version:** 2026.03.18
> **Last updated:** March 18, 2026
> **Migrations:** `db/migrations/015-saas-onboarding-auth.sql`, `db/migrations/017-billing-enhancements.sql`
---
@@ -10,17 +10,22 @@
1. [High-Level Flow](#1-high-level-flow)
2. [Stripe Billing & Checkout](#2-stripe-billing--checkout)
3. [Provisioning Pipeline](#3-provisioning-pipeline)
4. [Account Activation (Magic Link)](#4-account-activation-magic-link)
5. [Guided Onboarding Checklist](#5-guided-onboarding-checklist)
6. [Authentication & Sessions](#6-authentication--sessions)
7. [Multi-Factor Authentication (TOTP)](#7-multi-factor-authentication-totp)
8. [Single Sign-On (SSO)](#8-single-sign-on-sso)
9. [Passkeys (WebAuthn)](#9-passkeys-webauthn)
10. [Environment Variables Reference](#10-environment-variables-reference)
11. [Manual Intervention & Ops Tasks](#11-manual-intervention--ops-tasks)
12. [What's Stubbed vs. Production-Ready](#12-whats-stubbed-vs-production-ready)
13. [API Endpoint Reference](#13-api-endpoint-reference)
3. [14-Day Free Trial](#3-14-day-free-trial)
4. [Monthly / Annual Billing](#4-monthly--annual-billing)
5. [Provisioning Pipeline](#5-provisioning-pipeline)
6. [Account Activation (Magic Link)](#6-account-activation-magic-link)
7. [Guided Onboarding Checklist](#7-guided-onboarding-checklist)
8. [Subscription Management & Upgrade/Downgrade](#8-subscription-management--upgradedowngrade)
9. [ACH / Invoice Billing](#9-ach--invoice-billing)
10. [Access Control & Grace Periods](#10-access-control--grace-periods)
11. [Authentication & Sessions](#11-authentication--sessions)
12. [Multi-Factor Authentication (TOTP)](#12-multi-factor-authentication-totp)
13. [Single Sign-On (SSO)](#13-single-sign-on-sso)
14. [Passkeys (WebAuthn)](#14-passkeys-webauthn)
15. [Environment Variables Reference](#15-environment-variables-reference)
16. [Manual Intervention & Ops Tasks](#16-manual-intervention--ops-tasks)
17. [What's Stubbed vs. Production-Ready](#17-whats-stubbed-vs-production-ready)
18. [API Endpoint Reference](#18-api-endpoint-reference)
---
@@ -30,28 +35,26 @@
Visitor hits /pricing
|
v
Selects a plan (Starter $29 / Professional $79 / Enterprise $199)
Selects plan (Starter / Professional / Enterprise)
Chooses billing frequency (Monthly / Annual — 25% discount)
Enters email + business name
|
v
POST /api/billing/create-checkout-session
POST /api/billing/start-trial (no card required)
|
v
Redirect to Stripe Checkout (hosted by Stripe)
Backend creates Stripe customer + subscription with trial_period_days=14
Backend provisions: org -> schema -> user -> invite token -> email
|
v
Stripe fires `checkout.session.completed` webhook
Frontend navigates to /onboarding/pending?session_id=xxx
(polls GET /api/billing/status every 3s)
|
v
Backend provisions: org -> schema -> user -> invite token -> "email"
|
v
Frontend polls GET /api/billing/status?session_id=xxx
| (OnboardingPendingPage polls every 3s)
v
Status returns "active" -> user is redirected to /login
|
v
User clicks activation link from "email" (logged to console + DB)
User clicks activation link from email
|
v
GET /activate?token=xxx -> validates token
@@ -61,184 +64,295 @@ POST /activate -> sets password + name, issues session
Redirect to /onboarding (4-step guided wizard)
|
v
Dashboard
Dashboard (14-day trial active)
|
v
Day 11: Stripe fires customer.subscription.trial_will_end webhook
Backend sends trial-ending reminder email
|
v
User adds payment method via Stripe Portal (Settings > Manage Billing)
|
v
Trial ends -> Stripe charges card -> subscription becomes 'active'
OR: No card -> subscription cancelled -> org archived
```
---
## 2. Stripe Billing & Checkout
### Plans
### Plans & Pricing
| Plan ID | Name | Price | Unit Limit |
|---------------|--------------|---------|------------|
| `starter` | Starter | $29/mo | 50 units |
| `professional` | Professional | $79/mo | 200 units |
| `enterprise` | Enterprise | $199/mo | Unlimited |
| Plan | Monthly | Annual (25% off) | Unit Limit |
|------|---------|-------------------|------------|
| Starter | $29/mo | $261/yr ($21.75/mo) | 50 units |
| Professional | $79/mo | $711/yr ($59.25/mo) | 200 units |
| Enterprise | Custom | Custom | Unlimited |
### Checkout Flow
### Stripe Products & Prices
1. **Frontend** (`PricingPage.tsx`): User enters email + business name, selects a plan.
2. **API call**: `POST /api/billing/create-checkout-session`
- Body: `{ planId, email?, businessName? }`
- Returns: `{ url }` (Stripe hosted checkout URL)
- No auth required.
3. **Redirect**: Frontend does `window.location.href = url` to send user to Stripe.
4. **On success**: Stripe redirects to `/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`.
5. **On cancel**: Stripe redirects back to `/pricing`.
Each plan has **two Stripe Prices** (monthly and annual):
| Env Variable | Description |
|-------------|-------------|
| `STRIPE_STARTER_MONTHLY_PRICE_ID` | Starter monthly recurring price |
| `STRIPE_STARTER_ANNUAL_PRICE_ID` | Starter annual recurring price |
| `STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID` | Professional monthly recurring price |
| `STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID` | Professional annual recurring price |
| `STRIPE_ENTERPRISE_MONTHLY_PRICE_ID` | Enterprise monthly recurring price |
| `STRIPE_ENTERPRISE_ANNUAL_PRICE_ID` | Enterprise annual recurring price |
Backward compatibility: `STRIPE_STARTER_PRICE_ID` (old single var) maps to monthly if the new `_MONTHLY_` var is not set.
### Two Billing Paths
| Path | Audience | Payment | Trial |
|------|----------|---------|-------|
| **Path A: Self-serve (Card)** | Starter & Professional | Automatic card charge | 14-day no-card trial |
| **Path B: Invoice / ACH** | Enterprise (admin-set) | Invoice with Net-30 terms | Admin configures |
### Webhook Events Handled
The webhook endpoint is `POST /api/webhooks/stripe`.
| Event | Action |
|-------|--------|
| `checkout.session.completed` | Triggers full provisioning pipeline |
| `invoice.payment_succeeded` | Sets org status to `active` (handles reactivation after failed payment) |
| `invoice.payment_failed` | Sends payment-failed "email" (stubbed) |
| `checkout.session.completed` | Triggers full provisioning pipeline (card-required flow) |
| `invoice.payment_succeeded` | Sets org status to `active` (reactivation after trial/past_due) |
| `invoice.payment_failed` | Sets org to `past_due`, sends payment-failed email |
| `customer.subscription.deleted` | Sets org status to `archived` |
| `customer.subscription.trial_will_end` | Sends trial-ending reminder email (3 days before) |
| `customer.subscription.updated` | Syncs plan, interval, status, and collection_method to DB |
All webhook events are deduplicated via the `shared.stripe_events` table (idempotency by Stripe event ID).
### Stripe Customer Portal
---
`POST /api/billing/portal` (auth required) -- creates a Stripe Customer Portal session for managing subscription/payment methods. **Note: currently throws "not implemented" -- needs org-context customer ID lookup.**
## 3. 14-Day Free Trial
### How It Works
1. User visits `/pricing`, selects a plan and billing frequency
2. User enters email + business name (required)
3. Clicks "Start Free Trial"
4. Backend creates Stripe customer (no payment method)
5. Backend creates subscription with `trial_period_days: 14`
6. Backend provisions org with `status = 'trial'` immediately
7. User receives activation email, sets password, starts using the app
### Trial Configuration
| Setting | Description |
|---------|-------------|
| `REQUIRE_PAYMENT_METHOD_FOR_TRIAL` | `false` (default): no-card trial. `true`: uses Stripe Checkout (card required upfront). |
### Trial Lifecycle
| Day | Event |
|-----|-------|
| 0 | Trial starts, full access granted |
| 11 | `customer.subscription.trial_will_end` webhook fires |
| 11 | Trial-ending email sent ("Your trial ends in 3 days") |
| 14 | Trial ends |
| 14 | If card on file: Stripe charges, subscription becomes `active` |
| 14 | If no card: subscription cancelled, org set to `archived` |
### Trial Behavior by Plan Frequency
- **Monthly trial**: Trial ends, charge monthly price
- **Annual trial**: Trial ends, charge full annual amount
### Trial End Behavior
Configured in Stripe subscription: `trial_settings.end_behavior.missing_payment_method: 'cancel'`
When trial ends without a payment method, the subscription is cancelled and the org is archived. Users can resubscribe at any time.
---
## 3. Provisioning Pipeline
## 4. Monthly / Annual Billing
When `checkout.session.completed` fires, the backend runs **inline provisioning** (no background queue):
### Pricing Page Toggle
The pricing page (`PricingPage.tsx`) features a segmented control toggle:
- **Monthly**: Shows monthly prices ($29/mo, $79/mo)
- **Annual (Save 25%)**: Shows effective monthly rate + annual total ($21.75/mo billed annually at $261/yr)
The selected billing frequency is passed to the backend when starting a trial or creating a checkout session.
### Annual Discount
Annual pricing = Monthly price x 12 x 0.75 (25% discount):
- Starter: $29 x 12 x 0.75 = **$261/yr**
- Professional: $79 x 12 x 0.75 = **$711/yr**
---
## 5. Provisioning Pipeline
When a trial starts or `checkout.session.completed` fires, the backend runs **inline provisioning**:
1. **Create organization** in `shared.organizations` with:
- `name` = business name from checkout metadata
- `name` = business name from signup
- `schema_name` = `tenant_{random_12_chars}`
- `status` = `active`
- `status` = `trial` (for trial) or `active` (for card checkout)
- `plan_level` = selected plan
- `billing_interval` = `month` or `year`
- `stripe_customer_id` + `stripe_subscription_id`
- `trial_ends_at` (if trial)
- Uses `ON CONFLICT (stripe_customer_id)` for idempotency
2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`:
- Runs the full tenant DDL (accounts, journal entries, etc.)
- Skips if schema already exists
3. **Create or find user** in `shared.users` by email:
- New users are created with `is_email_verified = false` and no password
- Existing users are reused (linked to new org)
4. **Create membership** in `shared.user_organizations`:
- Role: `president`
- Idempotent via `ON CONFLICT DO NOTHING`
5. **Generate invite token** (JWT signed with `INVITE_TOKEN_SECRET`, 72-hour expiry):
- SHA-256 hash stored in `shared.invite_tokens`
- Raw token used in activation URL
6. **Send activation "email"** (stubbed -- see section 12):
- Logged to console and `shared.email_log` table
- Contains activation URL: `{APP_URL}/activate?token={jwt}`
7. **Initialize onboarding** progress row in `shared.onboarding_progress`
2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`
3. **Create or find user** in `shared.users` by email
4. **Create membership** in `shared.user_organizations` (role: `president`)
5. **Generate invite token** (JWT, 72-hour expiry)
6. **Send activation email** with link to set password
7. **Initialize onboarding** progress row
### Provisioning Status Polling
`GET /api/billing/status?session_id=xxx` (no auth required)
Returns: `{ status }` where status is one of:
Accepts both Stripe checkout session IDs and subscription IDs. Returns: `{ status }` where status is:
- `not_configured` -- Stripe not set up
- `pending` -- no customer ID yet
- `provisioning` -- org exists but not active yet
- `active` -- ready to go
The `OnboardingPendingPage` polls this every 3 seconds and redirects to `/login` once active.
- `provisioning` -- org exists but not ready
- `active` -- ready (includes `trial` status)
---
## 4. Account Activation (Magic Link)
## 6. Account Activation (Magic Link)
### Validate Token
`GET /api/auth/activate?token=xxx` (no auth required)
- Verifies JWT signature (using `INVITE_TOKEN_SECRET`)
- Checks `shared.invite_tokens` for existence, expiration, and prior use
- Returns: `{ valid, email, orgName, orgId, userId }`
`GET /api/auth/activate?token=xxx` -- returns `{ valid, email, orgName, orgId, userId }`
### Activate Account
`POST /api/auth/activate` (no auth required)
- Body: `{ token, password, fullName }`
- Password must be >= 8 characters
- Sets `password_hash`, `first_name`, `last_name`, `is_email_verified = true`
- Marks invite token as used (`used_at = NOW()`)
- Issues full session (access token + refresh token cookie)
- Frontend redirects to `/onboarding`
### Frontend (ActivatePage.tsx)
- Validates token on mount
- Shows password setup form with strength indicator (color-coded bar)
- On success: stores auth in Zustand and navigates to `/onboarding`
`POST /api/auth/activate` -- body `{ token, password, fullName }` -- sets password, issues session
---
## 5. Guided Onboarding Checklist
## 7. Guided Onboarding Checklist
### Required Steps
| Step Key | UI Label | Description |
|-----------------|----------------|-------------|
| `profile` | Profile | Set up user profile |
| `workspace` | Workspace | Configure organization settings |
| `invite_member` | Invite Member | Invite at least one team member |
| `first_workflow` | First Account | Create the first chart-of-accounts entry |
### API
- `GET /api/onboarding/progress` (auth required): Returns `{ completedSteps[], completedAt, requiredSteps[] }`
- `PATCH /api/onboarding/progress` (auth required): Body `{ step }` -- marks a step complete
Steps are stored as a PostgreSQL text array. When all 4 required steps are complete, `completed_at` is set. Users can skip onboarding via a "Finish Later" button (navigates to dashboard).
### Frontend (OnboardingPage.tsx)
- Mantine Stepper with 4 steps
- Each step calls `PATCH /onboarding/progress` on completion
- Celebration screen shown when all steps are done
| Step Key | UI Label | Description |
|----------|----------|-------------|
| `profile` | Profile | Set up user profile |
| `workspace` | Workspace | Configure organization settings |
| `invite_member` | Invite Member | Invite at least one team member |
| `first_workflow` | First Account | Create the first chart-of-accounts entry |
---
## 6. Authentication & Sessions
## 8. Subscription Management & Upgrade/Downgrade
### Stripe Customer Portal
Users manage their subscription through the **Stripe Customer Portal**, accessed via:
- Settings page > Billing card > "Manage Billing" button
- Calls `POST /api/billing/portal` which creates a portal session and returns the URL
### What Users Can Do in the Portal
- **Switch plans**: Change between Starter and Professional
- **Switch billing frequency**: Monthly to Annual (and vice versa)
- **Update payment method**: Add/change credit card
- **Cancel subscription**: Cancels at end of current period
- **View invoices**: See billing history
### Upgrade/Downgrade Behavior
| Change | Behavior |
|--------|----------|
| Monthly to Annual | Immediate. Prorate remaining monthly time, start annual cycle now. |
| Annual to Monthly | Scheduled at end of current annual period. |
| Starter to Professional | Immediate. Prorate price difference. |
| Professional to Starter | Scheduled at end of current period. |
Stripe handles proration automatically when configured with `proration_behavior: 'create_prorations'`.
### Subscription Info Endpoint
`GET /api/billing/subscription` (auth required) returns:
```json
{
"plan": "professional",
"planName": "Professional",
"billingInterval": "month",
"status": "active",
"collectionMethod": "charge_automatically",
"trialEndsAt": null,
"currentPeriodEnd": "2026-04-18T00:00:00.000Z",
"cancelAtPeriodEnd": false
}
```
---
## 9. ACH / Invoice Billing
### Overview
For enterprise customers who need to pay via ACH bank transfer or wire, an admin can switch the subscription's collection method from automatic card charge to invoice billing.
### How It Works
1. **Admin** calls `PUT /api/admin/organizations/:id/billing` with:
```json
{ "collectionMethod": "send_invoice", "daysUntilDue": 30 }
```
2. Stripe subscription is updated: `collection_method = 'send_invoice'`, `days_until_due = 30`
3. At each billing cycle, Stripe generates an invoice and emails it to the customer
4. Customer pays via ACH / wire / bank transfer
5. When payment is received, Stripe marks invoice paid and org remains active
### Access Rules for Invoice Customers
| Stage | Access |
|-------|--------|
| Trial | Full |
| Invoice issued | Full |
| Due date passed | Read-only (past_due) |
| 15+ days overdue | Admin may archive |
### Switching Back
To switch back to automatic card billing:
```json
{ "collectionMethod": "charge_automatically" }
```
---
## 10. Access Control & Grace Periods
### Organization Status Access Rules
| Status | Access | Description |
|--------|--------|-------------|
| `trial` | **Full** | 14-day trial, all features available |
| `active` | **Full** | Paid subscription, all features available |
| `past_due` | **Read-only** | Payment failed or invoice overdue. Users can view data but cannot create/edit/delete. |
| `suspended` | **Blocked** | Admin suspended. 403 on all org-scoped endpoints. |
| `archived` | **Blocked** | Subscription cancelled. 403 on all org-scoped endpoints. Data preserved. |
### Implementation
- **Tenant Middleware** (`tenant.middleware.ts`): Blocks `suspended` and `archived` with 403. Sets `req.orgPastDue = true` for `past_due`.
- **WriteAccessGuard** (`write-access.guard.ts`): Blocks POST/PUT/PATCH/DELETE for `past_due` orgs with message: "Your subscription is past due. Please update your payment method."
---
## 11. Authentication & Sessions
### Token Architecture
| Token | Type | Lifetime | Storage |
|-------|------|----------|---------|
| Access token | JWT | 1 hour | Frontend Zustand store (memory/localStorage) |
| Refresh token | Opaque (base64url, 64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) |
| MFA challenge | JWT | 5 minutes | Frontend state (in-memory only) |
| Access token | JWT | 1 hour | Frontend Zustand store |
| Refresh token | Opaque (64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) |
| MFA challenge | JWT | 5 minutes | Frontend state |
| Invite/activation | JWT | 72 hours | URL query parameter |
### Refresh Token Flow
1. Access token expires (401 from any API call)
2. Axios interceptor catches 401, calls `POST /api/auth/refresh`
3. Refresh token is sent automatically via httpOnly cookie
4. Server validates token hash in `shared.refresh_tokens` table
5. New access token issued (refresh token is NOT rotated)
6. Original failed request is replayed with new token
7. Concurrent requests are queued during refresh (no thundering herd)
### Cookie Configuration
- Name: `ledgeriq_rt`
- Path: `/api/auth`
- httpOnly: `true`
- Secure: `true` in production, `false` in dev
- SameSite: `strict`
- Max-Age: 30 days
### Session Endpoints
| Method | Path | Auth | Description |
@@ -246,107 +360,38 @@ Steps are stored as a PostgreSQL text array. When all 4 required steps are compl
| `POST` | `/api/auth/login` | No | Email + password login |
| `POST` | `/api/auth/register` | No | Create account |
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
| `POST` | `/api/auth/logout` | Cookie | Revoke current refresh token |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all user sessions |
| `POST` | `/api/auth/switch-org` | JWT | Switch org context (new tokens) |
| `POST` | `/api/auth/logout` | Cookie | Revoke current session |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
---
## 7. Multi-Factor Authentication (TOTP)
### Setup Flow
1. User goes to Settings > Security > Two-Factor Auth tab
2. `POST /api/auth/mfa/setup` -- returns `{ qrCodeDataUrl, secret, uri }`
3. User scans QR code in authenticator app (Google Authenticator, Authy, etc.)
4. User enters 6-digit code to confirm
5. `POST /api/auth/mfa/enable` with `{ token }` -- returns `{ recoveryCodes[] }`
6. **User must save their 10 recovery codes** (displayed once, bcrypt-hashed in DB)
### Login with MFA
1. `POST /api/auth/login` returns `{ mfaRequired: true, mfaToken }` instead of session
2. Frontend shows 6-digit PIN input (or recovery code input)
3. `POST /api/auth/mfa/verify` with `{ mfaToken, token, useRecovery? }`
4. On success: full session issued (access token + refresh cookie)
### Recovery Codes
- 10 codes generated on MFA enable
- Each code is single-use (removed from array after verification)
- Codes are bcrypt-hashed in `shared.users.recovery_codes` (JSON array)
## 12. Multi-Factor Authentication (TOTP)
### MFA Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR + secret |
| `POST` | `/api/auth/mfa/enable` | JWT | Verify code and enable MFA |
| `POST` | `/api/auth/mfa/verify` | No (uses mfaToken) | Verify during login |
| `POST` | `/api/auth/mfa/disable` | JWT | Disable MFA (requires password) |
| `GET` | `/api/auth/mfa/status` | JWT | Check if MFA is enabled |
### Tech Stack
- Library: `otplib` v4 (`generateSecret`, `generateURI`, `verifySync`)
- QR codes: `qrcode` package (data URL output)
- Recovery codes: `crypto.randomBytes` + `bcryptjs`
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR code + secret |
| `POST` | `/api/auth/mfa/enable` | JWT | Enable MFA with TOTP code |
| `POST` | `/api/auth/mfa/verify` | mfaToken | Verify during login |
| `POST` | `/api/auth/mfa/disable` | JWT | Disable (requires password) |
| `GET` | `/api/auth/mfa/status` | JWT | Check MFA status |
---
## 8. Single Sign-On (SSO)
## 13. Single Sign-On (SSO)
### Supported Providers
| Provider | Env Vars Required |
|----------|-------------------|
| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` |
| Microsoft/Azure AD | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_CALLBACK_URL` |
| Provider | Library | Env Vars Required |
|----------|---------|-------------------|
| Google | `passport-google-oauth20` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` |
| Microsoft/Azure AD | `passport-azure-ad` | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_CALLBACK_URL` |
SSO providers are **conditionally loaded** -- they only appear on the login page if their env vars are configured. The `GET /api/auth/sso/providers` endpoint returns `{ google: boolean, azure: boolean }`.
### SSO Login Flow
1. Frontend redirects to `/api/auth/google` or `/api/auth/azure`
2. Passport handles OAuth redirect to provider
3. Provider redirects back to `/api/auth/{provider}/callback`
4. Backend creates or links user via `SsoService.findOrCreateSsoUser()`
5. Session tokens issued, redirect to `/sso-callback?token={accessToken}`
### Account Linking
- SSO fields stored on `shared.users`: `sso_provider`, `sso_id`
- If email matches existing user, SSO is auto-linked on first login
- Users can unlink: `DELETE /api/auth/sso/unlink/:provider`
### SSO Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/auth/sso/providers` | No | List configured providers |
| `GET` | `/api/auth/google/callback` | No (OAuth) | Google callback |
| `GET` | `/api/auth/azure/callback` | No (OAuth) | Azure callback |
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO provider |
SSO providers are conditionally loaded based on env vars.
---
## 9. Passkeys (WebAuthn)
### Registration Flow (authenticated user)
1. `POST /api/auth/passkeys/register-options` -- returns WebAuthn creation options
2. Browser `navigator.credentials.create()` via `@simplewebauthn/browser`
3. `POST /api/auth/passkeys/register` with `{ response, deviceName? }`
4. Credential stored in `shared.user_passkeys`
### Login Flow (unauthenticated)
1. `POST /api/auth/passkeys/login-options` with `{ email? }` -- returns assertion options
2. Browser `navigator.credentials.get()` via `@simplewebauthn/browser`
3. `POST /api/auth/passkeys/login` with `{ response, challenge }`
4. Full session issued on success
### Passkey Endpoints
## 14. Passkeys (WebAuthn)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -354,157 +399,141 @@ SSO providers are **conditionally loaded** -- they only appear on the login page
| `POST` | `/api/auth/passkeys/register` | JWT | Complete registration |
| `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options |
| `POST` | `/api/auth/passkeys/login` | No | Authenticate with passkey |
| `GET` | `/api/auth/passkeys` | JWT | List registered passkeys |
| `GET` | `/api/auth/passkeys` | JWT | List user's passkeys |
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
### Configuration
- `WEBAUTHN_RP_ID` -- Relying party ID (e.g., `localhost` for dev, `yourdomain.com` for prod)
- `WEBAUTHN_RP_ORIGIN` -- Expected origin (e.g., `http://localhost` or `https://yourdomain.com`)
- Passkey removal is blocked if the user has no password and no other passkeys (prevents lockout)
---
## 10. Environment Variables Reference
## 15. Environment Variables Reference
### Stripe (Required for billing)
| Variable | Example | Description |
|----------|---------|-------------|
| `STRIPE_SECRET_KEY` | `sk_test_...` | Stripe secret key. Must NOT contain "placeholder" to activate. |
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Webhook endpoint signing secret |
| `STRIPE_STARTER_PRICE_ID` | `price_...` | Stripe Price ID for Starter plan |
| `STRIPE_PROFESSIONAL_PRICE_ID` | `price_...` | Stripe Price ID for Professional plan |
| `STRIPE_ENTERPRISE_PRICE_ID` | `price_...` | Stripe Price ID for Enterprise plan |
| Variable | Description |
|----------|-------------|
| `STRIPE_SECRET_KEY` | Stripe secret key. Must NOT contain "placeholder" to activate. |
| `STRIPE_WEBHOOK_SECRET` | Webhook endpoint signing secret |
| `STRIPE_STARTER_MONTHLY_PRICE_ID` | Stripe Price ID for Starter monthly |
| `STRIPE_STARTER_ANNUAL_PRICE_ID` | Stripe Price ID for Starter annual |
| `STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID` | Stripe Price ID for Professional monthly |
| `STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID` | Stripe Price ID for Professional annual |
| `STRIPE_ENTERPRISE_MONTHLY_PRICE_ID` | Stripe Price ID for Enterprise monthly |
| `STRIPE_ENTERPRISE_ANNUAL_PRICE_ID` | Stripe Price ID for Enterprise annual |
### SSO (Optional -- features hidden when not set)
Legacy single-price vars (`STRIPE_STARTER_PRICE_ID`, etc.) are still supported as fallback for monthly prices.
| Variable | Example | Description |
### Trial Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `GOOGLE_CLIENT_ID` | `xxx.apps.googleusercontent.com` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | `GOCSPX-...` | Google OAuth client secret |
| `GOOGLE_CALLBACK_URL` | `http://localhost/api/auth/google/callback` | OAuth redirect URI |
| `AZURE_CLIENT_ID` | `uuid` | Azure AD application (client) ID |
| `AZURE_CLIENT_SECRET` | `...` | Azure AD client secret |
| `AZURE_TENANT_ID` | `uuid` | Azure AD tenant (directory) ID |
| `AZURE_CALLBACK_URL` | `http://localhost/api/auth/azure/callback` | OAuth redirect URI |
| `REQUIRE_PAYMENT_METHOD_FOR_TRIAL` | `false` | Set to `true` to require card upfront via Stripe Checkout |
### SSO (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_CALLBACK_URL` | OAuth redirect URI |
| `AZURE_CLIENT_ID` | Azure AD application (client) ID |
| `AZURE_CLIENT_SECRET` | Azure AD client secret |
| `AZURE_TENANT_ID` | Azure AD tenant ID |
| `AZURE_CALLBACK_URL` | OAuth redirect URI |
### WebAuthn / Passkeys
| Variable | Default | Description |
|----------|---------|-------------|
| `WEBAUTHN_RP_ID` | `localhost` | Relying party identifier (your domain) |
| `WEBAUTHN_RP_ID` | `localhost` | Relying party identifier |
| `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin |
### Other
| Variable | Default | Description |
|----------|---------|-------------|
| `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for signing invite/activation JWTs. **Change in production.** |
| `APP_URL` | `http://localhost` | Base URL for generated links (activation emails, Stripe redirects) |
| `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for invite/activation JWTs |
| `APP_URL` | `http://localhost` | Base URL for generated links |
| `RESEND_API_KEY` | -- | Resend email provider API key |
---
## 11. Manual Intervention & Ops Tasks
## 16. Manual Intervention & Ops Tasks
### Before Going Live
### Stripe Dashboard Setup
1. **Set up Stripe products and prices** in the Stripe Dashboard:
- Create 3 products (Starter, Professional, Enterprise)
- Create monthly recurring prices for each
- Copy the Price IDs into `STRIPE_STARTER_PRICE_ID`, etc.
1. **Create Products and Prices** for each plan:
- Starter: monthly ($29/mo recurring) + annual ($261/yr recurring)
- Professional: monthly ($79/mo recurring) + annual ($711/yr recurring)
- Enterprise: monthly + annual (custom pricing)
- Copy all Price IDs to env vars
2. **Configure the Stripe webhook** in the Stripe Dashboard:
- Endpoint URL: `https://yourdomain.com/api/webhooks/stripe`
- Events to subscribe: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`
- Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`
2. **Configure Stripe Webhook** endpoint:
- URL: `https://yourdomain.com/api/webhooks/stripe`
- Events: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`, `customer.subscription.trial_will_end`, `customer.subscription.updated`
3. **Replace the email stub** with a real provider:
- `backend/src/modules/email/email.service.ts` currently logs to console + DB
- Swap in Resend, SendGrid, SES, or your preferred provider
- The four email methods to implement: `sendActivationEmail`, `sendWelcomeEmail`, `sendPaymentFailedEmail`, `sendInviteMemberEmail`
3. **Configure Stripe Customer Portal**:
- Enable plan switching (allow switching between monthly and annual prices)
- Enable payment method updates
- Enable cancellation
- Enable invoice history
4. **Set production secrets**:
- `INVITE_TOKEN_SECRET` -- use a strong random string (not `dev-invite-secret`)
- `JWT_SECRET` -- already required, verify it's strong
- `WEBAUTHN_RP_ID` and `WEBAUTHN_RP_ORIGIN` -- set to your production domain
4. **Set production secrets**: `INVITE_TOKEN_SECRET`, `JWT_SECRET`, `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_ORIGIN`
5. **Configure SSO providers** (if desired):
- Register apps in Google Cloud Console and/or Azure AD
- Set the callback URLs to your production domain
- Add client IDs and secrets to env vars
6. **Set up the Stripe Customer Portal** in Stripe Dashboard:
- Configure allowed actions (cancel, upgrade/downgrade, payment method updates)
- The `/api/billing/portal` endpoint needs the org-context customer ID lookup completed
5. **Configure SSO providers** (optional)
### Ongoing Ops
7. **Refresh token cleanup**: The `RefreshTokenService.cleanupExpired()` method deletes tokens that have been expired or revoked for 7+ days. **This is not currently called on a schedule.** Options:
- Add a cron job / scheduled task that calls it periodically
- Or add a NestJS `@Cron()` decorator (requires `@nestjs/schedule`)
- **Refresh token cleanup**: Schedule `RefreshTokenService.cleanupExpired()` periodically
- **Monitor `shared.email_log`**: Check for failed email deliveries
- **ACH/Invoice customers**: Admin sets up via `PUT /api/admin/organizations/:id/billing`
8. **Monitor `shared.email_log`**: While email is stubbed, activation URLs are only visible in:
- Backend console logs (look for lines starting with `EMAIL STUB`)
- The `shared.email_log` table (query: `SELECT * FROM shared.email_log ORDER BY sent_at DESC`)
### Finding activation URLs (dev/testing)
9. **Finding activation URLs manually** (dev/testing):
```sql
SELECT to_email, metadata->>'activationUrl' AS url, sent_at
FROM shared.email_log
WHERE template = 'activation'
ORDER BY sent_at DESC
LIMIT 10;
```
10. **Resend an activation email**: `POST /api/auth/resend-activation` with `{ email }` is stubbed (always returns success). To manually generate a new token:
```sql
-- Find the user and org
SELECT u.id AS user_id, uo.organization_id
FROM shared.users u
JOIN shared.user_organizations uo ON uo.user_id = u.id
WHERE u.email = 'user@example.com';
```
Then call `authService.generateInviteToken(userId, orgId, email)` or trigger a fresh checkout.
11. **Deprovisioning / cancellation**: When a Stripe subscription is deleted, the org is set to `archived`. Archived orgs:
- Block login (users see "Your organization has been suspended")
- Block API access (403 on org-scoped endpoints)
- Data is preserved (schema is NOT deleted)
- To restore: update `status` back to `active` in `shared.organizations`
```sql
SELECT to_email, metadata->>'activationUrl' AS url, sent_at
FROM shared.email_log
WHERE template = 'activation'
ORDER BY sent_at DESC
LIMIT 10;
```
---
## 12. What's Stubbed vs. Production-Ready
## 17. What's Stubbed vs. Production-Ready
| Component | Status | Notes |
|-----------|--------|-------|
| Stripe Checkout | **Ready** (test mode) | Switch to live keys for production |
| Stripe Webhooks | **Ready** | Signature verification, idempotency, event dispatch all implemented |
| Stripe Customer Portal | **Stubbed** | Endpoint exists but needs org-context customer ID lookup |
| Provisioning (org + schema + user) | **Ready** | Inline (synchronous). Consider BullMQ queue for production scale. |
| Email service | **Stubbed** | Logs to console + `shared.email_log`. Replace with real SMTP/API provider. |
| Activation (magic link) | **Ready** | Works end-to-end (token generation, validation, password set, session issue) |
| Onboarding checklist | **Ready** | Server-side progress tracking, step completion, UI wizard |
| Refresh tokens | **Ready** | Creation, validation, revocation, cleanup method (needs scheduling) |
| TOTP MFA | **Ready** | Setup, enable, verify, recovery codes, disable |
| SSO (Google) | **Ready** (needs keys) | Conditional loading, user creation/linking |
| SSO (Azure AD) | **Ready** (needs keys) | Uses deprecated `passport-azure-ad` (works, consider `@azure/msal-node`) |
| Passkeys (WebAuthn) | **Ready** | Registration, authentication, removal with lockout protection |
| Resend activation | **Stubbed** | Always returns success, no actual email sent |
| Stripe Checkout (card-required flow) | **Ready** (test mode) | Switch to live keys for production |
| Stripe Trial (no-card flow) | **Ready** (test mode) | Creates customer + subscription server-side |
| Stripe Webhooks | **Ready** | All 6 events handled with idempotency |
| Stripe Customer Portal | **Ready** | Full org-context customer ID lookup implemented |
| Monthly/Annual Pricing | **Ready** | Toggle on pricing page, 6 Stripe Price IDs |
| ACH/Invoice Billing | **Ready** | Admin endpoint switches collection method |
| Provisioning | **Ready** | Inline, supports both trial and active status |
| Email service | **Ready** (with Resend) | Falls back to stub logging if not configured |
| Trial emails | **Ready** | Trial-ending and trial-expired templates |
| Access control (past_due) | **Ready** | Read-only grace period for failed payments |
| Activation (magic link) | **Ready** | Full end-to-end flow |
| Onboarding checklist | **Ready** | Server-side progress tracking |
| Refresh tokens | **Ready** | Needs scheduled cleanup |
| TOTP MFA | **Ready** | Full setup, enable, verify, recovery |
| SSO (Google/Azure) | **Ready** (needs keys) | Conditional loading |
| Passkeys (WebAuthn) | **Ready** | Registration, authentication, removal |
---
## 13. API Endpoint Reference
## 18. API Endpoint Reference
### Billing (no auth unless noted)
### Billing
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/billing/create-checkout-session` | No | Create Stripe Checkout, returns `{ url }` |
| `POST` | `/api/billing/start-trial` | No | Start 14-day no-card trial |
| `POST` | `/api/billing/create-checkout-session` | No | Create Stripe Checkout (card-required flow) |
| `POST` | `/api/webhooks/stripe` | Stripe sig | Webhook receiver |
| `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status |
| `POST` | `/api/billing/portal` | JWT | Stripe Customer Portal (stubbed) |
| `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status |
| `GET` | `/api/billing/subscription` | JWT | Get current subscription info |
| `POST` | `/api/billing/portal` | JWT | Create Stripe Customer Portal session |
| `PUT` | `/api/admin/organizations/:id/billing` | JWT (superadmin) | Switch billing method (card/invoice) |
### Auth
@@ -515,62 +544,44 @@ SSO providers are **conditionally loaded** -- they only appear on the login page
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
| `POST` | `/api/auth/logout` | Cookie | Logout current session |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
| `GET` | `/api/auth/activate?token=` | No | Validate activation token |
| `GET` | `/api/auth/activate?token=` | No | Validate activation token |
| `POST` | `/api/auth/activate` | No | Set password + activate |
| `POST` | `/api/auth/resend-activation` | No | Resend activation (stubbed) |
| `GET` | `/api/auth/profile` | JWT | Get user profile |
| `POST` | `/api/auth/resend-activation` | No | Resend activation email |
| `GET` | `/api/auth/profile` | JWT | Get user profile |
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
### MFA
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR code + secret |
| `POST` | `/api/auth/mfa/enable` | JWT | Enable MFA with TOTP code |
| `POST` | `/api/auth/mfa/verify` | mfaToken | Verify during login |
| `POST` | `/api/auth/mfa/disable` | JWT | Disable (requires password) |
| `GET` | `/api/auth/mfa/status` | JWT | Check MFA status |
### SSO
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/auth/sso/providers` | No | List available providers |
| `GET` | `/api/auth/google/callback` | OAuth | Google callback handler |
| `GET` | `/api/auth/azure/callback` | OAuth | Azure callback handler |
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO account |
### Passkeys
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/passkeys/register-options` | JWT | Get registration options |
| `POST` | `/api/auth/passkeys/register` | JWT | Complete registration |
| `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options |
| `POST` | `/api/auth/passkeys/login` | No | Authenticate with passkey |
| `GET` | `/api/auth/passkeys` | JWT | List user's passkeys |
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
### Onboarding
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress |
| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress |
| `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete |
---
## Database Tables Added (Migration 015)
## Database Tables & Columns
### Tables Added (Migration 015)
| Table | Purpose |
|-------|---------|
| `shared.refresh_tokens` | Stores SHA-256 hashed refresh tokens with expiry/revocation |
| `shared.stripe_events` | Idempotency ledger for Stripe webhook events |
| `shared.invite_tokens` | Tracks activation/invite magic links |
| `shared.refresh_tokens` | Hashed refresh tokens with expiry/revocation |
| `shared.stripe_events` | Idempotency ledger for Stripe webhooks |
| `shared.invite_tokens` | Activation/invite magic links |
| `shared.onboarding_progress` | Per-org onboarding step completion |
| `shared.user_passkeys` | WebAuthn credential storage |
| `shared.email_log` | Stubbed email audit trail |
| `shared.user_passkeys` | WebAuthn credentials |
| `shared.email_log` | Email audit trail |
Columns added to existing tables:
- `shared.organizations`: `stripe_customer_id`, `stripe_subscription_id`, `trial_ends_at`
- `shared.users`: `totp_verified_at`, `recovery_codes`, `webauthn_challenge`
### Columns Added to `shared.organizations`
| Column | Type | Migration | Description |
|--------|------|-----------|-------------|
| `stripe_customer_id` | VARCHAR(255) UNIQUE | 015 | Stripe customer ID |
| `stripe_subscription_id` | VARCHAR(255) UNIQUE | 015 | Stripe subscription ID |
| `trial_ends_at` | TIMESTAMPTZ | 015 | Trial expiration date |
| `billing_interval` | VARCHAR(20) | 017 | `month` or `year` |
| `collection_method` | VARCHAR(20) | 017 | `charge_automatically` or `send_invoice` |
### Organization Status Values
`active`, `trial`, `past_due`, `suspended`, `archived`

22
PARKING-LOT.md Normal file
View File

@@ -0,0 +1,22 @@
# Parking Lot — Features Hidden or Deferred
This document tracks features that have been built but are currently hidden or deferred for future use.
---
## Invoices & Payments (Hidden as of 2026.03.19)
**Status:** Built but hidden from navigation
**What exists:**
- Full Invoices page at `/invoices` with CRUD, generation, and management
- Full Payments page at `/payments` with payment tracking and reconciliation
- Backend API endpoints for both modules are fully functional
- Routes remain registered in `App.tsx` (accessible via direct URL if needed)
**Where hidden:**
- `frontend/src/components/layout/Sidebar.tsx` — Navigation links commented out in the Transactions section
**To re-enable:**
1. Uncomment the Invoices and Payments entries in `Sidebar.tsx` (search for "PARKING-LOT.md")
2. No other changes needed — routes and backend are intact

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.3.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.3.19",
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.3.24",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {

View File

@@ -1,5 +1,5 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
@@ -7,6 +7,7 @@ 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 { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module';
@@ -32,6 +33,8 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
import { BillingModule } from './modules/billing/billing.module';
import { EmailModule } from './modules/email/email.module';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
import { IdeasModule } from './modules/ideas/ideas.module';
import { ShadowAiModule } from './modules/shadow-ai/shadow-ai.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
@@ -87,6 +90,8 @@ import { ScheduleModule } from '@nestjs/schedule';
BillingModule,
EmailModule,
OnboardingModule,
IdeasModule,
ShadowAiModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],
@@ -95,6 +100,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,
},
],
})
export class AppModule implements NestModule {

View File

@@ -30,6 +30,13 @@ export class WriteAccessGuard implements CanActivate {
throw new ForbiddenException('Read-only users cannot modify data');
}
// Block writes for past_due organizations (grace period: read-only access)
if (request.orgPastDue) {
throw new ForbiddenException(
'Your subscription is past due. Please update your payment method to continue making changes.',
);
}
return true;
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
/**
* Prevents browsers and proxies from caching authenticated API responses
* containing sensitive financial data (account balances, transactions, PII).
*/
@Injectable()
export class NoCacheInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const res = context.switchToHttp().getResponse();
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.setHeader('Pragma', 'no-cache');
return next.handle();
}
}

View File

@@ -0,0 +1,101 @@
/**
* Shared utility for calling OpenAI-compatible chat completion APIs.
* Used by both production AI features and shadow AI benchmarking.
*/
export interface AICallerParams {
apiUrl: string;
apiKey: string;
model: string;
messages: Array<{ role: string; content: string }>;
temperature: number;
maxTokens: number;
timeoutMs?: number;
}
export interface AICallerResult {
content: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
responseTimeMs: number;
rawResponse: string;
}
export async function callOpenAICompatible(params: AICallerParams): Promise<AICallerResult> {
const { apiUrl, apiKey, model, messages, temperature, maxTokens, timeoutMs = 600000 } = params;
const requestBody = {
model,
messages,
temperature,
max_tokens: maxTokens,
};
const bodyString = JSON.stringify(requestBody);
const startTime = Date.now();
const { URL } = await import('url');
const https = await import('https');
const aiResult = await new Promise<{ status: number; body: string }>((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: timeoutMs,
};
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 ${timeoutMs / 1000}s`));
});
req.write(bodyString);
req.end();
});
const responseTimeMs = Date.now() - startTime;
if (aiResult.status >= 400) {
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
}
const data = JSON.parse(aiResult.body);
const content = data.choices?.[0]?.message?.content || null;
if (!content) {
throw new Error('AI model returned empty content');
}
// Clean response: strip markdown fences and thinking blocks
let cleaned = content.trim();
if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
}
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
const usage = data.usage || undefined;
return {
content: cleaned,
usage,
responseTimeMs,
rawResponse: content,
};
}

View File

@@ -9,6 +9,7 @@ export interface TenantRequest extends Request {
orgId?: string;
userId?: string;
userRole?: string;
orgPastDue?: boolean;
}
@Injectable()
@@ -41,6 +42,10 @@ export class TenantMiddleware implements NestMiddleware {
});
return;
}
// past_due: allow through with read-only flag (WriteAccessGuard enforces)
if (orgInfo.status === 'past_due') {
req.orgPastDue = true;
}
req.tenantSchema = orgInfo.schemaName;
}
req.orgId = decoded.orgId;

View File

@@ -58,6 +58,14 @@ export class AccountsController {
return this.accountsService.adjustBalance(id, dto);
}
@Post('transfer')
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
transferFunds(
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
) {
return this.accountsService.transferFunds(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get account by ID' })
findOne(@Param('id') id: string) {

View File

@@ -74,9 +74,9 @@ export class AccountsService {
// Create opening balance journal entry if initialBalance is provided and non-zero
if (dto.initialBalance && dto.initialBalance !== 0) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date();
const year = balanceDate.getFullYear();
const month = balanceDate.getMonth() + 1;
// Find the current fiscal period
const periods = await this.tenant.query(
@@ -111,12 +111,14 @@ export class AccountsService {
);
}
// Create the journal entry
// Create the journal entry (use provided balance date or today)
const entryDate = dto.initialBalanceDate || new Date().toISOString().split('T')[0];
const jeInsert = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
VALUES ($1::date, $2, 'opening_balance', $3, true, NOW(), $4)
RETURNING id`,
[
entryDate,
`Opening balance for ${dto.name}`,
fiscalPeriodId,
'00000000-0000-0000-0000-000000000000',
@@ -358,6 +360,62 @@ export class AccountsService {
return journalEntry;
}
async transferFunds(dto: {
fromAccountId: string;
toAccountId: string;
amount: number;
transferDate: string;
memo?: string;
}) {
if (dto.amount <= 0) throw new BadRequestException('Transfer amount must be positive');
if (dto.fromAccountId === dto.toAccountId) throw new BadRequestException('Cannot transfer to the same account');
const fromAccount = await this.findOne(dto.fromAccountId);
const toAccount = await this.findOne(dto.toAccountId);
if (fromAccount.account_type !== 'asset') throw new BadRequestException('Source account must be an asset account');
if (toAccount.account_type !== 'asset') throw new BadRequestException('Destination account must be an asset account');
// Find fiscal period
const asOf = new Date(dto.transferDate);
const year = asOf.getFullYear();
const month = asOf.getMonth() + 1;
const periods = await this.tenant.query(
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
[year, month],
);
if (!periods.length) {
throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`);
}
const memo = dto.memo || `Transfer from ${fromAccount.name} to ${toAccount.name}`;
// Create journal entry: debit destination (increase), credit source (decrease)
const jeRows = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4)
RETURNING *`,
[dto.transferDate, memo, periods[0].id, '00000000-0000-0000-0000-000000000000'],
);
const je = jeRows[0];
// Credit source account (reduces asset balance)
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
VALUES ($1, $2, 0, $3, $4)`,
[je.id, dto.fromAccountId, dto.amount, memo],
);
// Debit destination account (increases asset balance)
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
VALUES ($1, $2, $3, 0, $4)`,
[je.id, dto.toAccountId, dto.amount, memo],
);
return je;
}
async getTrialBalance(asOfDate?: string) {
const dateFilter = asOfDate
? `AND je.entry_date <= $1`

View File

@@ -37,6 +37,11 @@ export class CreateAccountDto {
@IsOptional()
initialBalance?: number;
@ApiProperty({ required: false, description: 'ISO date string (YYYY-MM-DD) for when the initial balance was accurate' })
@IsString()
@IsOptional()
initialBalanceDate?: string;
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
@IsOptional()
interestRate?: number;

View File

@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { OrganizationsService } from '../organizations/organizations.service';
import { AdminAnalyticsService } from './admin-analytics.service';
import { IdeasService } from '../ideas/ideas.service';
import * as bcrypt from 'bcryptjs';
@ApiTags('admin')
@@ -17,6 +18,7 @@ export class AdminController {
private usersService: UsersService,
private orgService: OrganizationsService,
private analyticsService: AdminAnalyticsService,
private ideasService: IdeasService,
) {}
private async requireSuperadmin(req: any) {
@@ -196,4 +198,45 @@ export class AdminController {
return { success: true, organization: org };
}
// ── Ideation ──
@Get('ideas')
async listAllIdeas(@Req() req: any) {
await this.requireSuperadmin(req);
return this.ideasService.findAll();
}
@Put('ideas/:id/status')
async updateIdeaStatus(
@Req() req: any,
@Param('id') id: string,
@Body() body: { status: string },
) {
await this.requireSuperadmin(req);
const idea = await this.ideasService.updateStatus(id, body.status);
return { success: true, idea };
}
@Put('ideas/:id/note')
async updateIdeaNote(
@Req() req: any,
@Param('id') id: string,
@Body() body: { adminNote: string },
) {
await this.requireSuperadmin(req);
const idea = await this.ideasService.updateNote(id, body.adminNote);
return { success: true, idea };
}
@Put('organizations/:id/settings')
async updateOrgSettings(
@Req() req: any,
@Param('id') id: string,
@Body() body: Record<string, any>,
) {
await this.requireSuperadmin(req);
const org = await this.orgService.updateSettings(id, body);
return { success: true, organization: org };
}
}

View File

@@ -8,6 +8,8 @@ import {
Get,
Res,
Query,
HttpCode,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -23,6 +25,7 @@ import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
const COOKIE_NAME = 'ledgeriq_rt';
const isProduction = process.env.NODE_ENV === 'production';
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
function setRefreshCookie(res: Response, token: string) {
res.cookie(COOKIE_NAME, token, {
@@ -49,9 +52,14 @@ export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
if (!isOpenRegistration) {
throw new ForbiddenException(
'Open registration is disabled. Please use an invitation link to create your account.',
);
}
const result = await this.authService.register(dto);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
@@ -93,6 +101,7 @@ export class AuthController {
@Post('logout')
@ApiOperation({ summary: 'Logout and revoke refresh token' })
@HttpCode(200)
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
const rawToken = req.cookies?.[COOKIE_NAME];
if (rawToken) {
@@ -104,6 +113,7 @@ export class AuthController {
@Post('logout-everywhere')
@ApiOperation({ summary: 'Revoke all sessions' })
@HttpCode(200)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
@@ -183,4 +193,51 @@ export class AuthController {
// Stubbed — will be implemented when email service is ready
return { success: true, message: 'If an account exists, a new activation link has been sent.' };
}
// ─── Password Reset Flow ──────────────────────────────────────────
@Post('forgot-password')
@ApiOperation({ summary: 'Request a password reset email' })
@HttpCode(200)
@Throttle({ default: { limit: 3, ttl: 60000 } })
async forgotPassword(@Body() body: { email: string }) {
if (!body.email) throw new BadRequestException('Email is required');
await this.authService.requestPasswordReset(body.email);
// Always return same message to prevent account enumeration
return { message: 'If that email exists, a password reset link has been sent.' };
}
@Post('reset-password')
@ApiOperation({ summary: 'Reset password using a reset token' })
@HttpCode(200)
@Throttle({ default: { limit: 5, ttl: 60000 } })
async resetPassword(@Body() body: { token: string; newPassword: string }) {
if (!body.token || !body.newPassword) {
throw new BadRequestException('Token and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.resetPassword(body.token, body.newPassword);
return { message: 'Password updated successfully.' };
}
@Patch('change-password')
@ApiOperation({ summary: 'Change password (authenticated)' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async changePassword(
@Request() req: any,
@Body() body: { currentPassword: string; newPassword: string },
) {
if (!body.currentPassword || !body.newPassword) {
throw new BadRequestException('currentPassword and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.changePassword(req.user.sub, body.currentPassword, body.newPassword);
return { message: 'Password changed successfully.' };
}
}

View File

@@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
import { OrganizationsModule } from '../organizations/organizations.module';
import { IdeasModule } from '../ideas/ideas.module';
@Module({
imports: [
UsersModule,
OrganizationsModule,
IdeasModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],

View File

@@ -11,8 +11,9 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { createHash } from 'crypto';
import { randomBytes, createHash } from 'crypto';
import { UsersService } from '../users/users.service';
import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service';
@@ -21,6 +22,7 @@ import { RefreshTokenService } from './refresh-token.service';
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly inviteSecret: string;
private readonly appUrl: string;
constructor(
private usersService: UsersService,
@@ -28,8 +30,10 @@ export class AuthService {
private configService: ConfigService,
private dataSource: DataSource,
private refreshTokenService: RefreshTokenService,
private emailService: EmailService,
) {
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
}
async register(dto: RegisterDto) {
@@ -309,6 +313,105 @@ export class AuthService {
return token;
}
// ─── Password Reset Flow ──────────────────────────────────────────
/**
* Request a password reset. Generates a token, stores its hash, and sends an email.
* Silently succeeds even if the email doesn't exist (prevents enumeration).
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.usersService.findByEmail(email);
if (!user) {
// Silently return — don't reveal whether the account exists
return;
}
// Invalidate any existing reset tokens for this user
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW()
WHERE user_id = $1 AND used_at IS NULL`,
[user.id],
);
// Generate a 64-byte random token
const rawToken = randomBytes(64).toString('base64url');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await this.dataSource.query(
`INSERT INTO shared.password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[user.id, tokenHash, expiresAt],
);
const resetUrl = `${this.appUrl}/reset-password?token=${rawToken}`;
await this.emailService.sendPasswordResetEmail(user.email, resetUrl);
}
/**
* Reset password using a valid reset token.
*/
async resetPassword(rawToken: string, newPassword: string): Promise<void> {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const rows = await this.dataSource.query(
`SELECT id, user_id, expires_at, used_at
FROM shared.password_reset_tokens
WHERE token_hash = $1`,
[tokenHash],
);
if (rows.length === 0) {
throw new BadRequestException('Invalid or expired reset token');
}
const record = rows[0];
if (record.used_at) {
throw new BadRequestException('This reset link has already been used');
}
if (new Date(record.expires_at) < new Date()) {
throw new BadRequestException('This reset link has expired');
}
// Update password
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, record.user_id],
);
// Mark token as used
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW() WHERE id = $1`,
[record.id],
);
}
/**
* Change password for an authenticated user (requires current password).
*/
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
const user = await this.usersService.findById(userId);
if (!user || !user.passwordHash) {
throw new UnauthorizedException('User not found');
}
const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, userId],
);
}
// ─── Private Helpers ──────────────────────────────────────────────
private async recordLoginHistory(
userId: string,
organizationId: string | null,

View File

@@ -1,34 +1,63 @@
import {
Controller,
Post,
Put,
Get,
Body,
Param,
Query,
Req,
UseGuards,
RawBodyRequest,
BadRequestException,
ForbiddenException,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { Request as ExpressRequest } from 'express';
import { DataSource } from 'typeorm';
import { BillingService } from './billing.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('billing')
@Controller()
export class BillingController {
constructor(private billingService: BillingService) {}
constructor(
private billingService: BillingService,
private dataSource: DataSource,
) {}
@Post('billing/start-trial')
@ApiOperation({ summary: 'Start a free trial (no card required)' })
@Throttle({ default: { limit: 10, ttl: 60000 } })
async startTrial(
@Body() body: { planId: string; billingInterval?: 'month' | 'year'; email: string; businessName: string },
) {
if (!body.planId) throw new BadRequestException('planId is required');
if (!body.email) throw new BadRequestException('email is required');
if (!body.businessName) throw new BadRequestException('businessName is required');
return this.billingService.startTrial(
body.planId,
body.billingInterval || 'month',
body.email,
body.businessName,
);
}
@Post('billing/create-checkout-session')
@ApiOperation({ summary: 'Create a Stripe Checkout Session' })
@Throttle({ default: { limit: 10, ttl: 60000 } })
async createCheckout(
@Body() body: { planId: string; email?: string; businessName?: string },
@Body() body: { planId: string; billingInterval?: 'month' | 'year'; email?: string; businessName?: string },
) {
if (!body.planId) throw new BadRequestException('planId is required');
return this.billingService.createCheckoutSession(body.planId, body.email, body.businessName);
return this.billingService.createCheckoutSession(
body.planId,
body.billingInterval || 'month',
body.email,
body.businessName,
);
}
@Post('webhooks/stripe')
@@ -42,22 +71,63 @@ export class BillingController {
}
@Get('billing/status')
@ApiOperation({ summary: 'Check provisioning status for a checkout session' })
@ApiOperation({ summary: 'Check provisioning status for a checkout session or subscription' })
async getStatus(@Query('session_id') sessionId: string) {
if (!sessionId) throw new BadRequestException('session_id required');
return this.billingService.getProvisioningStatus(sessionId);
}
@Get('billing/subscription')
@ApiOperation({ summary: 'Get current subscription info' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async getSubscription(@Request() req: any) {
const orgId = req.user.orgId;
if (!orgId) throw new BadRequestException('No organization context');
return this.billingService.getSubscriptionInfo(orgId);
}
@Post('billing/portal')
@ApiOperation({ summary: 'Create Stripe Customer Portal session' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async createPortal(@Request() req: any) {
// Lookup the org's stripe_customer_id
// Only allow president or superadmin
const orgId = req.user.orgId;
if (!orgId) throw new BadRequestException('No organization context');
// For now, we'd look this up from the org
throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context');
return this.billingService.createPortalSession(orgId);
}
// ─── Admin: Switch Billing Method (ACH / Invoice) ──────────
@Put('admin/organizations/:id/billing')
@ApiOperation({ summary: 'Switch organization billing method (superadmin only)' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async updateBillingMethod(
@Request() req: any,
@Param('id') id: string,
@Body() body: { collectionMethod: 'charge_automatically' | 'send_invoice'; daysUntilDue?: number },
) {
// Require superadmin
const userId = req.user.userId || req.user.sub;
const userRows = await this.dataSource.query(
`SELECT is_superadmin FROM shared.users WHERE id = $1`,
[userId],
);
if (!userRows.length || !userRows[0].is_superadmin) {
throw new ForbiddenException('Superadmin access required');
}
if (!['charge_automatically', 'send_invoice'].includes(body.collectionMethod)) {
throw new BadRequestException('collectionMethod must be "charge_automatically" or "send_invoice"');
}
await this.billingService.switchToInvoiceBilling(
id,
body.collectionMethod,
body.daysUntilDue || 30,
);
return { success: true };
}
}

View File

@@ -14,12 +14,15 @@ const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
enterprise: { name: 'Enterprise', unitLimit: 999999 },
};
type BillingInterval = 'month' | 'year';
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
private stripe: Stripe | null = null;
private webhookSecret: string;
private priceMap: Record<string, string>;
private priceMap: Record<string, { monthly: string; annual: string }>;
private requirePaymentForTrial: boolean;
constructor(
private configService: ConfigService,
@@ -37,27 +40,118 @@ export class BillingService {
}
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
this.requirePaymentForTrial =
this.configService.get<string>('REQUIRE_PAYMENT_METHOD_FOR_TRIAL') === 'true';
// Build price map with backward-compat: new monthly vars fall back to old single vars
this.priceMap = {
starter: this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
professional: this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
enterprise: this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
starter: {
monthly: this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID')
|| this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
annual: this.configService.get<string>('STRIPE_STARTER_ANNUAL_PRICE_ID') || '',
},
professional: {
monthly: this.configService.get<string>('STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID')
|| this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
annual: this.configService.get<string>('STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID') || '',
},
enterprise: {
monthly: this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID')
|| this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
annual: this.configService.get<string>('STRIPE_ENTERPRISE_ANNUAL_PRICE_ID') || '',
},
};
}
// ─── Price Resolution ────────────────────────────────────────
private getPriceId(planId: string, interval: BillingInterval): string {
const plan = this.priceMap[planId];
if (!plan) throw new BadRequestException(`Invalid plan: ${planId}`);
const priceId = interval === 'year' ? plan.annual : plan.monthly;
if (!priceId || priceId.includes('placeholder')) {
throw new BadRequestException(`Price not configured for ${planId} (${interval})`);
}
return priceId;
}
// ─── Trial Signup (No Card Required) ────────────────────────
/**
* Start a free trial without collecting payment.
* Creates a Stripe customer + subscription with trial_period_days,
* then provisions the organization immediately.
*/
async startTrial(
planId: string,
billingInterval: BillingInterval,
email: string,
businessName: string,
): Promise<{ success: boolean; subscriptionId: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
if (!email) throw new BadRequestException('Email is required');
if (!businessName) throw new BadRequestException('Business name is required');
const priceId = this.getPriceId(planId, billingInterval);
// 1. Create Stripe customer
const customer = await this.stripe.customers.create({
email,
metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval },
});
// 2. Create subscription with 14-day trial (no payment method)
const subscription = await this.stripe.subscriptions.create({
customer: customer.id,
items: [{ price: priceId }],
trial_period_days: 14,
payment_settings: {
save_default_payment_method: 'on_subscription',
},
trial_settings: {
end_behavior: { missing_payment_method: 'cancel' },
},
metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval },
});
const trialEnd = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
// 3. Provision organization immediately with trial status
await this.provisionOrganization(
customer.id,
subscription.id,
email,
planId,
businessName,
'trial',
billingInterval,
trialEnd,
);
this.logger.log(`Trial started for ${email}, plan=${planId}, interval=${billingInterval}`);
return { success: true, subscriptionId: subscription.id };
}
// ─── Checkout Session (Card-required flow / post-trial) ─────
/**
* Create a Stripe Checkout Session for a new subscription.
* Used when REQUIRE_PAYMENT_METHOD_FOR_TRIAL=true, or for
* post-trial conversion where the user adds a payment method.
*/
async createCheckoutSession(planId: string, email?: string, businessName?: string): Promise<{ url: string }> {
if (!this.stripe) {
throw new BadRequestException('Stripe not configured');
}
async createCheckoutSession(
planId: string,
billingInterval: BillingInterval = 'month',
email?: string,
businessName?: string,
): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const priceId = this.priceMap[planId];
if (!priceId || priceId.includes('placeholder')) {
throw new BadRequestException(`Invalid plan: ${planId}`);
}
const priceId = this.getPriceId(planId, billingInterval);
const session = await this.stripe.checkout.sessions.create({
const sessionConfig: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
@@ -67,12 +161,28 @@ export class BillingService {
metadata: {
plan_id: planId,
business_name: businessName || '',
billing_interval: billingInterval,
},
});
};
// If trial is card-required, add trial period to checkout
if (this.requirePaymentForTrial) {
sessionConfig.subscription_data = {
trial_period_days: 14,
metadata: {
plan_id: planId,
business_name: businessName || '',
billing_interval: billingInterval,
},
};
}
const session = await this.stripe.checkout.sessions.create(sessionConfig);
return { url: session.url! };
}
// ─── Webhook Handling ───────────────────────────────────────
/**
* Handle a Stripe webhook event.
*/
@@ -117,19 +227,39 @@ export class BillingService {
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.trial_will_end':
await this.handleTrialWillEnd(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
default:
this.logger.log(`Unhandled Stripe event: ${event.type}`);
}
}
// ─── Provisioning Status ────────────────────────────────────
/**
* Get provisioning status for a checkout session.
* Get provisioning status for a checkout session OR subscription ID.
*/
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
if (!this.stripe) return { status: 'not_configured' };
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
const customerId = session.customer as string;
// Try as checkout session first
let customerId: string | null = null;
try {
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
customerId = session.customer as string;
} catch {
// Not a checkout session — try looking up by subscription ID
try {
const subscription = await this.stripe.subscriptions.retrieve(sessionId);
customerId = subscription.customer as string;
} catch {
return { status: 'pending' };
}
}
if (!customerId) return { status: 'pending' };
@@ -139,15 +269,56 @@ export class BillingService {
);
if (rows.length === 0) return { status: 'provisioning' };
if (rows[0].status === 'active') return { status: 'active' };
if (['active', 'trial'].includes(rows[0].status)) return { status: 'active' };
return { status: 'provisioning' };
}
// ─── Stripe Customer Portal ─────────────────────────────────
/**
* Create a Stripe Customer Portal session.
* Create a Stripe Customer Portal session for managing subscription.
*/
async createPortalSession(customerId: string): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
async createPortalSession(orgId: string): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe is not configured');
const rows = await this.dataSource.query(
`SELECT stripe_customer_id, stripe_subscription_id, status
FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (rows.length === 0) {
throw new BadRequestException('Organization not found');
}
let customerId = rows[0].stripe_customer_id;
// Fallback: if customer ID is missing but subscription exists, retrieve customer from subscription
if (!customerId && rows[0].stripe_subscription_id) {
try {
const sub = await this.stripe.subscriptions.retrieve(rows[0].stripe_subscription_id) as Stripe.Subscription;
customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
if (customerId) {
// Backfill the customer ID for future calls
await this.dataSource.query(
`UPDATE shared.organizations SET stripe_customer_id = $1 WHERE id = $2`,
[customerId, orgId],
);
this.logger.log(`Backfilled stripe_customer_id=${customerId} for org=${orgId}`);
}
} catch (err) {
this.logger.warn(`Failed to retrieve customer from subscription: ${(err as Error).message}`);
}
}
if (!customerId) {
const status = rows[0].status;
if (status === 'trial') {
throw new BadRequestException(
'Billing portal is not available during your free trial. Add a payment method when your trial ends to manage your subscription.',
);
}
throw new BadRequestException('No Stripe customer found for this organization. Please contact support.');
}
const session = await this.stripe.billingPortal.sessions.create({
customer: customerId,
@@ -157,7 +328,105 @@ export class BillingService {
return { url: session.url };
}
// ─── Provisioning (inline, no BullMQ for now — add queue later) ─────
// ─── Subscription Info ──────────────────────────────────────
/**
* Get current subscription details for the Settings billing tab.
*/
async getSubscriptionInfo(orgId: string): Promise<{
plan: string;
planName: string;
billingInterval: string;
status: string;
collectionMethod: string;
trialEndsAt: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeCustomer: boolean;
}> {
const rows = await this.dataSource.query(
`SELECT plan_level, billing_interval, status, collection_method,
trial_ends_at, stripe_subscription_id, stripe_customer_id
FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (rows.length === 0) throw new BadRequestException('Organization not found');
const org = rows[0];
let currentPeriodEnd: string | null = null;
let cancelAtPeriodEnd = false;
// Fetch live data from Stripe if available
if (this.stripe && org.stripe_subscription_id) {
try {
const sub = await this.stripe.subscriptions.retrieve(org.stripe_subscription_id, {
expand: ['items.data'],
}) as Stripe.Subscription;
// current_period_end is on the subscription item in newer Stripe API versions
const firstItem = sub.items?.data?.[0];
if (firstItem?.current_period_end) {
currentPeriodEnd = new Date(firstItem.current_period_end * 1000).toISOString();
}
cancelAtPeriodEnd = sub.cancel_at_period_end;
} catch {
// Non-critical — use DB data only
}
}
return {
plan: org.plan_level || 'starter',
planName: PLAN_FEATURES[org.plan_level]?.name || org.plan_level || 'Starter',
billingInterval: org.billing_interval || 'month',
status: org.status || 'active',
collectionMethod: org.collection_method || 'charge_automatically',
trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null,
currentPeriodEnd,
cancelAtPeriodEnd,
hasStripeCustomer: !!org.stripe_customer_id,
};
}
// ─── Invoice / ACH Billing (Admin) ──────────────────────────
/**
* Switch a customer's subscription to invoice collection (ACH/wire).
* Admin-only operation for enterprise customers.
*/
async switchToInvoiceBilling(
orgId: string,
collectionMethod: 'charge_automatically' | 'send_invoice',
daysUntilDue: number = 30,
): Promise<void> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const rows = await this.dataSource.query(
`SELECT stripe_subscription_id, stripe_customer_id FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (rows.length === 0 || !rows[0].stripe_subscription_id) {
throw new BadRequestException('No Stripe subscription found for this organization');
}
const updateParams: Stripe.SubscriptionUpdateParams = {
collection_method: collectionMethod,
};
if (collectionMethod === 'send_invoice') {
updateParams.days_until_due = daysUntilDue;
}
await this.stripe.subscriptions.update(rows[0].stripe_subscription_id, updateParams);
// Update DB
await this.dataSource.query(
`UPDATE shared.organizations SET collection_method = $1, updated_at = NOW() WHERE id = $2`,
[collectionMethod, orgId],
);
this.logger.log(`Billing method updated for org ${orgId}: ${collectionMethod}`);
}
// ─── Webhook Handlers ──────────────────────────────────────
private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const customerId = session.customer as string;
@@ -165,11 +434,27 @@ export class BillingService {
const email = session.customer_email || session.customer_details?.email || '';
const planId = session.metadata?.plan_id || 'starter';
const businessName = session.metadata?.business_name || 'My HOA';
const billingInterval = (session.metadata?.billing_interval || 'month') as BillingInterval;
this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
try {
await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName);
// Determine if this is a trial checkout (card required for trial)
let status: 'active' | 'trial' = 'active';
let trialEnd: Date | undefined;
if (this.stripe && subscriptionId) {
const sub = await this.stripe.subscriptions.retrieve(subscriptionId);
if (sub.status === 'trialing' && sub.trial_end) {
status = 'trial';
trialEnd = new Date(sub.trial_end * 1000);
}
}
await this.provisionOrganization(
customerId, subscriptionId, email, planId, businessName,
status, billingInterval, trialEnd,
);
} catch (err: any) {
this.logger.error(`Provisioning failed: ${err.message}`, err.stack);
}
@@ -177,10 +462,10 @@ export class BillingService {
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
// Activate tenant if it was pending
// Activate tenant if it was pending/trial
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'active', updated_at = NOW()
WHERE stripe_customer_id = $1 AND status != 'active'`,
WHERE stripe_customer_id = $1 AND status IN ('trial', 'past_due')`,
[customerId],
);
}
@@ -188,9 +473,17 @@ export class BillingService {
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
const rows = await this.dataSource.query(
`SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`,
`SELECT email, name FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
// Set org to past_due for grace period (read-only access)
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'past_due', updated_at = NOW()
WHERE stripe_customer_id = $1 AND status = 'active'`,
[customerId],
);
if (rows.length > 0 && rows[0].email) {
await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization');
}
@@ -207,6 +500,91 @@ export class BillingService {
this.logger.log(`Subscription cancelled for customer ${customerId}`);
}
private async handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
const rows = await this.dataSource.query(
`SELECT id, email, name FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
if (rows.length === 0) return;
const org = rows[0];
const daysRemaining = 3; // This webhook fires 3 days before trial end
const settingsUrl = `${this.getAppUrl()}/settings`;
if (org.email) {
await this.emailService.sendTrialEndingEmail(
org.email,
org.name || 'Your organization',
daysRemaining,
settingsUrl,
);
}
this.logger.log(`Trial ending soon for customer ${customerId}, org ${org.id}`);
}
private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
// Determine new status
let newStatus: string;
switch (subscription.status) {
case 'trialing':
newStatus = 'trial';
break;
case 'active':
newStatus = 'active';
break;
case 'past_due':
newStatus = 'past_due';
break;
case 'canceled':
case 'unpaid':
newStatus = 'archived';
break;
default:
return; // Don't update for other statuses
}
// Determine billing interval from the subscription items
let billingInterval: BillingInterval = 'month';
if (subscription.items?.data?.[0]?.price?.recurring?.interval === 'year') {
billingInterval = 'year';
}
// Determine plan from price metadata or existing mapping
let planId: string | null = null;
const activePriceId = subscription.items?.data?.[0]?.price?.id;
if (activePriceId) {
for (const [plan, prices] of Object.entries(this.priceMap)) {
if (prices.monthly === activePriceId || prices.annual === activePriceId) {
planId = plan;
break;
}
}
}
// Build update query dynamically
const updates: string[] = [`status = '${newStatus}'`, `billing_interval = '${billingInterval}'`, `updated_at = NOW()`];
if (planId) {
updates.push(`plan_level = '${planId}'`);
}
if (subscription.collection_method) {
updates.push(`collection_method = '${subscription.collection_method}'`);
}
await this.dataSource.query(
`UPDATE shared.organizations SET ${updates.join(', ')} WHERE stripe_customer_id = $1`,
[customerId],
);
this.logger.log(`Subscription updated for customer ${customerId}: status=${newStatus}, interval=${billingInterval}`);
}
// ─── Provisioning ──────────────────────────────────────────
/**
* Full provisioning flow: create org, schema, user, invite token, email.
*/
@@ -216,20 +594,26 @@ export class BillingService {
email: string,
planId: string,
businessName: string,
status: 'active' | 'trial' = 'active',
billingInterval: BillingInterval = 'month',
trialEndsAt?: Date,
): Promise<void> {
// 1. Create or upsert organization
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
const orgRows = await this.dataSource.query(
`INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email)
VALUES ($1, $2, 'active', $3, $4, $5, $6)
`INSERT INTO shared.organizations
(name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email, billing_interval, trial_ends_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (stripe_customer_id) DO UPDATE SET
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
plan_level = EXCLUDED.plan_level,
status = 'active',
status = EXCLUDED.status,
billing_interval = EXCLUDED.billing_interval,
trial_ends_at = EXCLUDED.trial_ends_at,
updated_at = NOW()
RETURNING id, schema_name`,
[businessName, schemaName, planId, customerId, subscriptionId, email],
[businessName, schemaName, status, planId, customerId, subscriptionId, email, billingInterval, trialEndsAt || null],
);
const orgId = orgRows[0].id;
@@ -285,7 +669,7 @@ export class BillingService {
[orgId],
);
this.logger.log(`Provisioning complete for org=${orgId}, user=${userId}`);
this.logger.log(`Provisioning complete for org=${orgId}, user=${userId}, status=${status}`);
}
private getAppUrl(): string {

View File

@@ -25,12 +25,15 @@ export class BoardPlanningProjectionService {
return this.computeProjection(scenarioId);
}
/** Compute full projection for a scenario. */
/** Compute full projection for a scenario. Also auto-creates renewal records for auto_renew investments. */
async computeProjection(scenarioId: string) {
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
const scenario = scenarioRows[0];
// Auto-create renewal investment records for auto_renew investments that have maturity dates
await this.ensureRenewalRecords(scenarioId);
const investments = await this.tenant.query(
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
);
@@ -152,6 +155,53 @@ export class BoardPlanningProjectionService {
// ── Private Helpers ──
/**
* For each auto_renew investment with a maturity_date, ensure a corresponding
* renewal investment record exists (starting at maturity_date, same term).
* The renewal record has auto_renew=false so it won't create infinite chains.
*/
private async ensureRenewalRecords(scenarioId: string) {
const autoRenewInvestments = await this.tenant.query(
`SELECT * FROM scenario_investments
WHERE scenario_id = $1 AND auto_renew = true AND maturity_date IS NOT NULL AND executed_investment_id IS NULL`,
[scenarioId],
);
for (const inv of autoRenewInvestments) {
// Check if a renewal record already exists (linked by notes convention or same label pattern)
const renewalLabel = `${inv.label} (Renewal)`;
const existing = await this.tenant.query(
`SELECT id FROM scenario_investments WHERE scenario_id = $1 AND label = $2 AND purchase_date = $3`,
[scenarioId, renewalLabel, inv.maturity_date],
);
if (existing.length > 0) continue; // Already created
// Compute new maturity date from original term
let newMaturityDate: string | null = null;
const termMonths = parseInt(inv.term_months) || 0;
if (termMonths > 0 && inv.maturity_date) {
const d = new Date(inv.maturity_date);
d.setMonth(d.getMonth() + termMonths);
newMaturityDate = d.toISOString().split('T')[0];
}
await this.tenant.query(
`INSERT INTO scenario_investments
(scenario_id, label, investment_type, fund_type, principal, interest_rate,
term_months, institution, purchase_date, maturity_date, auto_renew, notes, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, false, $11, $12)`,
[
scenarioId, renewalLabel, inv.investment_type, inv.fund_type,
inv.principal, inv.interest_rate, inv.term_months || null,
inv.institution, inv.maturity_date, newMaturityDate,
`Auto-created renewal of "${inv.label}". Modify as needed.`,
(parseInt(inv.sort_order) || 0) + 1,
],
);
}
}
private async getBaselineState(startYear: number, months: number) {
// Current balances from asset accounts
const opCashRows = await this.tenant.query(`
@@ -403,11 +453,9 @@ export class BoardPlanningProjectionService {
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
else { resCashFlow += maturityTotal; resInvChange -= principal; }
// Auto-renew: immediately reinvest
if (inv.auto_renew) {
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
else { resCashFlow -= principal; resInvChange += principal; }
}
// Note: auto_renew investments now create separate renewal records
// (via ensureRenewalRecords), so the renewal purchase is handled by
// that record's purchase_date logic above — no inline reinvest needed.
}
}
}

View File

@@ -96,6 +96,65 @@ export class EmailService {
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
}
async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise<void> {
const subject = `Your free trial ends in ${daysRemaining} days — ${businessName}`;
const html = this.buildTemplate({
preheader: `Your HOA LedgerIQ trial for ${businessName} is ending soon.`,
heading: `Your Trial Ends in ${daysRemaining} Days`,
body: `
<p>Your free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ ends in <strong>${daysRemaining} days</strong>.</p>
<p>To continue using all features without interruption, add a payment method before your trial expires.</p>
<p>If you don't add a payment method, your account will become read-only and you won't be able to make changes to your data.</p>
`,
ctaText: 'Add Payment Method',
ctaUrl: settingsUrl,
footer: 'If you have any questions about plans or pricing, just reply to this email.',
});
await this.send(email, subject, html, 'trial_ending', { businessName, daysRemaining, settingsUrl });
}
async sendTrialExpiredEmail(email: string, businessName: string): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Your free trial has ended — ${businessName}`;
const html = this.buildTemplate({
preheader: `Your HOA LedgerIQ trial for ${businessName} has ended.`,
heading: 'Your Trial Has Ended',
body: `
<p>The free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ has ended.</p>
<p>Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.</p>
`,
ctaText: 'Choose a Plan',
ctaUrl: `${appUrl}/pricing`,
footer: 'Your data will be preserved. You can reactivate your account at any time by subscribing to a plan.',
});
await this.send(email, subject, html, 'trial_expired', { businessName });
}
async sendNewMemberWelcomeEmail(
email: string,
firstName: string,
orgName: string,
): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Welcome to ${orgName} on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: `Your account for ${orgName} on HOA LedgerIQ is ready.`,
heading: `Welcome, ${this.esc(firstName)}!`,
body: `
<p>You've been added as a member of <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
<p>Your account is ready to use. Log in with your email address and the temporary password provided by your administrator. You'll be able to change your password after logging in.</p>
<p>HOA LedgerIQ gives you access to your community's financial dashboard, budgets, reports, and more.</p>
`,
ctaText: 'Log In Now',
ctaUrl: `${appUrl}/login`,
footer: 'If you were not expecting this email, please contact your HOA administrator.',
});
await this.send(email, subject, html, 'new_member_welcome', { orgName, firstName });
}
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password';
const html = this.buildTemplate({

View File

@@ -6,5 +6,6 @@ import { HealthScoresScheduler } from './health-scores.scheduler';
@Module({
controllers: [HealthScoresController],
providers: [HealthScoresService, HealthScoresScheduler],
exports: [HealthScoresService],
})
export class HealthScoresModule {}

View File

@@ -180,7 +180,7 @@ export class HealthScoresService {
// ── Data Readiness Checks ──
private async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
const missing: string[] = [];
if (scoreType === 'operating') {
@@ -249,7 +249,7 @@ export class HealthScoresService {
// ── Data Gathering ──
private async gatherOperatingData(qr: any) {
async gatherOperatingData(qr: any) {
const year = new Date().getFullYear();
const [accounts, budgets, assessments, cashFlow, recentTransactions, actualsMonths] = await Promise.all([
@@ -520,7 +520,7 @@ export class HealthScoresService {
};
}
private async gatherReserveData(qr: any) {
async gatherReserveData(qr: any) {
const year = new Date().getFullYear();
const currentMonth = new Date().getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
@@ -625,14 +625,16 @@ export class HealthScoresService {
.filter((b: any) => b.account_type === 'expense')
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
// Components needing replacement within 5 years — use whichever source has data
const urgentComponents = useComponentsTable
? reserveComponents.filter(
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
)
: reserveProjects.filter(
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
);
// Projects due within 5 years — based on planned date (target_year/target_month),
// NOT remaining_life_years. The planned date is the board's decision on when to act;
// remaining life is documentation-only reference info.
const now = new Date();
const fiveYearsFromNow = new Date(now.getFullYear() + 5, now.getMonth(), 1);
const urgentProjects = reserveProjects.filter((p: any) => {
if (!p.target_year) return false;
const targetDate = new Date(parseInt(p.target_year), (parseInt(p.target_month) || 6) - 1, 1);
return targetDate <= fiveYearsFromNow;
});
// ── Build 12-month forward reserve cash flow projection ──
@@ -773,7 +775,7 @@ export class HealthScoresService {
totalProjectCost,
annualReserveContribution,
annualReserveExpenses,
urgentComponents,
urgentProjects,
monthlySpecialAssessmentIncome,
year,
forecast,
@@ -785,7 +787,7 @@ export class HealthScoresService {
// ── AI Prompt Construction ──
private buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
const today = new Date().toISOString().split('T')[0];
const systemPrompt = `You are an HOA financial health analyst. You evaluate the operating fund health of homeowners associations on a scale of 0-100.
@@ -925,7 +927,7 @@ Projected Year-End Cash: $${data.projectedYearEndCash.toFixed(0)}`;
];
}
private buildReservePrompt(data: any): Array<{ role: string; content: string }> {
buildReservePrompt(data: any): Array<{ role: string; content: string }> {
const today = new Date().toISOString().split('T')[0];
const systemPrompt = `You are an HOA reserve fund analyst. You evaluate reserve fund health on a scale of 0-100, assessing whether the HOA is adequately prepared for future capital expenditures.
@@ -940,12 +942,13 @@ SCORING GUIDELINES:
KEY FACTORS TO EVALUATE:
1. Percent funded (total reserve assets vs total replacement costs)
2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?)
3. Component urgency (components due within 5 years and their funding status)
4. Capital project readiness (are planned projects adequately funded?)
2. Annual contribution adequacy (is annual contribution enough to keep pace with planned projects?)
3. Project urgency — based ONLY on the "Planned Date" field. The Planned Date is the board's decision on when a project will be executed. Do NOT use "Useful Life" or "Remaining Life" to determine urgency — those are reference information only. A project is only urgent if its Planned Date falls within the next 1-3 years.
4. Capital project readiness (are planned projects adequately funded by their planned dates?)
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
6. Diversity of reserve components (is the full building covered?)
6. Diversity of reserve components (is the full scope of community infrastructure tracked?)
7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low.
8. IMPORTANT — Projects with no Planned Date or with "Not scheduled" should be noted but NOT treated as urgent or imminent. Only assess urgency for projects with actual planned dates.
RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences):
@@ -974,7 +977,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
).join('\n');
// Build component lines from reserve_components if available, otherwise from reserve-funded projects
// Build component lines from reserve_components if available, otherwise from reserve-funded projects.
// Use planned date (target_year/target_month) as the authoritative timeline, not remaining_life_years.
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
const componentLines = componentSource.length === 0
? 'No reserve components or reserve projects tracked.'
@@ -982,7 +986,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
const plannedDate = c.target_year ? `${c.target_year}/${c.target_month || '?'}` : 'Not scheduled';
return `- ${c.name} [${c.category || 'N/A'}] | Planned Date: ${plannedDate} | Useful Life: ${c.useful_life_years || '?'}yr (reference only) | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
}).join('\n');
const projectLines = data.projects.length === 0
@@ -995,13 +1000,14 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
.join('\n') || 'No reserve budget line items.';
const urgentLines = data.urgentComponents.length === 0
? 'None — no components due within 5 years.'
: data.urgentComponents.map((c: any) => {
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const urgentLines = data.urgentProjects.length === 0
? 'None — no reserve projects planned within 5 years.'
: data.urgentProjects.map((p: any) => {
const cost = parseFloat(p.estimated_cost || '0');
const funded = parseFloat(p.current_fund_balance || '0');
const gap = cost - funded;
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
const targetDate = `${p.target_year}/${p.target_month || '?'}`;
return `- ${p.name}: planned for ${targetDate}, Cost: $${cost.toFixed(0)}, $${gap.toFixed(0)} funding gap`;
}).join('\n');
const userPrompt = `Evaluate this HOA's reserve fund health.
@@ -1027,10 +1033,10 @@ ${accountLines}
=== RESERVE INVESTMENTS ===
${investmentLines}
=== RESERVE COMPONENTS (ordered by urgency) ===
=== RESERVE COMPONENTS (ordered by planned date) ===
${componentLines}
=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) ===
=== PROJECTS PLANNED WITHIN 5 YEARS (by planned date) ===
${urgentLines}
=== CAPITAL PROJECTS ===

View File

@@ -0,0 +1,12 @@
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class CreateIdeaDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
title: string;
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,49 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Organization } from '../../organizations/entities/organization.entity';
import { User } from '../../users/entities/user.entity';
@Entity({ schema: 'shared', name: 'ideas' })
export class Idea {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'org_id' })
orgId: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ length: 255 })
title: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 20, default: 'new' })
status: string;
@Column({ name: 'admin_note', type: 'text', nullable: true })
adminNote: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'org_id' })
organization: Organization;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@@ -0,0 +1,27 @@
import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { IdeasService } from './ideas.service';
import { CreateIdeaDto } from './dto/create-idea.dto';
@ApiTags('ideas')
@Controller('ideas')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class IdeasController {
constructor(private ideasService: IdeasService) {}
@Post()
async create(@Req() req: any, @Body() dto: CreateIdeaDto) {
const orgId = req.user.orgId;
const userId = req.user.userId || req.user.sub;
const idea = await this.ideasService.create(orgId, userId, dto);
return { success: true, idea };
}
@Get()
async findByOrg(@Req() req: any) {
const orgId = req.user.orgId;
return this.ideasService.findByOrg(orgId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Idea } from './entities/idea.entity';
import { Organization } from '../organizations/entities/organization.entity';
import { IdeasController } from './ideas.controller';
import { IdeasService } from './ideas.service';
@Module({
imports: [TypeOrmModule.forFeature([Idea, Organization])],
controllers: [IdeasController],
providers: [IdeasService],
exports: [IdeasService],
})
export class IdeasModule {}

View File

@@ -0,0 +1,89 @@
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Idea } from './entities/idea.entity';
import { Organization } from '../organizations/entities/organization.entity';
import { CreateIdeaDto } from './dto/create-idea.dto';
@Injectable()
export class IdeasService {
constructor(
@InjectRepository(Idea)
private ideasRepository: Repository<Idea>,
@InjectRepository(Organization)
private orgRepository: Repository<Organization>,
) {}
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
const org = await this.orgRepository.findOne({ where: { id: orgId } });
if (!org) {
throw new NotFoundException('Organization not found');
}
if (org.settings?.ideationEnabled !== true) {
throw new ForbiddenException('Ideation is not enabled for this organization');
}
const idea = this.ideasRepository.create({
orgId,
userId,
title: dto.title,
description: dto.description,
});
return this.ideasRepository.save(idea);
}
async findByOrg(orgId: string): Promise<Idea[]> {
return this.ideasRepository.find({
where: { orgId },
order: { createdAt: 'DESC' },
});
}
async findAll(): Promise<any[]> {
return this.ideasRepository
.createQueryBuilder('idea')
.leftJoin('idea.organization', 'org')
.leftJoin('idea.user', 'user')
.select([
'idea.id AS id',
'idea.title AS title',
'idea.description AS description',
'idea.status AS status',
'idea.createdAt AS "createdAt"',
'idea.adminNote AS "adminNote"',
'org.id AS "orgId"',
'org.name AS "orgName"',
'user.id AS "userId"',
'user.email AS "userEmail"',
'user.firstName AS "userFirstName"',
'user.lastName AS "userLastName"',
])
.orderBy('idea.createdAt', 'DESC')
.getRawMany();
}
async updateStatus(id: string, status: string): Promise<Idea> {
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
if (!validStatuses.includes(status)) {
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
const idea = await this.ideasRepository.findOne({ where: { id } });
if (!idea) {
throw new NotFoundException('Idea not found');
}
idea.status = status;
return this.ideasRepository.save(idea);
}
async updateNote(id: string, adminNote: string): Promise<Idea> {
const idea = await this.ideasRepository.findOne({ where: { id } });
if (!idea) {
throw new NotFoundException('Idea not found');
}
idea.adminNote = adminNote;
return this.ideasRepository.save(idea);
}
}

View File

@@ -5,5 +5,6 @@ import { InvestmentPlanningService } from './investment-planning.service';
@Module({
controllers: [InvestmentPlanningController],
providers: [InvestmentPlanningService],
exports: [InvestmentPlanningService],
})
export class InvestmentPlanningModule {}

View File

@@ -877,7 +877,7 @@ export class InvestmentPlanningService {
// ── Private: AI Prompt Construction ──
private buildPromptMessages(
buildPromptMessages(
snapshot: any,
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
monthlyForecast: any,
@@ -1059,6 +1059,285 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
];
}
// ── Schema-Based Prompt Building (for shadow AI benchmarking) ──
/**
* Build investment recommendation prompt messages for a specific tenant schema.
* Bypasses request-scoped TenantService by using DataSource directly.
*/
async buildPromptForSchema(schemaName: string): Promise<Array<{ role: string; content: string }>> {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`SET search_path TO "${schemaName}"`);
const year = new Date().getFullYear();
const currentMonth = new Date().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'];
// ── Gather financial snapshot data ──
const [accountBalances, investmentAccounts, budgets, projects] = await Promise.all([
qr.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
`),
qr.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
`),
qr.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],
),
qr.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
`),
]);
// Cash flow context
const [opCashResult, resCashResult, assessmentIncome] = await Promise.all([
qr.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
`),
qr.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
`),
qr.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
`),
]);
const operatingCash = accountBalances
.filter((a: any) => a.fund_type === 'operating' && a.account_type === 'asset')
.reduce((sum: number, a: any) => sum + parseFloat(a.balance || '0'), 0);
const reserveCash = accountBalances
.filter((a: any) => a.fund_type === 'reserve' && a.account_type === 'asset')
.reduce((sum: number, a: any) => sum + parseFloat(a.balance || '0'), 0);
const operatingInvestments = investmentAccounts
.filter((i: any) => i.fund_type === 'operating')
.reduce((sum: number, i: any) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
const reserveInvestments = investmentAccounts
.filter((i: any) => i.fund_type === 'reserve')
.reduce((sum: number, i: any) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
const snapshot = {
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: {
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'),
current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
budget_summary: await qr.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],
),
monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
},
};
// ── Build monthly forecast ──
const [opCashRows2, resCashRows2, opInvRows, resInvRows] = await Promise.all([
qr.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`),
qr.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`),
qr.query(`SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true`),
qr.query(`SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true`),
]);
let runOpCash = parseFloat(opCashRows2[0]?.total || '0');
let runResCash = parseFloat(resCashRows2[0]?.total || '0');
let runOpInv = parseFloat(opInvRows[0]?.total || '0');
let runResInv = parseFloat(resInvRows[0]?.total || '0');
const assessmentGroups = await qr.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, 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 };
};
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
for (const yr of [year, year + 1]) {
const budgetRows = await qr.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; }
}
}
}
const maturities = await qr.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;
}
const projectExpenses = await qr.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 yr2 = parseInt(p.target_year);
const mo = parseInt(p.target_month) || 6;
const key = `${yr2}-${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;
}
const datapoints: any[] = [];
for (let i = 0; i < 12; i++) {
const fYear = year + Math.floor((currentMonth - 1 + i) / 12);
const fMonth = ((currentMonth - 1 + i) % 12) + 1;
const key = `${fYear}-${fMonth}`;
const label = `${monthLabels[fMonth - 1]} ${fYear}`;
const assessments = getAssessmentIncome(fMonth);
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 };
const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating;
const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve;
runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating;
runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve;
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,
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,
});
}
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),
}));
const monthlyForecast = { datapoints, assessment_schedule: assessmentSchedule };
const allRates = await this.getMarketRates();
return this.buildPromptMessages(snapshot, allRates, monthlyForecast);
} finally {
await qr.release();
}
}
// ── Private: AI API Call ──
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {

View File

@@ -1,20 +1,24 @@
import { Injectable, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
import { Injectable, ConflictException, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Organization } from './entities/organization.entity';
import { UserOrganization } from './entities/user-organization.entity';
import { TenantSchemaService } from '../../database/tenant-schema.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { EmailService } from '../email/email.service';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class OrganizationsService {
private readonly logger = new Logger(OrganizationsService.name);
constructor(
@InjectRepository(Organization)
private orgRepository: Repository<Organization>,
@InjectRepository(UserOrganization)
private userOrgRepository: Repository<UserOrganization>,
private tenantSchemaService: TenantSchemaService,
private emailService: EmailService,
) {}
async create(dto: CreateOrganizationDto, userId: string) {
@@ -124,12 +128,29 @@ export class OrganizationsService {
return rows;
}
private static readonly MEMBER_LIMIT_PLANS = ['starter', 'standard', 'professional'];
private static readonly MAX_MEMBERS = 5;
async addMember(
orgId: string,
data: { email: string; firstName: string; lastName: string; password: string; role: string },
) {
const dataSource = this.orgRepository.manager.connection;
// Enforce member limit for starter and professional plans
const org = await this.orgRepository.findOne({ where: { id: orgId } });
const planLevel = org?.planLevel || 'starter';
if (OrganizationsService.MEMBER_LIMIT_PLANS.includes(planLevel)) {
const activeMemberCount = await this.userOrgRepository.count({
where: { organizationId: orgId, isActive: true },
});
if (activeMemberCount >= OrganizationsService.MAX_MEMBERS) {
throw new BadRequestException(
`Your ${planLevel === 'starter' ? 'Starter' : 'Professional'} plan is limited to ${OrganizationsService.MAX_MEMBERS} user accounts. Please upgrade to Enterprise for unlimited members.`,
);
}
}
// Check if user already exists
let userRows = await dataSource.query(
`SELECT id FROM shared.users WHERE email = $1`,
@@ -179,7 +200,23 @@ export class OrganizationsService {
organizationId: orgId,
role: data.role,
});
return this.userOrgRepository.save(membership);
const saved = await this.userOrgRepository.save(membership);
// Send welcome email to the new member
try {
const org = await this.orgRepository.findOne({ where: { id: orgId } });
const orgName = org?.name || 'your organization';
await this.emailService.sendNewMemberWelcomeEmail(
data.email,
data.firstName,
orgName,
);
} catch (err) {
this.logger.warn(`Failed to send welcome email to ${data.email}: ${err}`);
// Don't fail the member addition if the email fails
}
return saved;
}
async updateMemberRole(orgId: string, membershipId: string, role: string) {

View File

@@ -65,6 +65,11 @@ export class ReportsController {
return this.reportsService.getDashboardKPIs();
}
@Get('upcoming-investment-activities')
getUpcomingInvestmentActivities() {
return this.reportsService.getUpcomingInvestmentActivities();
}
@Get('cash-flow-forecast')
getCashFlowForecast(
@Query('startYear') startYear?: string,
@@ -75,6 +80,13 @@ export class ReportsController {
return this.reportsService.getCashFlowForecast(yr, mo);
}
@Get('capital-planning')
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
return this.reportsService.getCapitalPlanningReport(
parseInt(startYear || '') || undefined,
);
}
@Get('quarterly')
getQuarterlyFinancial(
@Query('year') year?: string,

View File

@@ -780,6 +780,78 @@ export class ReportsService {
};
}
async getUpcomingInvestmentActivities() {
const now = new Date();
const in45Days = new Date(now);
in45Days.setDate(in45Days.getDate() + 45);
const in60Days = new Date(now);
in60Days.setDate(in60Days.getDate() + 60);
// 1. Investments maturing within 45 days
const maturingInvestments = await this.tenant.query(`
SELECT id, name, institution, investment_type, fund_type, current_value, principal,
interest_rate, maturity_date, purchase_date
FROM investment_accounts
WHERE is_active = true
AND maturity_date IS NOT NULL
AND maturity_date BETWEEN CURRENT_DATE AND $1::date
ORDER BY maturity_date ASC
`, [in45Days.toISOString().split('T')[0]]);
// Compute interest earned and days remaining for each
const maturing = maturingInvestments.map((inv: any) => {
const principal = parseFloat(inv.principal) || parseFloat(inv.current_value) || 0;
const rate = parseFloat(inv.interest_rate) || 0;
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : now;
const maturityDate = new Date(inv.maturity_date);
const daysHeld = Math.max((maturityDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
const interestEarned = principal * (rate / 100) * (daysHeld / 365);
const daysRemaining = Math.max(Math.ceil((maturityDate.getTime() - now.getTime()) / 86400000), 0);
return {
...inv,
interest_earned: interestEarned.toFixed(2),
maturity_value: (principal + interestEarned).toFixed(2),
days_remaining: daysRemaining,
activity_type: 'maturity',
};
});
// 2. Approved scenario investments due to execute within 60 days
let scenarioItems: any[] = [];
try {
scenarioItems = await this.tenant.query(`
SELECT si.id, si.label, si.investment_type, si.fund_type, si.principal,
si.interest_rate, si.purchase_date, si.maturity_date, si.institution,
bs.name as scenario_name, bs.status as scenario_status
FROM scenario_investments si
JOIN board_scenarios bs ON bs.id = si.scenario_id
WHERE bs.status = 'approved'
AND si.executed_investment_id IS NULL
AND si.purchase_date IS NOT NULL
AND si.purchase_date BETWEEN CURRENT_DATE AND $1::date
ORDER BY si.purchase_date ASC
`, [in60Days.toISOString().split('T')[0]]);
} catch {
// scenario tables may not exist
}
const upcoming = scenarioItems.map((si: any) => {
const purchaseDate = new Date(si.purchase_date);
const daysUntil = Math.max(Math.ceil((purchaseDate.getTime() - now.getTime()) / 86400000), 0);
return {
...si,
days_until: daysUntil,
activity_type: 'planned_purchase',
};
});
return {
maturing_investments: maturing,
upcoming_scenario_investments: upcoming,
total_activities: maturing.length + upcoming.length,
};
}
/**
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
@@ -1021,11 +1093,24 @@ export class ReportsService {
let runOpInv = opInv;
let runResInv = resInv;
// Determine which months have actual journal entries
// A month is "actual" only if it's not in the future AND has real journal entry data
const monthsWithActuals = new Set<string>();
for (const key of Object.keys(histIndex)) {
// histIndex keys are "year-month-fund_type", extract year-month
const parts = key.split('-');
const ym = `${parts[0]}-${parts[1]}`;
monthsWithActuals.add(ym);
}
for (let i = 0; i < months; i++) {
const year = startYear + Math.floor(i / 12);
const month = (i % 12) + 1;
const key = `${year}-${month}`;
const isHistorical = year < currentYear || (year === currentYear && month <= currentMonth);
// A month is historical (actual) only if it's in the past AND has journal entries
const isPastMonth = year < currentYear || (year === currentYear && month < currentMonth);
const hasActuals = monthsWithActuals.has(key);
const isHistorical = isPastMonth && hasActuals;
const label = `${monthLabels[month - 1]} ${year}`;
if (isHistorical) {
@@ -1251,4 +1336,120 @@ export class ReportsService {
over_budget_items: overBudgetItems,
};
}
async getCapitalPlanningReport(startYear?: number) {
const baseYear = startYear || new Date().getFullYear();
const years = [baseYear, baseYear + 1, baseYear + 2, baseYear + 3, baseYear + 4];
// Get all active projects
const projects = await this.tenant.query(
`SELECT id, name, description, category, estimated_cost, target_year, target_month,
useful_life_years, last_replacement_date, next_replacement_date, fund_source,
status, priority, condition_rating
FROM projects
WHERE is_active = true
ORDER BY category NULLS LAST, priority, name`,
);
// Also try capital_projects table
let capitalProjects: any[] = [];
try {
capitalProjects = await this.tenant.query(
`SELECT id, name, description, estimated_cost, target_year, target_month,
fund_source, status, priority, notes
FROM capital_projects
WHERE status NOT IN ('cancelled')
ORDER BY priority, name`,
);
} catch {
// Table may not exist
}
// Merge and group by category
const allProjects = [
...projects.map((p: any) => ({
id: p.id,
name: p.name,
description: p.description,
category: p.category || 'Uncategorized',
estimated_cost: parseFloat(p.estimated_cost) || 0,
target_year: parseInt(p.target_year) || null,
useful_life_years: parseInt(p.useful_life_years) || null,
last_replacement_date: p.last_replacement_date,
fund_source: p.fund_source || 'reserve',
status: p.status,
priority: parseInt(p.priority) || 3,
condition_rating: parseInt(p.condition_rating) || null,
})),
...capitalProjects
.filter((cp: any) => !projects.some((p: any) => p.name === cp.name && p.target_year === cp.target_year))
.map((cp: any) => ({
id: cp.id,
name: cp.name,
description: cp.description,
category: 'Capital Projects',
estimated_cost: parseFloat(cp.estimated_cost) || 0,
target_year: parseInt(cp.target_year) || null,
useful_life_years: null,
last_replacement_date: null,
fund_source: cp.fund_source || 'reserve',
status: cp.status,
priority: parseInt(cp.priority) || 3,
condition_rating: null,
})),
];
// Group by category
const categories: Record<string, any[]> = {};
for (const project of allProjects) {
const cat = project.category;
if (!categories[cat]) categories[cat] = [];
categories[cat].push(project);
}
// Build year columns for each project
const categoryData = Object.entries(categories).map(([category, items]) => ({
category,
projects: items.map((p) => {
const yearAmounts: Record<number, number> = {};
let beyond = 0;
if (p.target_year) {
if (p.target_year >= years[0] && p.target_year <= years[4]) {
yearAmounts[p.target_year] = p.estimated_cost;
} else if (p.target_year > years[4]) {
beyond = p.estimated_cost;
}
}
return {
...p,
year_amounts: yearAmounts,
beyond,
};
}),
}));
// Compute totals per year
const yearTotals: Record<number, number> = {};
let beyondTotal = 0;
for (const y of years) yearTotals[y] = 0;
for (const cat of categoryData) {
for (const p of cat.projects) {
for (const y of years) {
yearTotals[y] += p.year_amounts[y] || 0;
}
beyondTotal += p.beyond;
}
}
return {
title: `${years[4] - years[0] + 1}-YEAR CAPITAL PROJECT FORECAST`,
start_year: years[0],
years,
categories: categoryData,
year_totals: yearTotals,
beyond_total: beyondTotal,
grand_total: Object.values(yearTotals).reduce((a, b) => a + b, 0) + beyondTotal,
generated_at: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ schema: 'shared', name: 'shadow_ai_models' })
export class ShadowAiModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, unique: true })
slot: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ name: 'api_url', type: 'varchar', length: 500 })
apiUrl: string;
@Column({ name: 'api_key', type: 'varchar', length: 500 })
apiKey: string;
@Column({ name: 'model_name', type: 'varchar', length: 200 })
modelName: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ShadowRun } from './shadow-run.entity';
@Entity({ schema: 'shared', name: 'shadow_run_results' })
export class ShadowRunResult {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'run_id', type: 'uuid' })
runId: string;
@Column({ name: 'model_role', type: 'varchar', length: 20 })
modelRole: string;
@Column({ name: 'model_name', type: 'varchar', length: 200 })
modelName: string;
@Column({ name: 'api_url', type: 'varchar', length: 500 })
apiUrl: string;
@Column({ name: 'raw_response', type: 'text', nullable: true })
rawResponse: string;
@Column({ name: 'parsed_response', type: 'jsonb', nullable: true })
parsedResponse: any;
@Column({ name: 'response_time_ms', type: 'integer', nullable: true })
responseTimeMs: number;
@Column({ name: 'token_usage', type: 'jsonb', nullable: true })
tokenUsage: any;
@Column({ type: 'varchar', length: 20, default: 'pending' })
status: string;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@ManyToOne(() => ShadowRun, (run) => run.results, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'run_id' })
run: ShadowRun;
}

View File

@@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
} from 'typeorm';
import { ShadowRunResult } from './shadow-run-result.entity';
@Entity({ schema: 'shared', name: 'shadow_runs' })
export class ShadowRun {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 30 })
feature: string;
@Column({ type: 'varchar', length: 20, default: 'running' })
status: string;
@Column({ name: 'triggered_by', type: 'uuid', nullable: true })
triggeredBy: string;
@Column({ name: 'prompt_messages', type: 'jsonb' })
promptMessages: any;
@Column({ name: 'started_at', type: 'timestamptz', default: () => 'NOW()' })
startedAt: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@OneToMany(() => ShadowRunResult, (result) => result.run, { eager: true })
results: ShadowRunResult[];
// Virtual field populated via JOIN
tenantName?: string;
}

View File

@@ -0,0 +1,118 @@
import {
Controller,
Get,
Put,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Req,
ForbiddenException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UsersService } from '../users/users.service';
import { ShadowAiService } from './shadow-ai.service';
@ApiTags('admin/shadow-ai')
@Controller('admin/shadow-ai')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class ShadowAiController {
constructor(
private shadowAiService: ShadowAiService,
private usersService: UsersService,
) {}
private async requireSuperadmin(req: any) {
const user = await this.usersService.findById(req.user.userId || req.user.sub);
if (!user?.isSuperadmin) {
throw new ForbiddenException('Superadmin access required');
}
return user;
}
// ── Model Configuration ──
@Get('models')
async getModels(@Req() req: any) {
await this.requireSuperadmin(req);
return this.shadowAiService.getModels();
}
@Put('models/:slot')
async upsertModel(
@Req() req: any,
@Param('slot') slot: string,
@Body() body: { name: string; apiUrl: string; apiKey: string; modelName: string; isActive?: boolean },
) {
await this.requireSuperadmin(req);
if (!['A', 'B'].includes(slot)) {
throw new BadRequestException('Slot must be A or B');
}
if (!body.name || !body.apiUrl || !body.apiKey || !body.modelName) {
throw new BadRequestException('name, apiUrl, apiKey, and modelName are required');
}
return this.shadowAiService.upsertModel(slot, body);
}
@Delete('models/:slot')
async deleteModel(@Req() req: any, @Param('slot') slot: string) {
await this.requireSuperadmin(req);
if (!['A', 'B'].includes(slot)) {
throw new BadRequestException('Slot must be A or B');
}
return this.shadowAiService.deleteModel(slot);
}
// ── Shadow Runs ──
@Post('runs')
async triggerRun(
@Req() req: any,
@Body() body: { tenantId: string; feature: string },
) {
const user = await this.requireSuperadmin(req);
const validFeatures = ['operating_health', 'reserve_health', 'investment_recommendations'];
if (!validFeatures.includes(body.feature)) {
throw new BadRequestException(`Feature must be one of: ${validFeatures.join(', ')}`);
}
if (!body.tenantId) {
throw new BadRequestException('tenantId is required');
}
return this.shadowAiService.triggerRun(
body.tenantId,
body.feature as any,
user.id,
);
}
@Get('runs')
async getRunHistory(
@Req() req: any,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('tenantId') tenantId?: string,
@Query('feature') feature?: string,
) {
await this.requireSuperadmin(req);
return this.shadowAiService.getRunHistory({
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
tenantId,
feature,
});
}
@Get('runs/:id')
async getRunDetail(@Req() req: any, @Param('id') id: string) {
await this.requireSuperadmin(req);
const detail = await this.shadowAiService.getRunDetail(id);
if (!detail) throw new NotFoundException('Shadow run not found');
return detail;
}
}

View File

@@ -0,0 +1,26 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ShadowAiController } from './shadow-ai.controller';
import { ShadowAiService } from './shadow-ai.service';
import { ShadowAiModel } from './entities/shadow-ai-model.entity';
import { ShadowRun } from './entities/shadow-run.entity';
import { ShadowRunResult } from './entities/shadow-run-result.entity';
import { HealthScoresModule } from '../health-scores/health-scores.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
TypeOrmModule.forFeature([ShadowAiModel, ShadowRun, ShadowRunResult]),
HealthScoresModule,
UsersModule,
],
controllers: [ShadowAiController],
providers: [ShadowAiService],
})
export class ShadowAiModule implements OnModuleInit {
constructor(private shadowAiService: ShadowAiService) {}
async onModuleInit() {
await this.shadowAiService.ensureTables();
}
}

View File

@@ -0,0 +1,723 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { HealthScoresService } from '../health-scores/health-scores.service';
import { callOpenAICompatible } from '../../common/utils/ai-caller';
type Feature = 'operating_health' | 'reserve_health' | 'investment_recommendations';
interface ModelConfig {
role: string;
name: string;
apiUrl: string;
apiKey: string;
modelName: string;
}
@Injectable()
export class ShadowAiService {
private readonly logger = new Logger(ShadowAiService.name);
constructor(
private dataSource: DataSource,
private configService: ConfigService,
private healthScoresService: HealthScoresService,
) {}
// ── Model Configuration CRUD ──
async getModels() {
const rows = await this.dataSource.query(
`SELECT id, slot, name, api_url, api_key, model_name, is_active, created_at, updated_at
FROM shared.shadow_ai_models ORDER BY slot`,
);
return rows.map((r: any) => ({
...r,
api_key: r.api_key ? `****${r.api_key.slice(-4)}` : null,
}));
}
async upsertModel(slot: string, dto: { name: string; apiUrl: string; apiKey: string; modelName: string; isActive?: boolean }) {
const isActive = dto.isActive !== undefined ? dto.isActive : true;
// Check if model exists for this slot
const existing = await this.dataSource.query(
`SELECT id, api_key FROM shared.shadow_ai_models WHERE slot = $1`,
[slot],
);
if (existing.length > 0) {
// If apiKey is masked (starts with ****), keep the existing key
const apiKey = dto.apiKey.startsWith('****') ? existing[0].api_key : dto.apiKey;
await this.dataSource.query(
`UPDATE shared.shadow_ai_models
SET name = $1, api_url = $2, api_key = $3, model_name = $4, is_active = $5, updated_at = NOW()
WHERE slot = $6`,
[dto.name, dto.apiUrl, apiKey, dto.modelName, isActive, slot],
);
} else {
await this.dataSource.query(
`INSERT INTO shared.shadow_ai_models (slot, name, api_url, api_key, model_name, is_active)
VALUES ($1, $2, $3, $4, $5, $6)`,
[slot, dto.name, dto.apiUrl, dto.apiKey, dto.modelName, isActive],
);
}
return { slot, status: 'saved' };
}
async deleteModel(slot: string) {
await this.dataSource.query(
`DELETE FROM shared.shadow_ai_models WHERE slot = $1`,
[slot],
);
return { slot, status: 'deleted' };
}
// ── Shadow Run Execution ──
async triggerRun(tenantId: string, feature: Feature, userId: string) {
// Look up tenant schema
const orgs = await this.dataSource.query(
`SELECT schema_name, name FROM shared.organizations WHERE id = $1`,
[tenantId],
);
if (!orgs.length) throw new Error('Tenant not found');
const schemaName = orgs[0].schema_name;
// Build prompt messages for the feature
const messages = await this.buildPromptMessages(schemaName, feature);
// Create shadow run record
const runRows = await this.dataSource.query(
`INSERT INTO shared.shadow_runs (tenant_id, feature, status, triggered_by, prompt_messages, started_at)
VALUES ($1, $2, 'running', $3, $4, NOW())
RETURNING id`,
[tenantId, feature, userId, JSON.stringify(messages)],
);
const runId = runRows[0].id;
// Get model configs
const modelConfigs = await this.getModelConfigs();
// Create pending result rows
for (const config of modelConfigs) {
await this.dataSource.query(
`INSERT INTO shared.shadow_run_results (run_id, model_role, model_name, api_url, status)
VALUES ($1, $2, $3, $4, 'pending')`,
[runId, config.role, config.modelName, config.apiUrl],
);
}
// Fire-and-forget: run all models in parallel
this.executeModels(runId, messages, modelConfigs, feature).catch((err) => {
this.logger.error(`Shadow run ${runId} failed: ${err.message}`);
});
return { runId, status: 'running' };
}
// ── Run History ──
async getRunHistory(query: { page?: number; limit?: number; tenantId?: string; feature?: string }) {
const page = query.page || 1;
const limit = Math.min(query.limit || 20, 100);
const offset = (page - 1) * limit;
let where = '';
const params: any[] = [];
let paramIdx = 1;
if (query.tenantId) {
where += ` AND sr.tenant_id = $${paramIdx++}`;
params.push(query.tenantId);
}
if (query.feature) {
where += ` AND sr.feature = $${paramIdx++}`;
params.push(query.feature);
}
const [rows, countRows] = await Promise.all([
this.dataSource.query(
`SELECT sr.id, sr.tenant_id, sr.feature, sr.status, sr.started_at, sr.completed_at, sr.created_at,
o.name as tenant_name,
(SELECT COUNT(*) FROM shared.shadow_run_results rr WHERE rr.run_id = sr.id) as result_count,
(SELECT COUNT(*) FROM shared.shadow_run_results rr WHERE rr.run_id = sr.id AND rr.status = 'success') as success_count
FROM shared.shadow_runs sr
LEFT JOIN shared.organizations o ON o.id = sr.tenant_id
WHERE 1=1 ${where}
ORDER BY sr.created_at DESC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
[...params, limit, offset],
),
this.dataSource.query(
`SELECT COUNT(*) as total FROM shared.shadow_runs sr WHERE 1=1 ${where}`,
params,
),
]);
return {
runs: rows,
total: parseInt(countRows[0]?.total || '0'),
page,
limit,
};
}
async getRunDetail(runId: string) {
const [runs, results] = await Promise.all([
this.dataSource.query(
`SELECT sr.*, o.name as tenant_name
FROM shared.shadow_runs sr
LEFT JOIN shared.organizations o ON o.id = sr.tenant_id
WHERE sr.id = $1`,
[runId],
),
this.dataSource.query(
`SELECT * FROM shared.shadow_run_results
WHERE run_id = $1
ORDER BY CASE model_role
WHEN 'production' THEN 1
WHEN 'alternate_a' THEN 2
WHEN 'alternate_b' THEN 3
END`,
[runId],
),
]);
if (!runs.length) return null;
return {
...runs[0],
results,
};
}
// ── Private Helpers ──
private async buildPromptMessages(
schemaName: string,
feature: Feature,
): Promise<Array<{ role: string; content: string }>> {
if (feature === 'operating_health' || feature === 'reserve_health') {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`SET search_path TO "${schemaName}"`);
const scoreType = feature === 'operating_health' ? 'operating' : 'reserve';
const data = scoreType === 'operating'
? await this.healthScoresService.gatherOperatingData(qr)
: await this.healthScoresService.gatherReserveData(qr);
return scoreType === 'operating'
? this.healthScoresService.buildOperatingPrompt(data)
: this.healthScoresService.buildReservePrompt(data);
} finally {
await qr.release();
}
}
// investment_recommendations — build prompt directly via DataSource
return this.buildInvestmentPromptForSchema(schemaName);
}
/**
* Build investment recommendation prompts for a given tenant schema.
* Self-contained: uses DataSource directly, no request-scoped dependencies.
*/
private async buildInvestmentPromptForSchema(schemaName: string): Promise<Array<{ role: string; content: string }>> {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`SET search_path TO "${schemaName}"`);
const year = new Date().getFullYear();
const currentMonth = new Date().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'];
// ── Financial snapshot ──
const [accountBalances, investmentAccounts, budgets, projects] = await Promise.all([
qr.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
`),
qr.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`),
qr.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]),
qr.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`),
]);
const [opCashResult, resCashResult, budgetSummary, assessmentIncome] = await Promise.all([
qr.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`),
qr.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`),
qr.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]),
qr.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`),
]);
const operatingCash = accountBalances.filter((a: any) => a.fund_type === 'operating' && a.account_type === 'asset').reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
const reserveCash = accountBalances.filter((a: any) => a.fund_type === 'reserve' && a.account_type === 'asset').reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
const operatingInvestments = investmentAccounts.filter((i: any) => i.fund_type === 'operating').reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const reserveInvestments = investmentAccounts.filter((i: any) => i.fund_type === 'reserve').reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const snapshot = {
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: {
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'),
},
};
// ── 12-month forecast ──
const [opInvRows, resInvRows] = await Promise.all([
qr.query(`SELECT COALESCE(SUM(current_value),0) as total FROM investment_accounts WHERE fund_type='operating' AND is_active=true`),
qr.query(`SELECT COALESCE(SUM(current_value),0) as total FROM investment_accounts WHERE fund_type='reserve' AND is_active=true`),
]);
let runOpCash = parseFloat(opCashResult[0]?.total || '0'), runResCash = parseFloat(resCashResult[0]?.total || '0');
let runOpInv = parseFloat(opInvRows[0]?.total || '0'), runResInv = parseFloat(resInvRows[0]?.total || '0');
const assessmentGroups = await qr.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 getAssessmentInc = (month: number) => {
let op = 0, res = 0;
for (const g of assessmentGroups) {
const units = parseInt(g.unit_count) || 0, reg = parseFloat(g.regular_assessment) || 0, spec = parseFloat(g.special_assessment) || 0;
const freq = g.frequency || 'monthly';
let applies = freq === 'monthly' || (freq === 'quarterly' && [1,4,7,10].includes(month)) || (freq === 'annual' && month === 1);
if (applies) { op += reg * units; res += spec * units; }
}
return { operating: op, reserve: res };
};
const budgetsByYM: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
for (const yr of [year, year + 1]) {
const bRows = await qr.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 k = `${yr}-${m+1}`;
if (!budgetsByYM[k]) budgetsByYM[k] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
for (const r of bRows) {
const amt = parseFloat(r[monthNames[m]]) || 0;
if (!amt) continue;
const isOp = r.fund_type === 'operating';
if (r.account_type === 'income') { if (isOp) budgetsByYM[k].opIncome += amt; else budgetsByYM[k].resIncome += amt; }
else if (r.account_type === 'expense') { if (isOp) budgetsByYM[k].opExpense += amt; else budgetsByYM[k].resExpense += amt; }
}
}
}
const maturities = await qr.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 matIdx: Record<string, { operating: number; reserve: number }> = {};
for (const inv of maturities) {
const d = new Date(inv.maturity_date), k = `${d.getFullYear()}-${d.getMonth()+1}`;
if (!matIdx[k]) matIdx[k] = { operating: 0, reserve: 0 };
const val = parseFloat(inv.current_value) || 0, rate = parseFloat(inv.interest_rate) || 0;
const pDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
const days = Math.max((d.getTime() - pDate.getTime()) / 86400000, 1);
const total = val + val * (rate/100) * (days/365);
if (inv.fund_type === 'operating') matIdx[k].operating += total; else matIdx[k].reserve += total;
}
const projExp = await qr.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 projIdx: Record<string, { operating: number; reserve: number }> = {};
for (const p of projExp) {
const k = `${parseInt(p.target_year)}-${parseInt(p.target_month)||6}`;
if (!projIdx[k]) projIdx[k] = { operating: 0, reserve: 0 };
const c = parseFloat(p.estimated_cost) || 0;
if (p.fund_source === 'operating') projIdx[k].operating += c; else projIdx[k].reserve += c;
}
const datapoints: any[] = [];
for (let i = 0; i < 12; i++) {
const fY = year + Math.floor((currentMonth-1+i)/12), fM = ((currentMonth-1+i)%12)+1;
const k = `${fY}-${fM}`, label = `${monthLabels[fM-1]} ${fY}`;
const asmt = getAssessmentInc(fM), bud = budgetsByYM[k] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
const mat = matIdx[k] || { operating: 0, reserve: 0 }, proj = projIdx[k] || { operating: 0, reserve: 0 };
const opInc = bud.opIncome > 0 ? bud.opIncome : asmt.operating, resInc = bud.resIncome > 0 ? bud.resIncome : asmt.reserve;
runOpCash += opInc - bud.opExpense - proj.operating + mat.operating;
runResCash += resInc - bud.resExpense - proj.reserve + mat.reserve;
if (mat.operating > 0) runOpInv = Math.max(0, runOpInv - mat.operating * 0.96);
if (mat.reserve > 0) runResInv = Math.max(0, runResInv - mat.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,
op_income: Math.round(opInc*100)/100, op_expense: Math.round(bud.opExpense*100)/100,
res_income: Math.round(resInc*100)/100, res_expense: Math.round(bud.resExpense*100)/100,
project_cost_op: Math.round(proj.operating*100)/100, project_cost_res: Math.round(proj.reserve*100)/100,
maturity_op: Math.round(mat.operating*100)/100, maturity_res: Math.round(mat.reserve*100)/100 });
}
const asmtSchedule = 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),
}));
// ── Market rates from shared schema ──
const fetchLatest = async (rateType: string) =>
qr.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 [cdRates, mmRates, hysRates] = await Promise.all([fetchLatest('cd'), fetchLatest('money_market'), fetchLatest('high_yield_savings')]);
const allRates = { cd: cdRates, money_market: mmRates, high_yield_savings: hysRates };
// ── Build prompt (replicates InvestmentPlanningService.buildPromptMessages) ──
const { summary, investment_accounts: invAccts, cash_flow_context: cfc } = 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",
"components": [
{
"label": "Component label (e.g. '6-Month CD at Marcus')",
"amount": 6600.00,
"term_months": 6,
"rate": 4.05,
"bank_name": "Marcus",
"investment_type": "cd"
}
]
}
],
"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 ABOUT COMPONENTS:
- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts.
- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array.
- For simple single-investment recommendations, omit the "components" field entirely.
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.`;
const investmentsList = invAccts.length === 0 ? 'No current investments.'
: invAccts.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 = (cfc.budget_summary || []).length === 0 ? 'No budget summary available.'
: cfc.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');
const formatRates = (rates: any[], label: string) => rates.length === 0
? `No ${label} rate data available. Rate fetcher may not have been run yet.`
: rates.map((r: any) => `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${r.term !== 'N/A' ? ` | Term: ${r.term}` : ''} | Min Deposit: ${r.min_deposit ? '$'+parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`).join('\n');
const asmtLines = asmtSchedule.length === 0 ? 'No assessment schedule available.'
: asmtSchedule.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');
const forecastLines = datapoints.map((dp: any) => {
const d: string[] = [];
if (dp.op_income > 0) d.push(`OpInc:$${dp.op_income.toFixed(0)}`);
if (dp.op_expense > 0) d.push(`OpExp:$${dp.op_expense.toFixed(0)}`);
if (dp.res_income > 0) d.push(`ResInc:$${dp.res_income.toFixed(0)}`);
if (dp.res_expense > 0) d.push(`ResExp:$${dp.res_expense.toFixed(0)}`);
if (dp.project_cost_res > 0) d.push(`ResProjCost:$${dp.project_cost_res.toFixed(0)}`);
if (dp.project_cost_op > 0) d.push(`OpProjCost:$${dp.project_cost_op.toFixed(0)}`);
if (dp.maturity_op > 0) d.push(`OpMaturity:$${dp.maturity_op.toFixed(0)}`);
if (dp.maturity_res > 0) d.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: ${d.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 ===
${asmtLines}
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: $${cfc.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 ---
${formatRates(allRates.cd, 'CD')}
--- Money Market Rates ---
${formatRates(allRates.money_market, 'Money Market')}
--- High Yield Savings Rates ---
${formatRates(allRates.high_yield_savings, 'High Yield Savings')}
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 },
];
} finally {
await qr.release();
}
}
private async getModelConfigs(): Promise<ModelConfig[]> {
const configs: ModelConfig[] = [];
// Production model from env vars
const prodApiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
const prodApiKey = this.configService.get<string>('AI_API_KEY');
const prodModel = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
if (prodApiKey) {
configs.push({
role: 'production',
name: 'Production',
apiUrl: prodApiUrl,
apiKey: prodApiKey,
modelName: prodModel,
});
}
// Alternate models from DB
const alternates = await this.dataSource.query(
`SELECT slot, name, api_url, api_key, model_name
FROM shared.shadow_ai_models
WHERE is_active = true
ORDER BY slot`,
);
for (const alt of alternates) {
configs.push({
role: alt.slot === 'A' ? 'alternate_a' : 'alternate_b',
name: alt.name,
apiUrl: alt.api_url,
apiKey: alt.api_key,
modelName: alt.model_name,
});
}
return configs;
}
private getFeatureParams(feature: Feature): { temperature: number; maxTokens: number } {
if (feature === 'investment_recommendations') {
return { temperature: 0.3, maxTokens: 4096 };
}
return { temperature: 0.1, maxTokens: 2048 };
}
private async executeModels(
runId: string,
messages: Array<{ role: string; content: string }>,
configs: ModelConfig[],
feature: Feature,
) {
const { temperature, maxTokens } = this.getFeatureParams(feature);
const promises = configs.map(async (config) => {
// Mark as running
await this.dataSource.query(
`UPDATE shared.shadow_run_results SET status = 'running' WHERE run_id = $1 AND model_role = $2`,
[runId, config.role],
);
try {
const result = await callOpenAICompatible({
apiUrl: config.apiUrl,
apiKey: config.apiKey,
model: config.modelName,
messages,
temperature,
maxTokens,
});
// Try to parse the response as JSON
let parsedResponse: any = null;
try {
parsedResponse = JSON.parse(result.content);
} catch {
// Store raw content if not valid JSON
parsedResponse = { raw_text: result.content };
}
await this.dataSource.query(
`UPDATE shared.shadow_run_results
SET status = 'success', raw_response = $1, parsed_response = $2,
response_time_ms = $3, token_usage = $4
WHERE run_id = $5 AND model_role = $6`,
[
result.rawResponse,
JSON.stringify(parsedResponse),
result.responseTimeMs,
result.usage ? JSON.stringify(result.usage) : null,
runId,
config.role,
],
);
this.logger.log(`Shadow run ${runId} - ${config.role} (${config.modelName}) completed in ${result.responseTimeMs}ms`);
} catch (error: any) {
this.logger.error(`Shadow run ${runId} - ${config.role} (${config.modelName}) failed: ${error.message}`);
await this.dataSource.query(
`UPDATE shared.shadow_run_results
SET status = 'error', error_message = $1
WHERE run_id = $2 AND model_role = $3`,
[error.message, runId, config.role],
);
}
});
await Promise.allSettled(promises);
// Determine overall run status
const results = await this.dataSource.query(
`SELECT status FROM shared.shadow_run_results WHERE run_id = $1`,
[runId],
);
const allSuccess = results.every((r: any) => r.status === 'success');
const allError = results.every((r: any) => r.status === 'error');
const status = allSuccess ? 'completed' : allError ? 'failed' : 'partial';
await this.dataSource.query(
`UPDATE shared.shadow_runs SET status = $1, completed_at = NOW() WHERE id = $2`,
[status, runId],
);
this.logger.log(`Shadow run ${runId} finished with status: ${status}`);
}
// ── Table Creation (for initial setup) ──
async ensureTables() {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`
CREATE TABLE IF NOT EXISTS shared.shadow_ai_models (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slot VARCHAR(10) NOT NULL UNIQUE CHECK (slot IN ('A', 'B')),
name VARCHAR(100) NOT NULL,
api_url VARCHAR(500) NOT NULL,
api_key VARCHAR(500) NOT NULL,
model_name VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await qr.query(`
CREATE TABLE IF NOT EXISTS shared.shadow_runs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL,
feature VARCHAR(30) NOT NULL CHECK (feature IN ('operating_health', 'reserve_health', 'investment_recommendations')),
status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'partial', 'failed')),
triggered_by UUID,
prompt_messages JSONB NOT NULL,
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await qr.query(`
CREATE INDEX IF NOT EXISTS idx_shadow_runs_tenant ON shared.shadow_runs(tenant_id)
`);
await qr.query(`
CREATE INDEX IF NOT EXISTS idx_shadow_runs_created ON shared.shadow_runs(created_at DESC)
`);
await qr.query(`
CREATE TABLE IF NOT EXISTS shared.shadow_run_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
run_id UUID NOT NULL REFERENCES shared.shadow_runs(id) ON DELETE CASCADE,
model_role VARCHAR(20) NOT NULL CHECK (model_role IN ('production', 'alternate_a', 'alternate_b')),
model_name VARCHAR(200) NOT NULL,
api_url VARCHAR(500) NOT NULL,
raw_response TEXT,
parsed_response JSONB,
response_time_ms INTEGER,
token_usage JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'success', 'error')),
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(run_id, model_role)
)
`);
await qr.query(`
CREATE INDEX IF NOT EXISTS idx_shadow_results_run ON shared.shadow_run_results(run_id)
`);
this.logger.log('Shadow AI tables ensured');
} finally {
await qr.release();
}
}
}

View File

@@ -0,0 +1,25 @@
-- Migration 016: Password Reset Tokens
-- Adds table for password reset token storage (hashed, single-use, short-lived).
CREATE TABLE IF NOT EXISTS shared.password_reset_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON shared.password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON shared.password_reset_tokens(user_id);
-- Also ensure email_log table exists (may not exist if migration 015 hasn't been applied)
CREATE TABLE IF NOT EXISTS shared.email_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
to_email VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
body TEXT,
template VARCHAR(100),
metadata JSONB,
sent_at TIMESTAMPTZ DEFAULT NOW()
);

View File

@@ -0,0 +1,27 @@
-- Migration 017: Billing Enhancements
-- Adds support for annual billing, free trials, ACH/invoice billing,
-- and past_due grace period status.
-- ============================================================================
-- 1. Add billing_interval column (month or year)
-- ============================================================================
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS billing_interval VARCHAR(20) DEFAULT 'month';
-- ============================================================================
-- 2. Add collection_method column (charge_automatically or send_invoice)
-- ============================================================================
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS collection_method VARCHAR(20) DEFAULT 'charge_automatically';
-- ============================================================================
-- 3. Update status CHECK to include 'past_due'
-- ============================================================================
ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_status_check;
ALTER TABLE shared.organizations ADD CONSTRAINT organizations_status_check
CHECK (status IN ('active', 'suspended', 'trial', 'archived', 'past_due'));
-- ============================================================================
-- 4. Ensure plan_level CHECK includes SaaS tiers (idempotent with 015)
-- ============================================================================
ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_plan_level_check;
ALTER TABLE shared.organizations ADD CONSTRAINT organizations_plan_level_check
CHECK (plan_level IN ('standard', 'premium', 'enterprise', 'starter', 'professional'));

View File

@@ -0,0 +1,15 @@
-- Ideation feature: shared ideas table for cross-tenant idea submissions
CREATE TABLE IF NOT EXISTS shared.ideas (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id);
CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status);
CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC);

View File

@@ -0,0 +1,2 @@
-- Add private admin note column to ideas table
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;

View File

@@ -45,6 +45,13 @@ services:
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}

View File

@@ -34,6 +34,13 @@ services:
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}

View File

@@ -0,0 +1,275 @@
# Shadow AI Benchmarking Feature
## Context
The platform uses a single AI model (Qwen 3.5 via NVIDIA NIM) for three features: Operating Health Score, Reserve Health Score, and Investment Recommendations. The platform owner needs a way to evaluate alternate models (different providers, different versions) against the production model using real tenant data — without impacting users. This enables informed model migration decisions by comparing outputs side-by-side.
## Architecture Overview
- **New admin page** at `/admin/shadow-ai` with model configuration, run trigger, and history
- **New backend module** `shadow-ai` with controller, service, and 3 entities
- **3 new DB tables** in the `shared` schema for model configs, runs, and results
- **Shared AI caller utility** to avoid duplicating HTTP logic
- **Minimal changes** to existing services: make prompt-building methods public and export modules
---
## Phase 1: Shared AI Caller Utility
### New file: `backend/src/common/utils/ai-caller.ts`
Extract the HTTP POST logic (currently duplicated in both `callAI()` methods) into a reusable function:
```typescript
export async function callOpenAICompatible(params: {
apiUrl: string;
apiKey: string;
model: string;
messages: Array<{ role: string; content: string }>;
temperature: number;
maxTokens: number;
timeoutMs?: number; // default 600000
}): Promise<{
content: string; // cleaned JSON string (fences + <think> stripped)
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
responseTimeMs: number;
}>
```
Handles: HTTPS POST to `{apiUrl}/chat/completions`, timeout, markdown fence stripping, `<think>` block removal, timing.
## Phase 2: Expose Existing Prompt Builders
### `backend/src/modules/health-scores/health-scores.service.ts`
- Change `private``public` on:
- `gatherOperatingData(qr)` (line 252)
- `gatherReserveData(qr)` (line 523)
- `buildOperatingPrompt(data)` (line 790)
- `buildReservePrompt(data)` (line 930)
- `checkDataReadiness(qr, scoreType)` (used to validate data exists)
### `backend/src/modules/health-scores/health-scores.module.ts`
- Add `exports: [HealthScoresService]`
### `backend/src/modules/investment-planning/investment-planning.service.ts`
- Add new public method `buildPromptForSchema(schemaName: string)` that:
1. Creates a query runner, sets `search_path` to the tenant schema
2. Runs the same data-gathering queries (financial snapshot, market rates, monthly forecast) using the query runner directly (bypassing request-scoped `TenantService`)
3. Calls the existing `buildPromptMessages()` with gathered data
4. Returns `Array<{ role: string; content: string }>`
- Change `buildPromptMessages()` from `private``public` (line 880)
### `backend/src/modules/investment-planning/investment-planning.module.ts`
- Add `exports: [InvestmentPlanningService]`
## Phase 3: Database Tables & Entities
### 3 new tables in `shared` schema
**`shared.shadow_ai_models`** — Alternate model configurations (slots A and B)
| Column | Type | Notes |
|--------|------|-------|
| id | UUID PK | |
| slot | VARCHAR(10) | CHECK IN ('A', 'B'), UNIQUE |
| name | VARCHAR(100) | Display label |
| api_url | VARCHAR(500) | OpenAI-compatible endpoint |
| api_key | VARCHAR(500) | Bearer token |
| model_name | VARCHAR(200) | Model identifier |
| is_active | BOOLEAN | Default true |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |
**`shared.shadow_runs`** — One row per comparison execution
| Column | Type | Notes |
|--------|------|-------|
| id | UUID PK | |
| tenant_id | UUID FK | → shared.organizations |
| feature | VARCHAR(30) | CHECK IN ('operating_health', 'reserve_health', 'investment_recommendations') |
| status | VARCHAR(20) | CHECK IN ('running', 'completed', 'partial', 'failed') |
| triggered_by | UUID FK | → shared.users |
| prompt_messages | JSONB | Exact messages sent to all models (proof of identical input) |
| started_at | TIMESTAMPTZ | |
| completed_at | TIMESTAMPTZ | |
| created_at | TIMESTAMPTZ | |
**`shared.shadow_run_results`** — One row per model per run (up to 3 per run)
| Column | Type | Notes |
|--------|------|-------|
| id | UUID PK | |
| run_id | UUID FK | → shadow_runs ON DELETE CASCADE |
| model_role | VARCHAR(20) | CHECK IN ('production', 'alternate_a', 'alternate_b'), UNIQUE(run_id, model_role) |
| model_name | VARCHAR(200) | Snapshot of model used |
| api_url | VARCHAR(500) | Snapshot of endpoint used |
| raw_response | TEXT | Unprocessed AI response |
| parsed_response | JSONB | Validated structured output |
| response_time_ms | INTEGER | |
| token_usage | JSONB | { prompt_tokens, completion_tokens, total_tokens } |
| status | VARCHAR(20) | CHECK IN ('pending', 'running', 'success', 'error') |
| error_message | TEXT | |
| created_at | TIMESTAMPTZ | |
### Entity files
- `backend/src/modules/shadow-ai/entities/shadow-ai-model.entity.ts`
- `backend/src/modules/shadow-ai/entities/shadow-run.entity.ts`
- `backend/src/modules/shadow-ai/entities/shadow-run-result.entity.ts`
All use `@Entity({ schema: 'shared', name: '...' })` pattern.
## Phase 4: Shadow AI Backend Module
### New directory: `backend/src/modules/shadow-ai/`
### `shadow-ai.service.ts`
**Model CRUD:**
- `getModels()` — Return both slots, mask API keys (show last 4 chars)
- `upsertModel(slot, dto)` — INSERT/UPDATE config for slot A or B
- `deleteModel(slot)` — Remove model config
**Run Execution:**
- `triggerRun(tenantId, feature, userId)`:
1. Look up tenant `schema_name` from `shared.organizations`
2. Build prompt messages by calling the appropriate exposed method:
- `operating_health`: Create query runner → set search_path → `healthScoresService.gatherOperatingData(qr)``healthScoresService.buildOperatingPrompt(data)`
- `reserve_health`: Same pattern with reserve methods
- `investment_recommendations`: `investmentPlanningService.buildPromptForSchema(schemaName)`
3. Insert `shadow_runs` row with `prompt_messages` stored as JSONB
4. Get production config from env vars, alternate configs from DB
5. Insert 1-3 `shadow_run_results` rows as 'pending' (production + active alternates)
6. Return `{ runId }` immediately
7. Fire-and-forget: call all models in parallel using `callOpenAICompatible()`
- Per feature: operating/reserve use temp 0.1, max_tokens 2048; investment uses temp 0.3, max_tokens 4096
8. Update each result row as it completes (success/error, parsed response, timing)
9. Update run status when all complete
**History:**
- `getRunHistory(page, limit, tenantFilter?, featureFilter?)` — Paginated list with tenant name JOIN
- `getRunDetail(runId)` — Full run + all results
### `shadow-ai.controller.ts`
All endpoints use `@UseGuards(JwtAuthGuard)` + `requireSuperadmin(req)` pattern from `admin.controller.ts`.
| Method | Path | Body/Params |
|--------|------|-------------|
| GET | `/admin/shadow-ai/models` | — |
| PUT | `/admin/shadow-ai/models/:slot` | `{ name, apiUrl, apiKey, modelName, isActive }` |
| DELETE | `/admin/shadow-ai/models/:slot` | — |
| POST | `/admin/shadow-ai/runs` | `{ tenantId, feature }` |
| GET | `/admin/shadow-ai/runs` | `?page&limit&tenantId&feature` |
| GET | `/admin/shadow-ai/runs/:id` | — |
### `shadow-ai.module.ts`
```typescript
@Module({
imports: [
TypeOrmModule.forFeature([ShadowAiModel, ShadowRun, ShadowRunResult]),
HealthScoresModule,
InvestmentPlanningModule,
UsersModule,
],
controllers: [ShadowAiController],
providers: [ShadowAiService],
})
```
### Register in `backend/src/app.module.ts`
- Add `import { ShadowAiModule }` and include in the `imports` array
## Phase 5: Frontend — Admin Shadow AI Page
### New file: `frontend/src/pages/admin/AdminShadowAiPage.tsx`
**Layout**: Mantine `Tabs` with 3 tabs
#### Tab 1: "Model Configuration"
- Three `Card` components in a `SimpleGrid cols={3}`:
- **Production** (read-only): Shows model name, API URL from a dedicated endpoint or hardcoded label "From environment config"
- **Alternate A**: Form with `TextInput` (name, API URL, model name), `PasswordInput` (API key), `Switch` (active), Save/Delete buttons
- **Alternate B**: Same form
- Fetches via `GET /api/admin/shadow-ai/models`
- Saves via `PUT /api/admin/shadow-ai/models/A` or `/B`
#### Tab 2: "Run Comparison"
- `Select` dropdown for tenant (reuse `GET /api/admin/organizations` already used by AdminPage)
- `Select` for feature type (Operating Health / Reserve Health / Investment Recommendations)
- `Button` "Run Shadow Comparison"
- On trigger: `POST /api/admin/shadow-ai/runs` → get `runId`
- Poll `GET /api/admin/shadow-ai/runs/:id` every 3s via `refetchInterval` until status !== 'running'
- Show per-model progress indicators during run
- Once complete, render results using shared comparison component (below)
#### Tab 3: "History"
- `Table` with columns: Date, Tenant, Feature, Status (Badge), Duration
- Filter controls: tenant Select, feature Select
- Click row → expand detail or modal showing full comparison
- Pagination
#### Shared Component: Side-by-Side Results Display
- `SimpleGrid cols={3}` (or fewer columns if only some models were configured)
- Each column:
- Header: model name + response time `Badge`
- **For health scores**: Score with `RingProgress`, label `Badge`, summary text, factors list (color-coded by impact), recommendations list (color-coded by priority)
- **For investment**: Overall assessment text, recommendation cards with type/priority badges, risk notes
- Collapsible raw JSON via `Accordion`
- **Diff highlighting**: Where parsed values differ across models, apply subtle background highlight (e.g., `yellow.0` in Mantine theme). Simple recursive comparison of JSON keys/values.
### Route addition: `frontend/src/App.tsx`
Within the `/admin` route group (after `<Route index element={<AdminPage />} />`):
```tsx
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
```
### Sidebar nav: `frontend/src/components/layout/Sidebar.tsx`
In the `isAdminOnly` section (after the "Admin Panel" NavLink, around line 134):
```tsx
<NavLink
label="AI Benchmarking"
leftSection={<IconScale size={18} />}
active={location.pathname === '/admin/shadow-ai'}
onClick={() => go('/admin/shadow-ai')}
color="violet"
/>
```
## Implementation Order
1. **`ai-caller.ts`** — Shared utility (no dependencies)
2. **Health scores + investment planning** — Make methods public, add exports, add `buildPromptForSchema`
3. **Entities** — 3 TypeORM entity files
4. **Service + Controller + Module** — Shadow AI backend
5. **Register module** in `app.module.ts`
6. **Frontend page**`AdminShadowAiPage.tsx`
7. **Route + Sidebar** — Wire up navigation
## Verification
1. **Backend**: Start server, confirm no TypeORM errors for new entities
2. **Model config**: Use admin UI to save/load/delete alternate model configs
3. **Run comparison**: Select a tenant, trigger a run, verify all 3 models are called with identical prompts
4. **Results display**: Confirm side-by-side output renders correctly for all 3 feature types
5. **History**: Verify past runs are persisted and browsable
6. **Auth**: Confirm non-superadmin users get 403 on all shadow-ai endpoints
7. **Production safety**: Verify no changes to production AI behavior — shadow runs are completely isolated
## Key Files to Modify
- `backend/src/modules/health-scores/health-scores.service.ts` — Make 5 methods public
- `backend/src/modules/health-scores/health-scores.module.ts` — Add exports
- `backend/src/modules/investment-planning/investment-planning.service.ts` — Add `buildPromptForSchema()`, make `buildPromptMessages()` public
- `backend/src/modules/investment-planning/investment-planning.module.ts` — Add exports
- `backend/src/app.module.ts` — Register ShadowAiModule
- `frontend/src/App.tsx` — Add route
- `frontend/src/components/layout/Sidebar.tsx` — Add nav item
## New Files
- `backend/src/common/utils/ai-caller.ts`
- `backend/src/modules/shadow-ai/shadow-ai.module.ts`
- `backend/src/modules/shadow-ai/shadow-ai.service.ts`
- `backend/src/modules/shadow-ai/shadow-ai.controller.ts`
- `backend/src/modules/shadow-ai/entities/shadow-ai-model.entity.ts`
- `backend/src/modules/shadow-ai/entities/shadow-run.entity.ts`
- `backend/src/modules/shadow-ai/entities/shadow-run-result.entity.ts`
- `frontend/src/pages/admin/AdminShadowAiPage.tsx`

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.3.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.3.19",
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.3.24",
"private": true,
"type": "module",
"scripts": {

View File

@@ -24,10 +24,13 @@ 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 { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage';
import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
import { AdminPage } from './pages/admin/AdminPage';
import { AdminIdeasPage } from './pages/admin/AdminIdeasPage';
import { AdminShadowAiPage } from './pages/admin/AdminShadowAiPage';
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
@@ -132,6 +135,8 @@ export function App() {
}
>
<Route index element={<AdminPage />} />
<Route path="ideas" element={<AdminIdeasPage />} />
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
</Route>
{/* Main app routes (require auth + org) */}
@@ -167,6 +172,7 @@ export function App() {
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} />
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
<Route path="reports/capital-planning" element={<CapitalPlanningPage />} />
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />

View File

@@ -0,0 +1,69 @@
import { useState } from 'react';
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import api from '../../services/api';
interface IdeaModalProps {
opened: boolean;
onClose: () => void;
}
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const submitIdea = useMutation({
mutationFn: async () => {
const { data } = await api.post('/ideas', { title, description });
return data;
},
onSuccess: () => {
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
setTitle('');
setDescription('');
onClose();
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to submit idea',
color: 'red',
});
},
});
const handleClose = () => {
setTitle('');
setDescription('');
onClose();
};
return (
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
<Stack>
<TextInput
label="Title"
placeholder="Brief summary of your idea"
required
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={255}
/>
<Textarea
label="Description"
placeholder="Describe your idea in more detail (optional)"
minRows={4}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Button
onClick={() => submitIdea.mutate()}
loading={submitIdea.isPending}
disabled={!title.trim()}
>
Submit Idea
</Button>
</Stack>
</Modal>
);
}

View File

@@ -11,6 +11,7 @@ import {
IconEyeOff,
IconSun,
IconMoon,
IconBulb,
} from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
@@ -18,6 +19,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import { IdeaModal } from '../ideas/IdeaModal';
import logoSrc from '../../assets/logo.png';
export function AppLayout() {
@@ -28,6 +30,10 @@ export function AppLayout() {
const location = useLocation();
const isImpersonating = !!impersonationOriginal;
// ── Ideation State ──
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
// ── Onboarding State ──
const [showTour, setShowTour] = useState(false);
const [showWizard, setShowWizard] = useState(false);
@@ -121,6 +127,13 @@ export function AppLayout() {
{currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
)}
{ideationEnabled && (
<Tooltip label="Submit an idea">
<ActionIcon variant="default" size="lg" onClick={openIdeaModal} aria-label="Submit idea">
<IconBulb size={18} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
@@ -209,6 +222,9 @@ export function AppLayout() {
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
{/* ── Ideation Modal ── */}
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
</AppShell>
);
}

View File

@@ -20,6 +20,7 @@ import {
IconCalculator,
IconGitCompare,
IconScale,
IconBulb,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
@@ -73,8 +74,9 @@ const navSections = [
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
// { label: 'Payments', icon: IconCash, path: '/payments' },
],
},
{
@@ -93,6 +95,7 @@ const navSections = [
{ label: 'Sankey Diagram', path: '/reports/sankey' },
{ label: 'Year-End', path: '/reports/year-end' },
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
{ label: 'Capital Planning', path: '/reports/capital-planning' },
],
},
],
@@ -130,6 +133,20 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin')}
color="red"
/>
<NavLink
label="Idea Submissions"
leftSection={<IconBulb size={18} />}
active={location.pathname === '/admin/ideas'}
onClick={() => go('/admin/ideas')}
color="yellow"
/>
<NavLink
label="AI Benchmarking"
leftSection={<IconScale size={18} />}
active={location.pathname === '/admin/shadow-ai'}
onClick={() => go('/admin/shadow-ai')}
color="violet"
/>
{organizations && organizations.length > 0 && (
<>
<Divider my="sm" />
@@ -228,6 +245,20 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin')}
color="red"
/>
<NavLink
label="Idea Submissions"
leftSection={<IconBulb size={18} />}
active={location.pathname === '/admin/ideas'}
onClick={() => go('/admin/ideas')}
color="yellow"
/>
<NavLink
label="AI Benchmarking"
leftSection={<IconScale size={18} />}
active={location.pathname === '/admin/shadow-ai'}
onClick={() => go('/admin/shadow-ai')}
color="violet"
/>
</>
)}
</ScrollArea>

View File

@@ -1,15 +1,17 @@
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,
Select, Stack, Text, Title, Alert, ActionIcon, Table,
Card, ThemeIcon, Divider, Badge, SimpleGrid, Box,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import {
IconBuildingBank, IconUsers, IconFileSpreadsheet,
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
IconAlertCircle,
IconBuildingBank, IconUsers,
IconPlus, IconTrash, IconCheck, IconRocket,
IconAlertCircle, IconFileSpreadsheet, IconPigMoney, IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
@@ -24,56 +26,40 @@ interface UnitRow {
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 navigate = useNavigate();
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 ──
// ── Step 1: Operating 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);
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
// ── Step 2: Assessment Group State ──
// ── Step 2: Reserve Account State ──
const [reserveCreated, setReserveCreated] = useState(false);
const [reserveSkipped, setReserveSkipped] = useState(false);
const [reserveName, setReserveName] = useState('Reserve Savings');
const [reserveNumber, setReserveNumber] = useState('2000');
const [reserveDescription, setReserveDescription] = useState('');
const [reserveBalance, setReserveBalance] = useState<number | string>(0);
const [reserveBalanceDate, setReserveBalanceDate] = useState<Date | null>(new Date());
// ── Step 3: 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 [unitCount, setUnitCount] = useState<number | string>(0);
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 ──
// ── Step 1: Create Operating Account ──
const handleCreateAccount = async () => {
if (!accountName.trim()) {
setError('Account name is required');
@@ -99,6 +85,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
accountType: 'asset',
fundType: 'operating',
initialBalance: balance,
initialBalanceDate: balanceDate ? balanceDate.toISOString().split('T')[0] : undefined,
});
setAccountCreated(true);
notifications.show({
@@ -114,7 +101,53 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
}
};
// ── Step 2: Create Assessment Group ──
// ── Step 2: Create Reserve Account ──
const handleCreateReserve = async () => {
if (!reserveName.trim()) {
setError('Account name is required');
return;
}
if (!reserveNumber.trim()) {
setError('Account number is required');
return;
}
const balance = typeof reserveBalance === 'string' ? parseFloat(reserveBalance) : reserveBalance;
if (isNaN(balance)) {
setError('Initial balance must be a valid number');
return;
}
setLoading(true);
setError(null);
try {
await api.post('/accounts', {
accountNumber: reserveNumber.trim(),
name: reserveName.trim(),
description: reserveDescription.trim(),
accountType: 'asset',
fundType: 'reserve',
initialBalance: balance,
initialBalanceDate: reserveBalanceDate ? reserveBalanceDate.toISOString().split('T')[0] : undefined,
});
setReserveCreated(true);
notifications.show({
title: 'Reserve Account Created',
message: `${reserveName} has been created with an initial balance of $${balance.toLocaleString()}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create reserve account';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
const handleSkipReserve = () => {
setReserveSkipped(true);
};
// ── Step 3: Create Assessment Group ──
const handleCreateGroup = async () => {
if (!groupName.trim()) {
setError('Group name is required');
@@ -126,6 +159,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
return;
}
const count = typeof unitCount === 'string' ? parseInt(unitCount) : unitCount;
setLoading(true);
setError(null);
try {
@@ -133,6 +168,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
name: groupName.trim(),
regularAssessment: assessment,
frequency,
unitCount: isNaN(count) ? 0 : count,
isDefault: true,
});
setGroupCreated(true);
@@ -175,72 +211,19 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
}
};
// ── 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 ──
// ── Finish Wizard → Navigate to Budget Planning ──
const handleFinish = async () => {
setLoading(true);
try {
await api.patch('/organizations/settings', { onboardingComplete: true });
setOrgSettings({ onboardingComplete: true });
onComplete();
// Navigate to Budget Planning so user can set up their budget immediately
navigate('/board-planning/budgets');
} catch {
// Even if API fails, close the wizard — onboarding data is already created
onComplete();
navigate('/board-planning/budgets');
} finally {
setLoading(false);
}
@@ -264,8 +247,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
// ── Navigation ──
const canGoNext = () => {
if (active === 0) return accountCreated;
if (active === 1) return groupCreated;
if (active === 2) return true; // Budget is optional
if (active === 1) return reserveCreated || reserveSkipped;
if (active === 2) return groupCreated;
return false;
};
@@ -305,22 +288,22 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Stepper active={active} size="sm" mb="xl">
<Stepper.Step
label="Operating Account"
description="Set up your primary bank account"
description="Primary bank account"
icon={<IconBuildingBank size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Reserve Account"
description={reserveSkipped ? 'Skipped' : 'Savings account'}
icon={<IconPigMoney size={18} />}
completedIcon={reserveSkipped ? <IconX size={18} /> : <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 && (
@@ -343,6 +326,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Text fw={500}>{accountName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
{balanceDate && ` as of ${balanceDate.toLocaleDateString()}`}
</Text>
</Alert>
) : (
@@ -372,17 +356,26 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
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"
/>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Current Balance"
description="Enter the current balance of this bank account"
placeholder="0.00"
value={initialBalance}
onChange={setInitialBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
/>
<DateInput
label="Balance As-Of Date"
description="Date this balance was accurate (e.g. last statement date)"
value={balanceDate}
onChange={setBalanceDate}
maxDate={new Date()}
clearable={false}
/>
</SimpleGrid>
<Button
onClick={handleCreateAccount}
loading={loading}
@@ -396,8 +389,103 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Stack>
)}
{/* ── Step 2: Assessment Group + Units ── */}
{/* ── Step 2: Reserve Account ── */}
{active === 1 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Set Up a Reserve Savings Account</Text>
<Text size="sm" c="dimmed" mb="md">
Most HOAs maintain a reserve fund for long-term capital projects like roof replacements,
paving, and major repairs. Setting this up now gives you a more complete financial picture
from the start.
</Text>
{reserveCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{reserveName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof reserveBalance === 'number' ? reserveBalance : parseFloat(reserveBalance as string) || 0).toLocaleString()}
{reserveBalanceDate && ` as of ${reserveBalanceDate.toLocaleDateString()}`}
</Text>
</Alert>
) : reserveSkipped ? (
<Alert icon={<IconX size={16} />} color="gray" variant="light">
<Text fw={500}>Reserve account skipped</Text>
<Text size="sm" c="dimmed">
You can always add a reserve account later from the Accounts page.
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={2} mb="md">
<TextInput
label="Account Name"
placeholder="e.g. Reserve Savings"
value={reserveName}
onChange={(e) => setReserveName(e.currentTarget.value)}
required
/>
<TextInput
label="Account Number"
placeholder="e.g. 2000"
value={reserveNumber}
onChange={(e) => setReserveNumber(e.currentTarget.value)}
required
/>
</SimpleGrid>
<Textarea
label="Description"
placeholder="Optional description"
value={reserveDescription}
onChange={(e) => setReserveDescription(e.currentTarget.value)}
mb="md"
autosize
minRows={2}
/>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Current Balance"
description="Enter the current balance of this reserve account"
placeholder="0.00"
value={reserveBalance}
onChange={setReserveBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
/>
<DateInput
label="Balance As-Of Date"
description="Date this balance was accurate"
value={reserveBalanceDate}
onChange={setReserveBalanceDate}
maxDate={new Date()}
clearable={false}
/>
</SimpleGrid>
<Group>
<Button
onClick={handleCreateReserve}
loading={loading}
leftSection={<IconPigMoney size={16} />}
>
Create Reserve Account
</Button>
<Button
variant="subtle"
color="gray"
onClick={handleSkipReserve}
>
No Reserve Account
</Button>
</Group>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 3: Assessment Group + Units ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create an Assessment Group</Text>
@@ -415,7 +503,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Alert>
) : (
<>
<SimpleGrid cols={3} mb="md">
<SimpleGrid cols={2} mb="md">
<TextInput
label="Group Name"
placeholder="e.g. Standard Assessment"
@@ -423,6 +511,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
onChange={(e) => setGroupName(e.currentTarget.value)}
required
/>
<NumberInput
label="Total Unit Count"
description="How many units/lots does your community have?"
placeholder="e.g. 50"
value={unitCount}
onChange={setUnitCount}
min={0}
required
/>
</SimpleGrid>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Assessment Amount"
placeholder="0.00"
@@ -520,61 +619,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</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&apos;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' }}>
@@ -583,16 +627,25 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</ThemeIcon>
<Title order={3} mb="xs">You&apos;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.
Your organization is configured and ready to go. The next step is to set up your annual
budget we&apos;ll take you straight to Budget Planning.
</Text>
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
<SimpleGrid cols={4} mb="xl" maw={600} 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>
<Text size="xs" mt={4}>Operating</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="violet" variant="light" radius="xl" mx="auto" mb={4}>
<IconPigMoney size={16} />
</ThemeIcon>
<Badge color={reserveSkipped ? 'gray' : 'green'} size="sm">
{reserveSkipped ? 'Skipped' : 'Done'}
</Badge>
<Text size="xs" mt={4}>Reserve</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
@@ -602,24 +655,30 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<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}>
<ThemeIcon size={32} color="cyan" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} />
</ThemeIcon>
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
{budgetUploaded ? 'Done' : 'Skipped'}
</Badge>
<Badge color="cyan" size="sm">Up Next</Badge>
<Text size="xs" mt={4}>Budget</Text>
</Card>
</SimpleGrid>
<Alert icon={<IconFileSpreadsheet size={16} />} color="blue" variant="light" mb="lg" ta="left">
<Text size="sm" fw={500} mb={4}>Set Up Your Budget</Text>
<Text size="sm" c="dimmed">
Your budget is critical for accurate financial health scores, cash flow forecasting,
and investment planning. Click below to go directly to Budget Planning where you can
download a CSV template, fill in your monthly amounts, and upload your budget.
</Text>
</Alert>
<Button
size="lg"
onClick={handleFinish}
loading={loading}
leftSection={<IconRocket size={18} />}
leftSection={<IconFileSpreadsheet size={18} />}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Start Using LedgerIQ
Set Up My Budget
</Button>
</Card>
)}
@@ -627,16 +686,11 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
{/* ── 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'}
Next Step
</Button>
</Group>
)}

View File

@@ -37,6 +37,7 @@ import {
IconStarFilled,
IconAdjustments,
IconInfoCircle,
IconArrowsTransferDown,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -126,6 +127,7 @@ export function AccountsPage() {
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
@@ -283,6 +285,39 @@ export function AccountsPage() {
},
});
// ── Transfer form ──
const transferForm = useForm({
initialValues: {
fromAccountId: '',
toAccountId: '',
amount: 0,
transferDate: new Date() as Date | null,
memo: '',
},
validate: {
fromAccountId: (v) => (v ? null : 'Required'),
toAccountId: (v, values) => !v ? 'Required' : v === values.fromAccountId ? 'Must be different from source' : null,
amount: (v) => (v > 0 ? null : 'Must be greater than 0'),
transferDate: (v) => (v ? null : 'Required'),
},
});
const transferMutation = useMutation({
mutationFn: (values: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo: string }) =>
api.post('/accounts/transfer', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
notifications.show({ message: 'Transfer completed successfully', color: 'green' });
closeTransfer();
transferForm.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Transfer failed', color: 'red' });
},
});
// ── Investment edit form ──
const invForm = useForm({
initialValues: {
@@ -408,6 +443,9 @@ export function AccountsPage() {
const activeAccounts = filtered.filter((a) => a.is_active);
const archivedAccounts = filtered.filter((a) => !a.is_active);
// Asset accounts for transfer modal (all active asset accounts, not just filtered by search)
const assetAccounts = accounts.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset');
// ── Investments split by fund type ──
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
@@ -505,9 +543,14 @@ export function AccountsPage() {
size="sm"
/>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
<>
<Button variant="light" leftSection={<IconArrowsTransferDown size={16} />} onClick={openTransfer}>
Transfer Funds
</Button>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
</>
)}
</Group>
</Group>
@@ -587,7 +630,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && (
<>
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -605,7 +648,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && (
<>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -623,7 +666,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && (
<>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -854,6 +897,69 @@ export function AccountsPage() {
)}
</Modal>
{/* Transfer Funds Modal */}
<Modal opened={transferOpened} onClose={closeTransfer} title="Transfer Funds Between Accounts" size="md" closeOnClickOutside={false}>
<form onSubmit={transferForm.onSubmit((values) => {
transferMutation.mutate({
...values,
transferDate: values.transferDate ? values.transferDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
});
})}>
<Stack>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
This creates a journal entry transferring funds between asset accounts.
Both accounts will be updated in the general ledger.
</Alert>
<Select
label="From Account"
placeholder="Select source account"
required
data={assetAccounts.map((a) => ({
value: a.id,
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
}))}
searchable
{...transferForm.getInputProps('fromAccountId')}
/>
<Select
label="To Account"
placeholder="Select destination account"
required
data={assetAccounts
.filter((a) => a.id !== transferForm.values.fromAccountId)
.map((a) => ({
value: a.id,
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
}))}
searchable
{...transferForm.getInputProps('toAccountId')}
/>
<NumberInput
label="Amount"
required
prefix="$"
decimalScale={2}
thousandSeparator=","
min={0.01}
{...transferForm.getInputProps('amount')}
/>
<DateInput
label="Transfer Date"
required
{...transferForm.getInputProps('transferDate')}
/>
<TextInput
label="Memo (optional)"
placeholder="e.g. Monthly reserve contribution"
{...transferForm.getInputProps('memo')}
/>
<Button type="submit" leftSection={<IconArrowsTransferDown size={16} />} loading={transferMutation.isPending}>
Complete Transfer
</Button>
</Stack>
</form>
</Modal>
{/* Investment Edit Modal */}
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
{editingInvestment && (
@@ -1087,9 +1193,11 @@ function AccountTable({
function InvestmentMiniTable({
investments,
onEdit,
isReadOnly = false,
}: {
investments: Investment[];
onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce(
@@ -1132,7 +1240,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -1182,13 +1290,15 @@ function InvestmentMiniTable({
'-'
)}
</Table.Td>
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -0,0 +1,308 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
Tooltip, Paper,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconBulb, IconSearch, IconNote, IconFilter,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface AdminIdea {
id: string;
title: string;
description: string | null;
status: string;
createdAt: string;
adminNote: string | null;
orgId: string;
orgName: string;
userId: string;
userEmail: string;
userFirstName: string;
userLastName: string;
}
const statusColor: Record<string, string> = {
new: 'blue',
reviewed: 'yellow',
accepted: 'green',
rejected: 'red',
};
const statusOptions = [
{ value: 'new', label: 'New' },
{ value: 'reviewed', label: 'Reviewed' },
{ value: 'accepted', label: 'Accepted' },
{ value: 'rejected', label: 'Rejected' },
];
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString();
}
function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleString();
}
export function AdminIdeasPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [noteText, setNoteText] = useState('');
const queryClient = useQueryClient();
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
queryKey: ['admin-ideas'],
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
});
const updateStatus = useMutation({
mutationFn: async ({ id, status }: { id: string; status: string }) => {
await api.put(`/admin/ideas/${id}/status`, { status });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
notifications.show({ message: 'Status updated', color: 'green' });
},
});
const updateNote = useMutation({
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
await api.put(`/admin/ideas/${id}/note`, { adminNote });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
notifications.show({ message: 'Note saved', color: 'green' });
},
});
const openIdeaDetail = (idea: AdminIdea) => {
setSelectedIdea(idea);
setNoteText(idea.adminNote || '');
openDetail();
};
const handleSaveNote = () => {
if (selectedIdea) {
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
}
};
const filtered = (ideas || []).filter((idea) => {
const matchesSearch = !search ||
idea.title.toLowerCase().includes(search.toLowerCase()) ||
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
idea.userEmail.toLowerCase().includes(search.toLowerCase());
const matchesStatus = !statusFilter || idea.status === statusFilter;
return matchesSearch && matchesStatus;
});
const counts = {
total: ideas?.length || 0,
new: ideas?.filter(i => i.status === 'new').length || 0,
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
};
if (isLoading) {
return <Center h={400}><Loader /></Center>;
}
return (
<Stack>
<Group justify="space-between">
<Group>
<IconBulb size={28} />
<Title order={2}>Idea Submissions</Title>
</Group>
<Badge size="lg" variant="light">{counts.total} total</Badge>
</Group>
{/* Summary cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }}>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
</Paper>
</SimpleGrid>
{/* Filters */}
<Group>
<TextInput
placeholder="Search ideas, tenants, users..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
leftSection={<IconFilter size={16} />}
data={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
clearable
w={160}
/>
</Group>
{/* Ideas table */}
<Card withBorder p={0}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th>
<Table.Th>Tenant</Table.Th>
<Table.Th>Submitted By</Table.Th>
<Table.Th>Title</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th w={40}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text ta="center" c="dimmed" py="lg">
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
</Text>
</Table.Td>
</Table.Tr>
) : (
filtered.map((idea) => (
<Table.Tr
key={idea.id}
style={{ cursor: 'pointer' }}
onClick={() => openIdeaDetail(idea)}
>
<Table.Td>
<Text size="xs">{formatDate(idea.createdAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>{idea.orgName}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
{idea.status}
</Badge>
</Table.Td>
<Table.Td>
{idea.adminNote && (
<Tooltip label="Has admin note">
<IconNote size={16} color="gray" />
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Card>
{/* Detail Modal */}
<Modal
opened={detailOpened}
onClose={closeDetail}
title={<Text fw={600}>Idea Detail</Text>}
size="lg"
>
{selectedIdea && (
<Stack>
<Card withBorder>
<SimpleGrid cols={2} spacing="xs">
<Text size="xs" c="dimmed">Tenant</Text>
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
<Text size="xs" c="dimmed">Submitted By</Text>
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
<Text size="xs" c="dimmed">Date</Text>
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Title</Text>
<Text size="sm">{selectedIdea.title}</Text>
{selectedIdea.description && (
<>
<Text fw={600} mt="md" mb="xs">Description</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
</>
)}
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Status</Text>
<Select
data={statusOptions}
value={selectedIdea.status}
onChange={(val) => {
if (val && val !== selectedIdea.status) {
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
onSuccess: () => {
setSelectedIdea({ ...selectedIdea, status: val });
},
});
}
}}
w={200}
/>
</Card>
<Card withBorder>
<Group justify="space-between" mb="xs">
<Text fw={600}>Private Admin Note</Text>
<Text size="xs" c="dimmed">Only visible to super admins</Text>
</Group>
<Textarea
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
minRows={3}
value={noteText}
onChange={(e) => setNoteText(e.currentTarget.value)}
/>
<Button
size="xs"
variant="light"
mt="xs"
onClick={handleSaveNote}
loading={updateNote.isPending}
disabled={noteText === (selectedIdea.adminNote || '')}
>
Save Note
</Button>
</Card>
</Stack>
)}
</Modal>
</Stack>
);
}

View File

@@ -11,7 +11,7 @@ import {
IconCrown, IconPlus, IconArchive, IconChevronDown,
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
@@ -211,6 +211,16 @@ export function AdminPage() {
},
});
const toggleIdeation = useMutation({
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
},
});
const impersonateUser = useMutation({
mutationFn: async (userId: string) => {
const { data } = await api.post(`/admin/impersonate/${userId}`);
@@ -782,6 +792,27 @@ export function AdminPage() {
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Feature Toggles</Text>
<Group justify="space-between">
<Group gap="xs">
<IconBulb size={16} />
<div>
<Text size="sm">Ideation</Text>
<Text size="xs" c="dimmed">Allow users to submit feature ideas</Text>
</div>
</Group>
<Switch
checked={tenantDetail.organization.settings?.ideationEnabled === true}
onChange={(e) => {
if (selectedOrgId) {
toggleIdeation.mutate({ orgId: selectedOrgId, enabled: e.currentTarget.checked });
}
}}
/>
</Group>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Subscription</Text>
<Stack gap="xs">

View File

@@ -0,0 +1,780 @@
import { useState, useEffect } from 'react';
import {
Title, Text, Card, SimpleGrid, Group, Stack, Badge, Loader, Center,
Tabs, TextInput, Button, PasswordInput, Select, Table, Accordion,
Switch, Paper, RingProgress, Divider, Alert, Code, ScrollArea, Box,
Tooltip, ActionIcon,
} from '@mantine/core';
import {
IconScale, IconSettings, IconPlayerPlay, IconHistory,
IconCheck, IconX, IconAlertTriangle, IconClock, IconTrash,
IconRefresh, IconArrowRight, IconChevronDown,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
// ── Interfaces ──
interface ShadowModel {
id: string;
slot: string;
name: string;
api_url: string;
api_key: string;
model_name: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
interface ShadowRunResult {
id: string;
run_id: string;
model_role: string;
model_name: string;
api_url: string;
raw_response: string;
parsed_response: any;
response_time_ms: number;
token_usage: any;
status: string;
error_message: string;
created_at: string;
}
interface ShadowRun {
id: string;
tenant_id: string;
tenant_name: string;
feature: string;
status: string;
prompt_messages: any;
started_at: string;
completed_at: string;
created_at: string;
results: ShadowRunResult[];
result_count?: string;
success_count?: string;
}
interface AdminOrg {
id: string;
name: string;
status: string;
}
// ── Helper Functions ──
const featureLabels: Record<string, string> = {
operating_health: 'Operating Health',
reserve_health: 'Reserve Health',
investment_recommendations: 'Investment Recommendations',
};
const roleLabels: Record<string, string> = {
production: 'Production',
alternate_a: 'Alternate A',
alternate_b: 'Alternate B',
};
const statusColor: Record<string, string> = {
running: 'blue',
completed: 'green',
partial: 'yellow',
failed: 'red',
pending: 'gray',
success: 'green',
error: 'red',
};
function formatDuration(ms: number | null): string {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatDate(d: string): string {
if (!d) return '-';
return new Date(d).toLocaleString();
}
// ── Model Configuration Tab ──
function ModelConfigTab() {
const queryClient = useQueryClient();
const { data: models, isLoading } = useQuery<ShadowModel[]>({
queryKey: ['shadow-ai-models'],
queryFn: () => api.get('/admin/shadow-ai/models').then((r) => r.data),
});
const modelA = models?.find((m) => m.slot === 'A');
const modelB = models?.find((m) => m.slot === 'B');
return (
<Stack>
<Text size="sm" c="dimmed">
Configure alternate AI models to benchmark against the production model.
Each model can use any OpenAI-compatible API endpoint.
</Text>
<SimpleGrid cols={{ base: 1, md: 3 }}>
<ProductionModelCard />
<ModelSlotCard slot="A" model={modelA} isLoading={isLoading} />
<ModelSlotCard slot="B" model={modelB} isLoading={isLoading} />
</SimpleGrid>
</Stack>
);
}
function ProductionModelCard() {
return (
<Card withBorder shadow="sm">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>Production Model</Text>
<Badge color="green" variant="light">Active</Badge>
</Group>
<Divider />
<Text size="sm" c="dimmed">Configured via environment variables</Text>
<TextInput label="Model" value="(from AI_MODEL env var)" readOnly disabled size="sm" />
<TextInput label="API URL" value="(from AI_API_URL env var)" readOnly disabled size="sm" />
<Text size="xs" c="dimmed" mt="xs">
Production model settings are managed through server environment
variables and cannot be changed from the UI.
</Text>
</Stack>
</Card>
);
}
function ModelSlotCard({ slot, model, isLoading }: { slot: string; model?: ShadowModel; isLoading: boolean }) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [apiUrl, setApiUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [modelName, setModelName] = useState('');
const [isActive, setIsActive] = useState(true);
useEffect(() => {
if (model) {
setName(model.name);
setApiUrl(model.api_url);
setApiKey(model.api_key);
setModelName(model.model_name);
setIsActive(model.is_active);
}
}, [model]);
const saveMutation = useMutation({
mutationFn: () => api.put(`/admin/shadow-ai/models/${slot}`, { name, apiUrl, apiKey, modelName, isActive }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shadow-ai-models'] }),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/admin/shadow-ai/models/${slot}`),
onSuccess: () => {
setName(''); setApiUrl(''); setApiKey(''); setModelName(''); setIsActive(true);
queryClient.invalidateQueries({ queryKey: ['shadow-ai-models'] });
},
});
if (isLoading) return <Card withBorder shadow="sm"><Center h={200}><Loader size="sm" /></Center></Card>;
return (
<Card withBorder shadow="sm">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>Alternate {slot}</Text>
{model ? (
<Badge color={isActive ? 'blue' : 'gray'} variant="light">
{isActive ? 'Active' : 'Inactive'}
</Badge>
) : (
<Badge color="gray" variant="light">Not configured</Badge>
)}
</Group>
<Divider />
<TextInput label="Display Name" placeholder="e.g. GPT-4o" value={name} onChange={(e) => setName(e.target.value)} size="sm" />
<TextInput label="API URL" placeholder="https://api.openai.com/v1" value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} size="sm" />
<PasswordInput label="API Key" placeholder="sk-..." value={apiKey} onChange={(e) => setApiKey(e.target.value)} size="sm" />
<TextInput label="Model Name" placeholder="gpt-4o" value={modelName} onChange={(e) => setModelName(e.target.value)} size="sm" />
<Switch label="Active" checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} />
<Group>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={!name || !apiUrl || !apiKey || !modelName}
>
Save
</Button>
{model && (
<Button size="sm" color="red" variant="light" onClick={() => deleteMutation.mutate()} loading={deleteMutation.isPending}>
<IconTrash size={16} />
</Button>
)}
</Group>
{saveMutation.isError && <Text size="xs" c="red">Failed to save</Text>}
{saveMutation.isSuccess && <Text size="xs" c="green">Saved</Text>}
</Stack>
</Card>
);
}
// ── Run Comparison Tab ──
function RunComparisonTab() {
const queryClient = useQueryClient();
const [tenantId, setTenantId] = useState<string | null>(null);
const [feature, setFeature] = useState<string | null>(null);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const { data: orgs } = useQuery<AdminOrg[]>({
queryKey: ['admin-orgs'],
queryFn: () => api.get('/admin/organizations').then((r) => r.data),
});
const triggerMutation = useMutation({
mutationFn: () => api.post('/admin/shadow-ai/runs', { tenantId, feature }),
onSuccess: (res) => {
setActiveRunId(res.data.runId);
},
});
const { data: activeRun } = useQuery<ShadowRun>({
queryKey: ['shadow-ai-run', activeRunId],
queryFn: () => api.get(`/admin/shadow-ai/runs/${activeRunId}`).then((r) => r.data),
enabled: !!activeRunId,
refetchInterval: (query) => {
const run = query.state.data;
return run?.status === 'running' ? 3000 : false;
},
});
const orgOptions = (orgs || [])
.filter((o) => o.status === 'active')
.map((o) => ({ value: o.id, label: o.name }));
const featureOptions = [
{ value: 'operating_health', label: 'Operating Health Score' },
{ value: 'reserve_health', label: 'Reserve Health Score' },
{ value: 'investment_recommendations', label: 'Investment Recommendations' },
];
return (
<Stack>
<Card withBorder shadow="sm">
<Stack gap="md">
<Text fw={600}>Run Shadow Comparison</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Select
label="Tenant"
placeholder="Select a tenant"
data={orgOptions}
value={tenantId}
onChange={setTenantId}
searchable
/>
<Select
label="AI Feature"
placeholder="Select feature"
data={featureOptions}
value={feature}
onChange={setFeature}
/>
<Stack justify="flex-end">
<Button
leftSection={<IconPlayerPlay size={16} />}
onClick={() => triggerMutation.mutate()}
loading={triggerMutation.isPending}
disabled={!tenantId || !feature}
>
Run Comparison
</Button>
</Stack>
</SimpleGrid>
{triggerMutation.isError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
Failed to start comparison. Ensure at least one alternate model is configured.
</Alert>
)}
</Stack>
</Card>
{activeRun && (
<Card withBorder shadow="sm">
<Stack gap="md">
<Group justify="space-between">
<Group>
<Text fw={600}>
{featureLabels[activeRun.feature] || activeRun.feature}
</Text>
<Badge color={statusColor[activeRun.status]}>{activeRun.status}</Badge>
</Group>
{activeRun.tenant_name && (
<Text size="sm" c="dimmed">Tenant: {activeRun.tenant_name}</Text>
)}
</Group>
{activeRun.status === 'running' && (
<Center py="md">
<Stack align="center" gap="xs">
<Loader size="md" />
<Text size="sm" c="dimmed">Running models... This may take a few minutes.</Text>
<Group gap="xs">
{(activeRun.results || []).map((r) => (
<Badge key={r.model_role} color={statusColor[r.status]} variant="light">
{roleLabels[r.model_role]}: {r.status}
</Badge>
))}
</Group>
</Stack>
</Center>
)}
{activeRun.status !== 'running' && activeRun.results && (
<ComparisonResults results={activeRun.results} feature={activeRun.feature} />
)}
</Stack>
</Card>
)}
</Stack>
);
}
// ── History Tab ──
function HistoryTab() {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [tenantFilter, setTenantFilter] = useState<string | null>(null);
const [featureFilter, setFeatureFilter] = useState<string | null>(null);
const { data: orgs } = useQuery<AdminOrg[]>({
queryKey: ['admin-orgs'],
queryFn: () => api.get('/admin/organizations').then((r) => r.data),
});
const { data: historyData, isLoading } = useQuery({
queryKey: ['shadow-ai-runs', tenantFilter, featureFilter],
queryFn: () => {
const params = new URLSearchParams();
if (tenantFilter) params.set('tenantId', tenantFilter);
if (featureFilter) params.set('feature', featureFilter);
params.set('limit', '50');
return api.get(`/admin/shadow-ai/runs?${params}`).then((r) => r.data);
},
});
const { data: selectedRun } = useQuery<ShadowRun>({
queryKey: ['shadow-ai-run', selectedRunId],
queryFn: () => api.get(`/admin/shadow-ai/runs/${selectedRunId}`).then((r) => r.data),
enabled: !!selectedRunId,
});
const orgOptions = [
{ value: '', label: 'All Tenants' },
...(orgs || []).map((o) => ({ value: o.id, label: o.name })),
];
const featureOptions = [
{ value: '', label: 'All Features' },
{ value: 'operating_health', label: 'Operating Health' },
{ value: 'reserve_health', label: 'Reserve Health' },
{ value: 'investment_recommendations', label: 'Investment Recommendations' },
];
const runs: ShadowRun[] = historyData?.runs || [];
return (
<Stack>
<Group>
<Select
size="sm"
placeholder="Filter by tenant"
data={orgOptions}
value={tenantFilter || ''}
onChange={(v) => setTenantFilter(v || null)}
clearable
w={200}
/>
<Select
size="sm"
placeholder="Filter by feature"
data={featureOptions}
value={featureFilter || ''}
onChange={(v) => setFeatureFilter(v || null)}
clearable
w={200}
/>
</Group>
{isLoading ? (
<Center py="xl"><Loader /></Center>
) : runs.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No shadow runs found.</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th>
<Table.Th>Tenant</Table.Th>
<Table.Th>Feature</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Models</Table.Th>
<Table.Th>Duration</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map((run) => {
const duration = run.completed_at && run.started_at
? new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()
: null;
return (
<Table.Tr
key={run.id}
style={{ cursor: 'pointer' }}
onClick={() => setSelectedRunId(run.id)}
bg={selectedRunId === run.id ? 'var(--mantine-color-blue-light)' : undefined}
>
<Table.Td>{formatDate(run.created_at)}</Table.Td>
<Table.Td>{run.tenant_name || '-'}</Table.Td>
<Table.Td>{featureLabels[run.feature] || run.feature}</Table.Td>
<Table.Td><Badge color={statusColor[run.status]} size="sm">{run.status}</Badge></Table.Td>
<Table.Td>{run.success_count || '0'}/{run.result_count || '0'}</Table.Td>
<Table.Td>{formatDuration(duration)}</Table.Td>
<Table.Td><IconArrowRight size={14} /></Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
)}
{selectedRun && selectedRun.results && (
<Card withBorder shadow="sm" mt="md">
<Stack gap="md">
<Group justify="space-between">
<Group>
<Text fw={600}>{featureLabels[selectedRun.feature] || selectedRun.feature}</Text>
<Badge color={statusColor[selectedRun.status]}>{selectedRun.status}</Badge>
</Group>
<Text size="sm" c="dimmed">
{selectedRun.tenant_name} | {formatDate(selectedRun.created_at)}
</Text>
</Group>
<ComparisonResults results={selectedRun.results} feature={selectedRun.feature} />
</Stack>
</Card>
)}
</Stack>
);
}
// ── Comparison Results Component ──
function ComparisonResults({ results, feature }: { results: ShadowRunResult[]; feature: string }) {
const isHealthScore = feature === 'operating_health' || feature === 'reserve_health';
// Collect all parsed values for diff highlighting
const parsedValues = results
.filter((r) => r.status === 'success' && r.parsed_response)
.map((r) => r.parsed_response);
return (
<SimpleGrid cols={{ base: 1, md: Math.min(results.length, 3) }}>
{results.map((result) => (
<ResultCard
key={result.model_role}
result={result}
isHealthScore={isHealthScore}
allParsed={parsedValues}
/>
))}
</SimpleGrid>
);
}
function ResultCard({
result,
isHealthScore,
allParsed,
}: {
result: ShadowRunResult;
isHealthScore: boolean;
allParsed: any[];
}) {
const roleColor: Record<string, string> = {
production: 'green',
alternate_a: 'blue',
alternate_b: 'violet',
};
return (
<Card withBorder shadow="xs" padding="md">
<Stack gap="sm">
<Group justify="space-between">
<Group gap="xs">
<Badge color={roleColor[result.model_role] || 'gray'} variant="filled">
{roleLabels[result.model_role]}
</Badge>
</Group>
<Badge
color={statusColor[result.status]}
variant="light"
leftSection={result.status === 'success' ? <IconCheck size={12} /> : result.status === 'error' ? <IconX size={12} /> : <IconClock size={12} />}
>
{result.status}
</Badge>
</Group>
<Text size="xs" c="dimmed" truncate>{result.model_name}</Text>
{result.response_time_ms && (
<Badge color="gray" variant="light" size="sm">
{formatDuration(result.response_time_ms)}
</Badge>
)}
{result.token_usage && (
<Text size="xs" c="dimmed">
Tokens: {result.token_usage.prompt_tokens || '?'} prompt / {result.token_usage.completion_tokens || '?'} completion
</Text>
)}
<Divider />
{result.status === 'error' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{result.error_message || 'Unknown error'}</Text>
</Alert>
)}
{result.status === 'success' && result.parsed_response && (
isHealthScore
? <HealthScoreDisplay data={result.parsed_response} allParsed={allParsed} />
: <InvestmentDisplay data={result.parsed_response} allParsed={allParsed} />
)}
{result.status === 'success' && (
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>
<Text size="xs">Raw JSON Response</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollArea h={300}>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(result.parsed_response, null, 2)}
</Code>
</ScrollArea>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</Stack>
</Card>
);
}
// ── Health Score Display ──
function HealthScoreDisplay({ data, allParsed }: { data: any; allParsed: any[] }) {
const score = data.score ?? data.raw_text;
const label = data.label || '';
const summary = data.summary || '';
const factors = data.factors || [];
const recommendations = data.recommendations || [];
// Check if score differs from other models
const scores = allParsed.map((p) => p.score).filter((s) => typeof s === 'number');
const scoreDiffers = scores.length > 1 && !scores.every((s) => s === scores[0]);
const labelColor: Record<string, string> = {
Excellent: 'green', Good: 'teal', Fair: 'yellow',
'Needs Attention': 'orange', 'At Risk': 'red', Critical: 'red',
};
return (
<Stack gap="sm">
{typeof score === 'number' && (
<Group justify="center">
<Box bg={scoreDiffers ? 'yellow.0' : undefined} p="xs" style={{ borderRadius: 8 }}>
<RingProgress
size={100}
thickness={10}
roundCaps
sections={[{ value: score, color: labelColor[label] || 'blue' }]}
label={
<Text ta="center" fw={700} size="lg">{score}</Text>
}
/>
</Box>
</Group>
)}
{label && (
<Group justify="center">
<Badge color={labelColor[label] || 'gray'} size="lg">{label}</Badge>
</Group>
)}
{summary && <Text size="sm">{summary}</Text>}
{factors.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Factors</Text>
{factors.map((f: any, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<Badge
size="xs"
variant="light"
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
>
{f.impact}
</Badge>
<Text size="xs" style={{ flex: 1 }}><b>{f.name}:</b> {f.detail}</Text>
</Group>
))}
</>
)}
{recommendations.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Recommendations</Text>
{recommendations.map((r: any, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<Badge
size="xs"
variant="light"
color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'}
>
{r.priority}
</Badge>
<Text size="xs" style={{ flex: 1 }}>{r.text}</Text>
</Group>
))}
</>
)}
</Stack>
);
}
// ── Investment Display ──
function InvestmentDisplay({ data, allParsed }: { data: any; allParsed: any[] }) {
const recommendations = data.recommendations || [];
const overall = data.overall_assessment || '';
const riskNotes = data.risk_notes || [];
const recCounts = allParsed.map((p) => (p.recommendations || []).length);
const countDiffers = recCounts.length > 1 && !recCounts.every((c) => c === recCounts[0]);
const typeColors: Record<string, string> = {
cd_ladder: 'violet', new_investment: 'blue', reallocation: 'teal',
maturity_action: 'orange', liquidity_warning: 'red', general: 'gray',
};
return (
<Stack gap="sm">
{overall && (
<Paper p="xs" bg="gray.0" radius="sm">
<Text size="sm">{overall}</Text>
</Paper>
)}
{recommendations.length > 0 && (
<>
<Group gap="xs">
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
Recommendations
</Text>
<Badge
size="xs"
variant="light"
color={countDiffers ? 'yellow' : 'gray'}
>
{recommendations.length}
</Badge>
</Group>
{recommendations.map((rec: any, i: number) => (
<Card key={i} withBorder padding="xs" radius="sm">
<Stack gap={4}>
<Group gap="xs">
<Badge size="xs" color={typeColors[rec.type] || 'gray'}>{rec.type}</Badge>
<Badge size="xs" variant="light" color={rec.priority === 'high' ? 'red' : rec.priority === 'medium' ? 'yellow' : 'blue'}>
{rec.priority}
</Badge>
{rec.fund_type && <Badge size="xs" variant="outline">{rec.fund_type}</Badge>}
</Group>
<Text size="sm" fw={600}>{rec.title}</Text>
<Text size="xs">{rec.summary}</Text>
{rec.suggested_amount && (
<Text size="xs" c="dimmed">
Amount: ${rec.suggested_amount.toLocaleString()}
{rec.suggested_rate ? ` | Rate: ${rec.suggested_rate}%` : ''}
{rec.suggested_term ? ` | Term: ${rec.suggested_term}` : ''}
</Text>
)}
</Stack>
</Card>
))}
</>
)}
{riskNotes.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Risk Notes</Text>
{riskNotes.map((note: string, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<IconAlertTriangle size={14} color="orange" />
<Text size="xs">{note}</Text>
</Group>
))}
</>
)}
</Stack>
);
}
// ── Main Page ──
export function AdminShadowAiPage() {
return (
<Stack gap="lg" p="md">
<Group>
<IconScale size={28} />
<Title order={2}>AI Benchmarking</Title>
</Group>
<Text c="dimmed" size="sm">
Compare AI model outputs side-by-side using real tenant data.
Configure alternate models, run shadow comparisons, and review historical results.
</Text>
<Tabs defaultValue="run">
<Tabs.List>
<Tabs.Tab value="config" leftSection={<IconSettings size={16} />}>
Model Configuration
</Tabs.Tab>
<Tabs.Tab value="run" leftSection={<IconPlayerPlay size={16} />}>
Run Comparison
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<IconHistory size={16} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="config" pt="md">
<ModelConfigTab />
</Tabs.Panel>
<Tabs.Panel value="run" pt="md">
<RunComparisonTab />
</Tabs.Panel>
<Tabs.Panel value="history" pt="md">
<HistoryTab />
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

@@ -1,13 +1,13 @@
import { useState, useEffect, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card, Alert, Modal,
Select, Loader, Center, Badge, Card, Alert, Modal, ThemeIcon,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
IconCheck, IconArrowBack, IconTrash, IconRefresh,
IconUpload, IconDownload,
IconUpload, IconDownload, IconFileSpreadsheet,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -659,7 +659,37 @@ export function BudgetPlanningPage() {
{lineData.length === 0 && (
<Table.Tr>
<Table.Td colSpan={15}>
<Text ta="center" c="dimmed" py="lg">No budget plan lines.</Text>
<Card withBorder p="xl" mx="auto" maw={600} my="lg" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="light" color="blue" mx="auto" mb="md">
<IconFileSpreadsheet size={28} />
</ThemeIcon>
<Title order={4} mb="xs">Get Started with Your {selectedYear} Budget</Title>
<Text c="dimmed" size="sm" mb="lg" maw={450} mx="auto">
Your budget plan is created but has no line items yet. Download the
CSV template pre-filled with your chart of accounts, fill in your
monthly amounts, then upload it here.
</Text>
<Group justify="center" gap="md">
<Button
variant="light"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download Budget Template
</Button>
<Button
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Upload Budget CSV
</Button>
</Group>
<Text size="xs" c="dimmed" mt="md">
Tip: The template includes all your active accounts. Fill in the monthly
dollar amounts for each line, save as CSV, then upload.
</Text>
</Card>
</Table.Td>
</Table.Tr>
)}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
@@ -40,7 +40,7 @@ export function InvestmentScenarioDetailPage() {
},
});
const { data: projection, isLoading: projLoading } = useQuery({
const { data: projection, isLoading: projLoading, dataUpdatedAt: projUpdatedAt } = useQuery({
queryKey: ['board-planning-projection', id],
queryFn: async () => {
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
@@ -49,6 +49,17 @@ export function InvestmentScenarioDetailPage() {
enabled: !!id,
});
// When projection refreshes (which may create auto-renew records on the backend),
// re-fetch the scenario so the investments list picks up any new renewal records.
const [lastProjUpdate, setLastProjUpdate] = useState(0);
if (projUpdatedAt && projUpdatedAt !== lastProjUpdate) {
setLastProjUpdate(projUpdatedAt);
if (lastProjUpdate > 0) {
// Only re-fetch after a real update (not the initial load)
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
}
}
const addMutation = useMutation({
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
onSuccess: () => {
@@ -100,12 +111,40 @@ export function InvestmentScenarioDetailPage() {
},
});
// Compute shared time range for aligned charts (must be above early returns to satisfy Rules of Hooks)
const investments = scenario?.investments || [];
const summary = projection?.summary;
const { sharedStartDate, sharedEndDate } = useMemo(() => {
const allDates: Date[] = [];
// Dates from investments
for (const inv of investments) {
if (inv.purchase_date) allDates.push(new Date(inv.purchase_date));
if (inv.maturity_date) allDates.push(new Date(inv.maturity_date));
}
// Dates from projection datapoints
const dps = projection?.datapoints || [];
if (dps.length > 0) {
allDates.push(new Date(dps[0].year, dps[0].monthNum - 1, 1));
const last = dps[dps.length - 1];
allDates.push(new Date(last.year, last.monthNum - 1, 1));
}
if (allDates.length === 0) return { sharedStartDate: undefined, sharedEndDate: undefined };
const min = new Date(Math.min(...allDates.map((d) => d.getTime())));
const max = new Date(Math.max(...allDates.map((d) => d.getTime())));
return {
sharedStartDate: new Date(min.getFullYear(), min.getMonth(), 1),
sharedEndDate: new Date(max.getFullYear(), max.getMonth(), 1),
};
}, [investments, projection]);
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
const investments = scenario.investments || [];
const summary = projection?.summary;
// Build a lookup of per-investment interest from the projection
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
if (summary?.investment_interest_details) {
@@ -259,7 +298,13 @@ export function InvestmentScenarioDetailPage() {
</Card>
{/* Investment Timeline */}
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
{investments.length > 0 && (
<InvestmentTimeline
investments={investments}
sharedStartDate={sharedStartDate}
sharedEndDate={sharedEndDate}
/>
)}
{/* Projection Chart */}
{projection && (
@@ -267,6 +312,8 @@ export function InvestmentScenarioDetailPage() {
datapoints={projection.datapoints || []}
title="Scenario Projection"
summary={projection.summary}
sharedStartDate={sharedStartDate}
sharedEndDate={sharedEndDate}
/>
)}
{projLoading && <Center py="xl"><Loader /></Center>}

View File

@@ -13,9 +13,12 @@ const typeColors: Record<string, string> = {
interface Props {
investments: any[];
/** Optional shared time range to align with ProjectionChart */
sharedStartDate?: Date;
sharedEndDate?: Date;
}
export function InvestmentTimeline({ investments }: Props) {
export function InvestmentTimeline({ investments, sharedStartDate, sharedEndDate }: Props) {
const { items, startDate, endDate, totalMonths } = useMemo(() => {
const now = new Date();
const items = investments
@@ -28,16 +31,24 @@ export function InvestmentTimeline({ investments }: Props) {
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
// Use shared range if provided (to align with ProjectionChart), otherwise compute from investments
let startDate: Date;
let endDate: Date;
if (sharedStartDate && sharedEndDate) {
startDate = sharedStartDate;
endDate = sharedEndDate;
} else {
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
}
const totalMonths = Math.max(
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
1,
);
return { items, startDate, endDate, totalMonths };
}, [investments]);
}, [investments, sharedStartDate, sharedEndDate]);
if (!items.length) return null;

View File

@@ -23,18 +23,31 @@ interface Props {
datapoints: Datapoint[];
title?: string;
summary?: any;
/** Optional shared time range to align with InvestmentTimeline */
sharedStartDate?: Date;
sharedEndDate?: Date;
}
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary, sharedStartDate, sharedEndDate }: Props) {
const [fundFilter, setFundFilter] = useState('all');
const chartData = useMemo(() => {
return datapoints.map((d) => ({
let filtered = datapoints;
// If shared range provided, filter datapoints to match
if (sharedStartDate && sharedEndDate) {
const startKey = sharedStartDate.getFullYear() * 12 + sharedStartDate.getMonth();
const endKey = sharedEndDate.getFullYear() * 12 + sharedEndDate.getMonth();
filtered = datapoints.filter((d) => {
const dpKey = d.year * 12 + (d.monthNum - 1);
return dpKey >= startKey && dpKey <= endKey;
});
}
return filtered.map((d) => ({
...d,
label: `${d.month}`,
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
}));
}, [datapoints]);
}, [datapoints, sharedStartDate, sharedEndDate]);
// Find first forecast month for reference line
const forecastStart = chartData.findIndex((d) => d.is_forecast);
@@ -89,20 +102,20 @@ export function ProjectionChart({ datapoints, title = 'Financial Projection', su
<AreaChart data={chartData}>
<defs>
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
<stop offset="5%" stopColor="#228be6" stopOpacity={0.6} />
<stop offset="95%" stopColor="#228be6" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />

View File

@@ -72,9 +72,10 @@ interface KanbanCardProps {
project: Project;
onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
isReadOnly?: boolean;
}
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: 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();
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
padding="sm"
radius="md"
withBorder
draggable
onDragStart={(e) => onDragStart(e, project)}
style={{ cursor: 'grab', userSelect: 'none' }}
draggable={!isReadOnly}
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
mb="xs"
>
<Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
{!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
<Text fw={600} size="sm" truncate>
{project.name}
</Text>
</Group>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
{!isReadOnly && (
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
)}
</Group>
<Group gap={6} mb={6}>
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void;
isReadOnly?: boolean;
}
function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave,
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
}: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR;
@@ -178,9 +182,9 @@ function KanbanColumn({
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease',
}}
onDragOver={(e) => onDragOverHandler(e, year)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, year)}
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
onDragLeave={!isReadOnly ? onDragLeave : undefined}
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
>
<Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title>
@@ -199,7 +203,7 @@ function KanbanColumn({
<Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here
{isReadOnly ? 'No projects' : 'Drop projects here'}
</Text>
) : useWideLayout ? (
<div style={{
@@ -208,12 +212,12 @@ function KanbanColumn({
gap: 'var(--mantine-spacing-xs)',
}}>
{projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))}
</div>
) : (
projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))
)}
</Box>
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave}
isReadOnly={isReadOnly}
/>
);
})}

View File

@@ -1,10 +1,9 @@
import { useState, useMemo } from 'react';
import {
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon,
Title, Text, Stack, Card, Group,
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
} from '@mantine/core';
import {
IconCash, IconBuildingBank, IconChartAreaLine,
IconArrowLeft, IconArrowRight, IconCalendar,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
@@ -108,30 +107,6 @@ export function CashFlowForecastPage() {
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
}, [datapoints, viewStartIndex]);
// Compute summary stats for the current view
const summaryStats = useMemo(() => {
if (!viewData.length) return null;
const last = viewData[viewData.length - 1];
const first = viewData[0];
const totalOperating = last.operating_cash + last.operating_investments;
const totalReserve = last.reserve_cash + last.reserve_investments;
const totalAll = totalOperating + totalReserve;
const firstTotal = first.operating_cash + first.operating_investments +
first.reserve_cash + first.reserve_investments;
const netChange = totalAll - firstTotal;
return {
totalOperating,
totalReserve,
totalAll,
netChange,
periodStart: first.month,
periodEnd: last.month,
};
}, [viewData]);
// Determine the first forecast month index within the view
const forecastStartLabel = useMemo(() => {
const idx = viewData.findIndex((d) => d.is_forecast);
@@ -181,65 +156,6 @@ export function CashFlowForecastPage() {
/>
</Group>
{/* Summary Cards */}
{summaryStats && (
<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 Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalOperating)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="violet" size="sm">
<IconBuildingBank size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalReserve)}
</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}>Combined Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalAll)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon
variant="light"
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
size="sm"
>
<IconCash size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
</Group>
<Text
fw={700}
size="xl"
ff="monospace"
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
>
{fmt(summaryStats.netChange)}
</Text>
</Card>
</SimpleGrid>
)}
{/* Chart Navigation */}
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
@@ -287,20 +203,20 @@ export function CashFlowForecastPage() {
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} />
<stop offset="5%" stopColor="#339af0" stopOpacity={0.6} />
<stop offset="95%" stopColor="#339af0" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} />
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} />
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} />
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />

View File

@@ -1,7 +1,7 @@
import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
Popover, List,
Popover, List, Anchor,
} from '@mantine/core';
import {
IconCash,
@@ -15,10 +15,13 @@ import {
IconHeartbeat,
IconRefresh,
IconInfoCircle,
IconCoin,
IconCalendarEvent,
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import { useNavigate } from 'react-router-dom';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api';
interface HealthScore {
@@ -58,6 +61,28 @@ function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
return null;
}
// Map missing data items to navigation links
const missingDataLinks: Record<string, { label: string; path: string }> = {
'reserve fund account': { label: 'Set up a reserve account', path: '/accounts' },
'reserve account': { label: 'Set up a reserve account', path: '/accounts' },
'reserve projects': { label: 'Add reserve projects', path: '/projects' },
'capital projects': { label: 'Add capital projects', path: '/projects' },
'projects': { label: 'Add projects', path: '/projects' },
'budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'operating budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'reserve budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'assessment groups': { label: 'Create assessment groups', path: '/assessment-groups' },
'accounts': { label: 'Set up accounts', path: '/accounts' },
};
function getMissingDataLink(item: string): { label: string; path: string } | null {
const lower = item.toLowerCase();
for (const [key, value] of Object.entries(missingDataLinks)) {
if (lower.includes(key)) return value;
}
return null;
}
function HealthScoreCard({
score,
title,
@@ -65,6 +90,7 @@ function HealthScoreCard({
isRefreshing,
onRefresh,
lastFailed,
onNavigate,
}: {
score: HealthScore | null;
title: string;
@@ -72,6 +98,7 @@ function HealthScoreCard({
isRefreshing?: boolean;
onRefresh?: () => void;
lastFailed?: boolean;
onNavigate?: (path: string) => void;
}) {
// No score at all yet
if (!score) {
@@ -118,9 +145,19 @@ function HealthScoreCard({
<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>
))}
{missingItems.map((item: string, i: number) => {
const link = getMissingDataLink(item);
return link ? (
<Anchor key={i} size="xs" href={link.path} onClick={(e: React.MouseEvent) => {
e.preventDefault();
onNavigate?.(link.path);
}}>
{item} &rarr; {link.label}
</Anchor>
) : (
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
);
})}
</Stack>
</Center>
</Card>
@@ -313,7 +350,9 @@ interface DashboardData {
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient();
const navigate = useNavigate();
// Track whether a refresh is in progress (per score type) for async polling
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
@@ -325,6 +364,16 @@ export function DashboardPage() {
enabled: !!currentOrg,
});
const { data: investmentActivities } = useQuery<{
maturing_investments: any[];
upcoming_scenario_investments: any[];
total_activities: number;
}>({
queryKey: ['upcoming-investment-activities'],
queryFn: async () => { const { data } = await api.get('/reports/upcoming-investment-activities'); return data; },
enabled: !!currentOrg,
});
const { data: healthScores } = useQuery<HealthScoresData>({
queryKey: ['health-scores'],
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
@@ -426,8 +475,9 @@ export function DashboardPage() {
</ThemeIcon>
}
isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating}
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed}
onNavigate={navigate}
/>
<HealthScoreCard
score={healthScores?.reserve || null}
@@ -438,8 +488,9 @@ export function DashboardPage() {
</ThemeIcon>
}
isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve}
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed}
onNavigate={navigate}
/>
</SimpleGrid>
@@ -492,6 +543,97 @@ export function DashboardPage() {
</Card>
</SimpleGrid>
{/* Upcoming Investment Activities */}
{(investmentActivities?.total_activities || 0) > 0 && (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="sm">
<Group gap="xs">
<ThemeIcon color="teal" variant="light" size={28} radius="md">
<IconCalendarEvent size={16} />
</ThemeIcon>
<Title order={4}>Upcoming Investment Activities</Title>
</Group>
<Badge variant="light" color="teal">{investmentActivities?.total_activities} upcoming</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Activity</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Timeline</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(investmentActivities?.maturing_investments || []).map((inv: any) => (
<Table.Tr key={`mat-${inv.id}`}>
<Table.Td>
<Group gap={6}>
<IconCoin size={14} color="var(--mantine-color-orange-6)" />
<Text size="sm" fw={500}>{inv.name}</Text>
</Group>
{inv.institution && <Text size="xs" c="dimmed">{inv.institution}</Text>}
</Table.Td>
<Table.Td>
<Badge size="xs" color="orange" variant="light">Maturing</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={500}>{fmt(inv.maturity_value)}</Text>
<Text size="xs" c="green">+{fmt(inv.interest_earned)} interest</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color={inv.days_remaining <= 14 ? 'red' : inv.days_remaining <= 30 ? 'yellow' : 'gray'} variant="light">
{inv.days_remaining} days
</Badge>
</Table.Td>
</Table.Tr>
))}
{(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => (
<Table.Tr key={`plan-${si.id}`}>
<Table.Td>
<Group gap={6}>
<IconTrendingUp size={14} color="var(--mantine-color-blue-6)" />
<Text size="sm" fw={500}>{si.label}</Text>
</Group>
<Text size="xs" c="dimmed">Scenario: {si.scenario_name}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" color="blue" variant="light">Planned Purchase</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" color={si.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
{si.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={500}>{fmt(si.principal)}</Text>
{si.interest_rate && <Text size="xs" c="dimmed">{parseFloat(si.interest_rate).toFixed(2)}% APY</Text>}
</Table.Td>
<Table.Td>
<Text size="sm">{new Date(si.purchase_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color={si.days_until <= 14 ? 'red' : si.days_until <= 30 ? 'yellow' : 'gray'} variant="light">
{si.days_until} days
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Card withBorder padding="lg" radius="md">
<Title order={4}>Quick Stats</Title>

View File

@@ -43,6 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ──
@@ -384,6 +385,7 @@ export function InvestmentPlanningPage() {
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
const isReadOnly = useIsReadOnly();
// Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({
@@ -557,6 +559,32 @@ export function InvestmentPlanningPage() {
}
}, []);
// Auto-refresh: if no recommendations exist or they are older than 30 days, trigger automatically
const autoRefreshTriggered = useRef(false);
useEffect(() => {
if (autoRefreshTriggered.current || isProcessing || isTriggering || isReadOnly) return;
if (savedRec === undefined) return; // still loading
const shouldAutoRefresh = (() => {
// No saved recommendation at all
if (!savedRec) return true;
// Error state with no cached data
if (savedRec.status === 'error' && (!savedRec.recommendations || savedRec.recommendations.length === 0)) return true;
// Recommendations older than 30 days
if (savedRec.created_at) {
const age = Date.now() - new Date(savedRec.created_at).getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (age > thirtyDays) return true;
}
return false;
})();
if (shouldAutoRefresh) {
autoRefreshTriggered.current = true;
handleTriggerAI();
}
}, [savedRec, isProcessing, isTriggering, isReadOnly, handleTriggerAI]);
// Build AI result from saved recommendation for display
const aiResult: AIResponse | null = hasResults
? {
@@ -821,15 +849,17 @@ export function InvestmentPlanningPage() {
</Text>
</div>
</Group>
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
{!isReadOnly && (
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
)}
</Group>
{/* Processing State - shown as banner when refreshing with existing results */}

View File

@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
@@ -64,6 +65,7 @@ export function InvoicesPage() {
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
@@ -124,10 +126,12 @@ export function InvoicesPage() {
<Stack>
<Group justify="space-between">
<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 Invoices</Button>
</Group>
{!isReadOnly && (
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</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>

View File

@@ -214,6 +214,13 @@ export function OrgMembersPage() {
As an organization administrator, you can add board members, property managers, and
viewers to give them access to this tenant. Each member can log in with their own
credentials and see the same financial data.
{currentOrg?.planLevel && !['enterprise'].includes(currentOrg.planLevel) && (
<Text size="sm" mt={6} fw={500}>
Your {currentOrg.planLevel === 'professional' ? 'Professional' : 'Starter'} plan
supports up to 5 user accounts ({activeMembers.length}/5 used).
{activeMembers.length >= 5 && ' Upgrade to Enterprise for unlimited members.'}
</Text>
)}
</Alert>
<Table striped highlightOnHover>

View File

@@ -1,19 +1,21 @@
import { useState } from 'react';
import {
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
Button, List, ThemeIcon, TextInput, Center, Alert,
Button, List, ThemeIcon, TextInput, Center, Alert, SegmentedControl, Box,
} from '@mantine/core';
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import logoSrc from '../../assets/logo.png';
type BillingInterval = 'month' | 'year';
const plans = [
{
id: 'starter',
name: 'Starter',
price: '$29',
period: '/month',
monthlyPrice: 29,
annualPrice: 261, // 29 * 12 * 0.75
description: 'For small communities getting started',
icon: IconRocket,
color: 'blue',
@@ -29,8 +31,8 @@ const plans = [
{
id: 'professional',
name: 'Professional',
price: '$79',
period: '/month',
monthlyPrice: 79,
annualPrice: 711, // 79 * 12 * 0.75
description: 'For growing HOAs that need full features',
icon: IconStar,
color: 'violet',
@@ -47,8 +49,8 @@ const plans = [
{
id: 'enterprise',
name: 'Enterprise',
price: 'Custom',
period: '',
monthlyPrice: 0,
annualPrice: 0,
description: 'For large communities and management firms',
icon: IconCrown,
color: 'orange',
@@ -64,29 +66,53 @@ const plans = [
},
];
function formatPrice(plan: typeof plans[0], interval: BillingInterval) {
if (plan.externalUrl) return { display: 'Custom', sub: '' };
if (interval === 'year') {
const monthly = (plan.annualPrice / 12).toFixed(2);
return {
display: `$${monthly}`,
sub: `/mo billed annually ($${plan.annualPrice}/yr)`,
};
}
return { display: `$${plan.monthlyPrice}`, sub: '/month' };
}
export function PricingPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState('');
const [email, setEmail] = useState('');
const [businessName, setBusinessName] = useState('');
const [billingInterval, setBillingInterval] = useState<BillingInterval>('month');
const handleStartTrial = async (planId: string) => {
if (!email.trim()) {
setError('Email address is required to start a trial');
return;
}
if (!businessName.trim()) {
setError('HOA / Business name is required to start a trial');
return;
}
const handleSelectPlan = async (planId: string) => {
setLoading(planId);
setError('');
try {
const { data } = await api.post('/billing/create-checkout-session', {
const { data } = await api.post('/billing/start-trial', {
planId,
email: email || undefined,
businessName: businessName || undefined,
billingInterval,
email: email.trim(),
businessName: businessName.trim(),
});
if (data.url) {
window.location.href = data.url;
if (data.subscriptionId) {
// Navigate to pending page with subscription ID for polling
navigate(`/onboarding/pending?session_id=${data.subscriptionId}`);
} else {
setError('Unable to create checkout session');
setError('Unable to start trial');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to start checkout');
setError(err.response?.data?.message || 'Failed to start trial');
} finally {
setLoading(null);
}
@@ -104,20 +130,48 @@ export function PricingPage() {
</Text>
</Stack>
{/* Optional pre-capture fields */}
{/* Monthly / Annual Toggle */}
<Center mb="xl">
<Box pos="relative">
<SegmentedControl
value={billingInterval}
onChange={(val) => setBillingInterval(val as BillingInterval)}
data={[
{ label: 'Monthly', value: 'month' },
{ label: 'Annual', value: 'year' },
]}
size="md"
radius="xl"
/>
{billingInterval === 'year' && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: -10, right: -40 }}
>
Save 25%
</Badge>
)}
</Box>
</Center>
{/* Pre-capture fields (required for trial) */}
<Center mb="xl">
<Group>
<TextInput
placeholder="Email address"
placeholder="Email address *"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
style={{ width: 220 }}
required
/>
<TextInput
placeholder="HOA / Business name"
placeholder="HOA / Business name *"
value={businessName}
onChange={(e) => setBusinessName(e.currentTarget.value)}
style={{ width: 220 }}
required
/>
</Group>
</Center>
@@ -129,87 +183,101 @@ export function PricingPage() {
)}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{plans.map((plan) => (
<Card
key={plan.id}
withBorder
shadow={plan.popular ? 'lg' : 'sm'}
radius="md"
p="xl"
style={plan.popular ? {
border: '2px solid var(--mantine-color-violet-5)',
position: 'relative',
} : undefined}
>
{plan.popular && (
<Badge
color="violet"
variant="filled"
style={{ position: 'absolute', top: -10, right: 20 }}
>
Most Popular
</Badge>
)}
{plans.map((plan) => {
const price = formatPrice(plan, billingInterval);
return (
<Card
key={plan.id}
withBorder
shadow={plan.popular ? 'lg' : 'sm'}
radius="md"
p="xl"
style={plan.popular ? {
border: '2px solid var(--mantine-color-violet-5)',
position: 'relative',
} : undefined}
>
{plan.popular && (
<Badge
color="violet"
variant="filled"
style={{ position: 'absolute', top: -10, right: 20 }}
>
Most Popular
</Badge>
)}
<Stack gap="md">
<Group>
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
<plan.icon size={20} />
</ThemeIcon>
<div>
<Text fw={700} size="lg">{plan.name}</Text>
<Text size="xs" c="dimmed">{plan.description}</Text>
</div>
</Group>
<Stack gap="md">
<Group>
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
<plan.icon size={20} />
</ThemeIcon>
<div>
<Text fw={700} size="lg">{plan.name}</Text>
<Text size="xs" c="dimmed">{plan.description}</Text>
<Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
{plan.externalUrl ? 'Request Quote' : price.display}
</Text>
</Group>
{price.sub && (
<Text size="xs" c="dimmed" mt={2}>
{price.sub}
</Text>
)}
{!plan.externalUrl && billingInterval === 'year' && (
<Text size="xs" c="dimmed" td="line-through" mt={2}>
${plan.monthlyPrice}/mo without annual discount
</Text>
)}
</div>
</Group>
<Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
{plan.externalUrl ? 'Request Quote' : plan.price}
</Text>
{plan.period && <Text size="sm" c="dimmed">{plan.period}</Text>}
</Group>
<List spacing="xs" size="sm" center>
{plan.features.map((f, i) => (
<List.Item
key={i}
icon={
<ThemeIcon
size={20}
radius="xl"
color={f.included ? 'teal' : 'gray'}
variant={f.included ? 'filled' : 'light'}
>
{f.included ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
}
>
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text>
</List.Item>
))}
</List>
<List spacing="xs" size="sm" center>
{plan.features.map((f, i) => (
<List.Item
key={i}
icon={
<ThemeIcon
size={20}
radius="xl"
color={f.included ? 'teal' : 'gray'}
variant={f.included ? 'filled' : 'light'}
>
{f.included ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
}
>
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text>
</List.Item>
))}
</List>
<Button
fullWidth
size="md"
color={plan.color}
variant={plan.popular ? 'filled' : 'light'}
loading={!plan.externalUrl ? loading === plan.id : false}
onClick={() =>
plan.externalUrl
? window.open(plan.externalUrl, '_blank', 'noopener')
: handleSelectPlan(plan.id)
}
>
{plan.externalUrl ? 'Request Quote' : 'Get Started'}
</Button>
</Stack>
</Card>
))}
<Button
fullWidth
size="md"
color={plan.color}
variant={plan.popular ? 'filled' : 'light'}
loading={!plan.externalUrl ? loading === plan.id : false}
onClick={() =>
plan.externalUrl
? window.open(plan.externalUrl, '_blank', 'noopener')
: handleStartTrial(plan.id)
}
>
{plan.externalUrl ? 'Request Quote' : 'Start Free Trial'}
</Button>
</Stack>
</Card>
);
})}
</SimpleGrid>
<Text ta="center" size="sm" c="dimmed" mt="xl">
All plans include a 14-day free trial. No credit card required to start.
All plans include a 14-day free trial. No credit card required.
</Text>
</Container>
);

View File

@@ -2,13 +2,13 @@ import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
Card, SimpleGrid, Progress, Switch, Tooltip,
Card, SimpleGrid, Progress, Switch, Tooltip, ThemeIcon, List,
} 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, IconUpload, IconDownload, IconLock, IconLockOpen } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, IconShieldCheck, IconBulb, IconRocket } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
@@ -465,10 +465,55 @@ export function ProjectsPage() {
))}
{projects.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg">
No projects yet
</Text>
<Table.Td colSpan={9} p={0}>
<Card p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'violet', to: 'blue' }} mx="auto" mb="md">
<IconShieldCheck size={32} />
</ThemeIcon>
<Title order={3} mb="xs">Capital Projects & Reserve Planning</Title>
<Text c="dimmed" maw={550} mx="auto" mb="lg">
Track your community&apos;s capital improvement projects, reserve fund allocations,
and long-term maintenance schedule. This is where you build a comprehensive
picture of your HOA&apos;s future capital needs.
</Text>
<Card withBorder p="md" maw={550} mx="auto" mb="lg" ta="left">
<Text fw={600} mb="xs">
<IconBulb size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />
Common HOA Projects to Get Started
</Text>
<List size="sm" spacing="xs" c="dimmed">
<List.Item><Text span fw={500} c="dark">Roof Replacement</Text> Track the remaining useful life and reserve funding for your building&apos;s roof</List.Item>
<List.Item><Text span fw={500} c="dark">Parking Lot / Paving</Text> Plan for periodic seal-coating and resurfacing</List.Item>
<List.Item><Text span fw={500} c="dark">Pool & Recreation</Text> Budget for pool resurfacing, equipment, and amenity upgrades</List.Item>
<List.Item><Text span fw={500} c="dark">Painting & Exterior</Text> Schedule exterior painting cycles (typically every 5-7 years)</List.Item>
<List.Item><Text span fw={500} c="dark">HVAC Systems</Text> Track common-area heating and cooling equipment lifecycles</List.Item>
<List.Item><Text span fw={500} c="dark">Elevator Modernization</Text> Plan for required elevator upgrades and code compliance</List.Item>
</List>
</Card>
<Group justify="center" gap="md">
{!isReadOnly && (
<>
<Button
size="md"
leftSection={<IconRocket size={18} />}
variant="gradient"
gradient={{ from: 'violet', to: 'blue' }}
onClick={handleNew}
>
Create Your First Project
</Button>
<Button
size="md"
variant="light"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
>
Import from CSV
</Button>
</>
)}
</Group>
</Card>
</Table.Td>
</Table.Tr>
)}

View File

@@ -0,0 +1,196 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
Button, NumberInput,
} from '@mantine/core';
import { IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
interface ProjectItem {
id: string;
name: string;
description: string;
category: string;
estimated_cost: number;
target_year: number | null;
useful_life_years: number | null;
last_replacement_date: string | null;
fund_source: string;
status: string;
priority: number;
condition_rating: number | null;
year_amounts: Record<number, number>;
beyond: number;
}
interface CategoryGroup {
category: string;
projects: ProjectItem[];
}
interface CapitalPlanningData {
title: string;
start_year: number;
years: number[];
categories: CategoryGroup[];
year_totals: Record<number, number>;
beyond_total: number;
grand_total: number;
generated_at: string;
}
const fmt = (v: number) =>
v === 0 ? '-' : v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
export function CapitalPlanningPage() {
const [startYear, setStartYear] = useState(new Date().getFullYear());
const { data, isLoading } = useQuery<CapitalPlanningData>({
queryKey: ['capital-planning', startYear],
queryFn: async () => {
const { data } = await api.get(`/reports/capital-planning?startYear=${startYear}`);
return data;
},
});
if (isLoading) return <Center h={300}><Loader /></Center>;
const years = data?.years || [];
const hasProjects = (data?.categories || []).some((c) => c.projects.length > 0);
return (
<Stack>
<Group justify="space-between">
<div>
<Title order={2}>Capital Planning Report</Title>
<Text c="dimmed" size="sm">{data?.title || '5-Year Capital Project Forecast'}</Text>
</div>
<Group>
<NumberInput
size="xs"
w={100}
value={startYear}
onChange={(v) => v && setStartYear(Number(v))}
min={2020}
max={2050}
/>
<Button
variant="light"
leftSection={<IconPrinter size={16} />}
onClick={() => window.print()}
>
Print / PDF
</Button>
</Group>
</Group>
{!hasProjects ? (
<Card withBorder p="xl">
<Text ta="center" c="dimmed" py="lg">
No capital projects found. Add projects on the Projects page to generate this report.
</Text>
</Card>
) : (
<Card withBorder p="lg" className="capital-planning-print">
<Title order={3} ta="center" mb="xs">{data?.title}</Title>
<Text ta="center" c="dimmed" size="sm" mb="md">
Generated {new Date(data?.generated_at || '').toLocaleDateString()}
</Text>
<Table striped withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th ta="center" w={60}>Life (yr)</Table.Th>
<Table.Th ta="center" w={90}>Last Done</Table.Th>
{years.map((y) => (
<Table.Th key={y} ta="right" w={100}>{y}</Table.Th>
))}
<Table.Th ta="right" w={100}>Beyond</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(data?.categories || []).map((cat) => {
const catTotals: Record<number, number> = {};
let catBeyond = 0;
for (const y of years) catTotals[y] = 0;
for (const p of cat.projects) {
for (const y of years) catTotals[y] += p.year_amounts[y] || 0;
catBeyond += p.beyond;
}
return [
<Table.Tr key={`cat-${cat.category}`} style={{ background: 'var(--mantine-color-blue-0)' }}>
<Table.Td colSpan={3 + years.length + 1}>
<Text fw={700} size="sm">{cat.category}</Text>
</Table.Td>
</Table.Tr>,
...cat.projects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td>
<Text size="sm">{p.name}</Text>
{p.status !== 'planned' && (
<Badge size="xs" variant="light" ml={4}
color={p.status === 'completed' ? 'green' : p.status === 'in_progress' ? 'blue' : 'gray'}>
{p.status}
</Badge>
)}
</Table.Td>
<Table.Td ta="center">
<Text size="sm">{p.useful_life_years || '-'}</Text>
</Table.Td>
<Table.Td ta="center">
<Text size="sm">
{p.last_replacement_date
? new Date(p.last_replacement_date).getFullYear()
: '-'}
</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text size="sm">{fmt(p.year_amounts[y] || 0)}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text size="sm">{fmt(p.beyond)}</Text>
</Table.Td>
</Table.Tr>
)),
<Table.Tr key={`subtotal-${cat.category}`} style={{ borderTop: '2px solid var(--mantine-color-gray-4)' }}>
<Table.Td colSpan={3}>
<Text size="sm" fw={600} fs="italic">Subtotal {cat.category}</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text size="sm" fw={600}>{fmt(catTotals[y])}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={600}>{fmt(catBeyond)}</Text>
</Table.Td>
</Table.Tr>,
];
})}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr style={{ background: 'var(--mantine-color-dark-0)' }}>
<Table.Td colSpan={3}>
<Text fw={700}>TOTAL</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text fw={700}>{fmt(data?.year_totals[y] || 0)}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text fw={700}>{fmt(data?.beyond_total || 0)}</Text>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
</Card>
)}
</Stack>
);
}

View File

@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
Tabs, Button, Switch,
Tabs, Button, Switch, Loader,
} from '@mantine/core';
import {
IconBuilding, IconUser, IconSettings, IconShieldLock,
IconFingerprint, IconLink, IconLogout,
IconFingerprint, IconLink, IconLogout, IconCreditCard,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useAuthStore } from '../../stores/authStore';
@@ -15,10 +15,40 @@ import { PasskeySettings } from './PasskeySettings';
import { LinkedAccounts } from './LinkedAccounts';
import api from '../../services/api';
interface SubscriptionInfo {
plan: string;
planName: string;
billingInterval: string;
status: string;
collectionMethod: string;
trialEndsAt: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeCustomer: boolean;
}
const statusColors: Record<string, string> = {
active: 'green',
trial: 'blue',
past_due: 'orange',
archived: 'red',
suspended: 'red',
};
export function SettingsPage() {
const { user, currentOrg } = useAuthStore();
const { compactView, toggleCompactView } = usePreferencesStore();
const [loggingOutAll, setLoggingOutAll] = useState(false);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
const [subLoading, setSubLoading] = useState(true);
const [portalLoading, setPortalLoading] = useState(false);
useEffect(() => {
api.get('/billing/subscription')
.then(({ data }) => setSubscription(data))
.catch(() => { /* billing not configured or no subscription */ })
.finally(() => setSubLoading(false));
}, []);
const handleLogoutEverywhere = async () => {
setLoggingOutAll(true);
@@ -32,6 +62,32 @@ export function SettingsPage() {
}
};
const handleManageBilling = async () => {
setPortalLoading(true);
try {
const { data } = await api.post('/billing/portal');
if (data.url) {
window.location.href = data.url;
}
} catch (err: any) {
const msg = err.response?.data?.message || 'Unable to open billing portal';
notifications.show({ message: typeof msg === 'string' ? msg : 'Unable to open billing portal', color: 'red' });
} finally {
setPortalLoading(false);
}
};
const formatInterval = (interval: string) => {
return interval === 'year' ? 'Annual' : 'Monthly';
};
const formatDate = (iso: string | null) => {
if (!iso) return null;
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
});
};
return (
<Stack>
<div>
@@ -63,6 +119,79 @@ export function SettingsPage() {
</Stack>
</Card>
{/* Billing / Subscription */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="teal" variant="light" size={40} radius="md">
<IconCreditCard size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Billing</Text>
<Text c="dimmed" size="sm">Subscription and payment</Text>
</div>
</Group>
{subLoading ? (
<Group justify="center" py="md"><Loader size="sm" /></Group>
) : subscription ? (
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Plan</Text>
<Group gap={4}>
<Badge variant="light">{subscription.planName}</Badge>
<Badge variant="light" color="gray" size="sm">{formatInterval(subscription.billingInterval)}</Badge>
</Group>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Status</Text>
<Badge
color={statusColors[subscription.status] || 'gray'}
variant="light"
>
{subscription.status === 'past_due' ? 'Past Due' : subscription.status}
{subscription.cancelAtPeriodEnd ? ' (Canceling)' : ''}
</Badge>
</Group>
{subscription.trialEndsAt && subscription.status === 'trial' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Trial Ends</Text>
<Text size="sm" fw={500}>{formatDate(subscription.trialEndsAt)}</Text>
</Group>
)}
{subscription.currentPeriodEnd && subscription.status !== 'trial' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Current Period Ends</Text>
<Text size="sm" fw={500}>{formatDate(subscription.currentPeriodEnd)}</Text>
</Group>
)}
{subscription.collectionMethod === 'send_invoice' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Payment</Text>
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
</Group>
)}
{subscription.hasStripeCustomer ? (
<Button
variant="light"
color="teal"
size="sm"
leftSection={<IconCreditCard size={16} />}
onClick={handleManageBilling}
loading={portalLoading}
mt="xs"
>
Manage Billing
</Button>
) : subscription.status === 'trial' ? (
<Text size="xs" c="dimmed" mt="xs">
Billing portal will be available once you add a payment method.
</Text>
) : null}
</Stack>
) : (
<Text size="sm" c="dimmed">No active subscription</Text>
)}
</Card>
{/* User Profile */}
<Card withBorder padding="lg">
<Group mb="md">
@@ -108,7 +237,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.03.17</Badge>
<Badge variant="light">2026.4.2</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

@@ -1,13 +1,13 @@
import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, TextInput, Modal,
Switch, Badge, ActionIcon, Text, Loader, Center,
Switch, Badge, ActionIcon, Text, Loader, Center, Card, ThemeIcon, List,
} 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 { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
@@ -153,7 +153,63 @@ export function VendorsPage() {
<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={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
{filtered.length === 0 && vendors.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8} p={0}>
<Card p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'orange', to: 'yellow' }} mx="auto" mb="md">
<IconUsers size={32} />
</ThemeIcon>
<Title order={3} mb="xs">Vendor Management</Title>
<Text c="dimmed" maw={550} mx="auto" mb="lg">
Keep track of your HOA&apos;s service providers, contractors, and suppliers.
Having a centralized vendor directory helps with 1099 reporting, contract
renewal tracking, and comparing year-over-year spending.
</Text>
<Card withBorder p="md" maw={550} mx="auto" mb="lg" ta="left">
<Text fw={600} mb="xs">
<IconBulb size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />
Common HOA Vendors to Track
</Text>
<List size="sm" spacing="xs" c="dimmed">
<List.Item><Text span fw={500} c="dark">Landscaping Company</Text> Lawn care, tree trimming, seasonal planting</List.Item>
<List.Item><Text span fw={500} c="dark">Property Management</Text> Day-to-day management and tenant communications</List.Item>
<List.Item><Text span fw={500} c="dark">Insurance Provider</Text> Master policy for buildings and common areas</List.Item>
<List.Item><Text span fw={500} c="dark">Pool Maintenance</Text> Weekly chemical testing, cleaning, and equipment repair</List.Item>
<List.Item><Text span fw={500} c="dark">Snow Removal / Paving</Text> Winter plowing and parking lot maintenance</List.Item>
<List.Item><Text span fw={500} c="dark">Attorney / CPA</Text> Legal counsel and annual financial review</List.Item>
</List>
</Card>
<Group justify="center" gap="md">
{!isReadOnly && (
<>
<Button
size="md"
leftSection={<IconRocket size={18} />}
variant="gradient"
gradient={{ from: 'orange', to: 'yellow' }}
onClick={() => { setEditing(null); form.reset(); open(); }}
>
Add Your First Vendor
</Button>
<Button
size="md"
variant="light"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
>
Import from CSV
</Button>
</>
)}
</Group>
</Card>
</Table.Td>
</Table.Tr>
)}
{filtered.length === 0 && vendors.length > 0 && (
<Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors match your search</Text></Table.Td></Table.Tr>
)}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>

View File

@@ -6,6 +6,7 @@ interface Organization {
name: string;
role: string;
status?: string;
planLevel?: string;
settings?: Record<string, any>;
}

View File

@@ -0,0 +1,141 @@
{
"_meta": {
"capturedAt": null,
"environment": "staging",
"k6Version": null,
"vus": null,
"duration": null,
"notes": "Empty baseline populate after first stable load test run"
},
"auth": {
"POST /api/auth/login": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"POST /api/auth/refresh": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"POST /api/auth/logout": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"GET /api/auth/profile": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
}
},
"dashboard": {
"GET /api/reports/dashboard": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"GET /api/reports/balance-sheet": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"GET /api/reports/income-statement": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"GET /api/reports/aging": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"GET /api/health-scores/latest": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"GET /api/reports/cash-flow": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
},
"GET /api/reports/cash-flow-forecast": {
"p50": null,
"p95": null,
"p99": null,
"errorRate": null,
"rps": null
}
},
"crud": {
"units": {
"GET /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"PUT /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"DELETE /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
},
"vendors": {
"GET /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"PUT /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
},
"journalEntries": {
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/journal-entries/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/journal-entries/:id/void": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
},
"payments": {
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"PUT /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"DELETE /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
},
"accounts": {
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"PUT /api/accounts/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
},
"invoices": {
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/invoices/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
}
},
"boardPlanning": {
"GET /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"POST /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/board-planning/scenarios/:id/projection": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/board-planning/budget-plans": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
},
"organizations": {
"GET /api/organizations": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
"GET /api/organizations/members": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
}
}

View File

@@ -0,0 +1,270 @@
-- =============================================================================
-- HOA Financial Platform (HOALedgerIQ) New Relic NRQL Query Library
-- App Name: HOALedgerIQ_App (see NEW_RELIC_APP_NAME env var)
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 1. OVERVIEW & THROUGHPUT
-- ---------------------------------------------------------------------------
-- Overall throughput (requests/min) over past hour
SELECT rate(count(*), 1 minute) AS 'RPM'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES AUTO;
-- Throughput by HTTP method
SELECT rate(count(*), 1 minute) AS 'RPM'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET request.method
SINCE 1 hour ago
TIMESERIES AUTO;
-- Apdex score over time
SELECT apdex(duration, t: 0.5)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES AUTO;
-- ---------------------------------------------------------------------------
-- 2. AUTHENTICATION ENDPOINTS
-- ---------------------------------------------------------------------------
-- Login latency (p50, p95, p99)
SELECT percentile(duration, 50, 95, 99) AS 'Login Latency (s)'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri = '/api/auth/login'
SINCE 1 hour ago
TIMESERIES AUTO;
-- Login error rate
SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Login Error %'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri = '/api/auth/login'
SINCE 1 hour ago
TIMESERIES AUTO;
-- Token refresh latency
SELECT percentile(duration, 50, 95, 99) AS 'Refresh Latency (s)'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri = '/api/auth/refresh'
SINCE 1 hour ago
TIMESERIES AUTO;
-- Auth endpoints overview
SELECT count(*), average(duration), percentile(duration, 95)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/auth/%'
FACET request.uri
SINCE 1 hour ago;
-- ---------------------------------------------------------------------------
-- 3. DASHBOARD & REPORTS
-- ---------------------------------------------------------------------------
-- Dashboard KPI latency
SELECT percentile(duration, 50, 95, 99) AS 'Dashboard Latency (s)'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri = '/api/reports/dashboard'
SINCE 1 hour ago
TIMESERIES AUTO;
-- All report endpoints performance
SELECT count(*), average(duration), percentile(duration, 95)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/reports/%'
FACET request.uri
SINCE 1 hour ago;
-- Slowest report queries (> 2s)
SELECT count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/reports/%'
AND duration > 2
FACET request.uri
SINCE 1 hour ago;
-- ---------------------------------------------------------------------------
-- 4. CRUD OPERATIONS
-- ---------------------------------------------------------------------------
-- Units endpoint latency by method
SELECT percentile(duration, 50, 95) AS 'Units Latency'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/units%'
FACET request.method
SINCE 1 hour ago;
-- Vendors endpoint latency by method
SELECT percentile(duration, 50, 95) AS 'Vendors Latency'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/vendors%'
FACET request.method
SINCE 1 hour ago;
-- Journal entries performance
SELECT percentile(duration, 50, 95) AS 'JE Latency'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/journal-entries%'
FACET request.method
SINCE 1 hour ago;
-- Payments performance
SELECT percentile(duration, 50, 95) AS 'Payments Latency'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/payments%'
FACET request.method
SINCE 1 hour ago;
-- Accounts performance
SELECT percentile(duration, 50, 95) AS 'Accounts Latency'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/accounts%'
FACET request.method
SINCE 1 hour ago;
-- Invoices performance
SELECT percentile(duration, 50, 95) AS 'Invoices Latency'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/invoices%'
FACET request.method
SINCE 1 hour ago;
-- ---------------------------------------------------------------------------
-- 5. MULTI-TENANT / ORG OPERATIONS
-- ---------------------------------------------------------------------------
-- Organizations endpoint performance
SELECT percentile(duration, 50, 95)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/organizations%'
FACET request.method
SINCE 1 hour ago;
-- Board planning (complex module) latency
SELECT count(*), percentile(duration, 50, 95, 99)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND request.uri LIKE '/api/board-planning%'
FACET request.uri
SINCE 1 hour ago;
-- ---------------------------------------------------------------------------
-- 6. ERROR ANALYSIS
-- ---------------------------------------------------------------------------
-- Error rate by endpoint (top 20 offenders)
SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Error %',
count(*) AS 'Total'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET request.uri
SINCE 1 hour ago
LIMIT 20;
-- 5xx errors specifically
SELECT count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode >= 500
FACET request.uri, httpResponseCode
SINCE 1 hour ago;
-- Error rate over time
SELECT percentage(count(*), WHERE httpResponseCode >= 500) AS 'Server Error %',
percentage(count(*), WHERE httpResponseCode >= 400 AND httpResponseCode < 500) AS 'Client Error %'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES AUTO;
-- ---------------------------------------------------------------------------
-- 7. DATABASE & EXTERNAL SERVICES
-- ---------------------------------------------------------------------------
-- Database call duration (TypeORM / Postgres)
SELECT average(databaseDuration), percentile(databaseDuration, 95)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES AUTO;
-- Slowest DB transactions
SELECT average(databaseDuration), count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND databaseDuration > 1
FACET request.uri
SINCE 1 hour ago
LIMIT 20;
-- External service calls (Stripe, Resend, NVIDIA AI)
SELECT average(externalDuration), count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND externalDuration > 0
FACET request.uri
SINCE 1 hour ago;
-- ---------------------------------------------------------------------------
-- 8. LOAD TEST COMPARISON
-- ---------------------------------------------------------------------------
-- Compare metrics between two time windows (baseline vs test)
-- Adjust SINCE/UNTIL for your test windows
SELECT percentile(duration, 50, 95, 99), count(*), percentage(count(*), WHERE httpResponseCode >= 500)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 2 hours ago
UNTIL 1 hour ago
COMPARE WITH 1 hour ago;
-- Per-endpoint comparison during load test window
SELECT average(duration), percentile(duration, 95), count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET request.uri
SINCE 1 hour ago
LIMIT 50;
-- ---------------------------------------------------------------------------
-- 9. INFRASTRUCTURE (if NR Infrastructure agent is installed)
-- ---------------------------------------------------------------------------
-- CPU utilization
SELECT average(cpuPercent)
FROM SystemSample
WHERE hostname LIKE '%hoaledgeriq%'
SINCE 1 hour ago
TIMESERIES AUTO;
-- Memory utilization
SELECT average(memoryUsedPercent)
FROM SystemSample
WHERE hostname LIKE '%hoaledgeriq%'
SINCE 1 hour ago
TIMESERIES AUTO;
-- Connection pool saturation (custom metric requires NR custom events)
-- SELECT average(custom.db.pool.active), average(custom.db.pool.idle)
-- FROM Metric
-- WHERE appName = 'HOALedgerIQ_App'
-- SINCE 1 hour ago
-- TIMESERIES AUTO;

View File

@@ -0,0 +1,183 @@
/**
* HOALedgerIQ Auth + Dashboard Load Test
* Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout
*
* Covers the highest-frequency production flow: a treasurer or admin
* opening the app, loading the dashboard, and reviewing financial reports.
*/
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { SharedArray } from 'k6/data';
import { Trend, Rate, Counter } from 'k6/metrics';
// ── Custom metrics ──────────────────────────────────────────────────────────
const loginDuration = new Trend('login_duration', true);
const dashboardDuration = new Trend('dashboard_duration', true);
const refreshDuration = new Trend('refresh_duration', true);
const authErrorRate = new Rate('auth_error_rate');
const dashboardErrorRate = new Rate('dashboard_error_rate');
const tokenRefreshCount = new Counter('token_refresh_count');
// ── User pool ────────────────────────────────────────────────────────────────
const users = new SharedArray('users', function () {
return open('../config/user-pool.csv')
.split('\n')
.slice(1) // skip header row
.filter(line => line.trim())
.map(line => {
const [email, password, orgId, role] = line.split(',');
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
});
});
// ── Environment config ───────────────────────────────────────────────────────
const ENV = __ENV.TARGET_ENV || 'staging';
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
const BASE_URL = envConfig.baseUrl;
// ── Test options ─────────────────────────────────────────────────────────────
export const options = {
scenarios: {
auth_dashboard: {
executor: 'ramping-vus',
stages: [
{ duration: '2m', target: 20 }, // warm up
{ duration: '5m', target: 100 }, // ramp to target load
{ duration: '5m', target: 100 }, // sustained load
{ duration: '3m', target: 200 }, // peak spike
{ duration: '2m', target: 0 }, // ramp down
],
},
},
thresholds: {
// Latency targets per environment (overridden by environments.json)
'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`],
'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`],
'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
},
};
// ── Helpers ──────────────────────────────────────────────────────────────────
function authHeaders(token) {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
}
// ── Main scenario ────────────────────────────────────────────────────────────
export default function () {
const user = users[__VU % users.length];
let accessToken = null;
// ── 1. Login ────────────────────────────────────────────────────────────
group('auth:login', () => {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ email: user.email, password: user.password }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
);
loginDuration.add(res.timings.duration);
const ok = check(res, {
'login 200': r => r.status === 200,
'has access_token': r => r.json('access_token') !== undefined,
'has orgId in body': r => r.json('user.orgId') !== undefined,
});
authErrorRate.add(!ok);
if (!ok) { sleep(1); return; }
accessToken = res.json('access_token');
// httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar
});
if (!accessToken) return;
sleep(1.5); // think time user lands on dashboard
// ── 2. Load dashboard & key reports in parallel ─────────────────────────
group('dashboard:load', () => {
const requests = {
dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`],
balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`],
income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`],
profile: ['GET', `${BASE_URL}/api/auth/profile`],
accounts: ['GET', `${BASE_URL}/api/accounts`],
};
const responses = http.batch(
Object.entries(requests).map(([name, [method, url]]) => ({
method, url,
params: { headers: authHeaders(accessToken), tags: { name } },
}))
);
let allOk = true;
responses.forEach((res, i) => {
const name = Object.keys(requests)[i];
dashboardDuration.add(res.timings.duration, { endpoint: name });
const ok = check(res, {
[`${name} 200`]: r => r.status === 200,
[`${name} has body`]: r => r.body && r.body.length > 0,
});
if (!ok) allOk = false;
});
dashboardErrorRate.add(!allOk);
});
sleep(2); // user reads the dashboard
// ── 3. Simulate token refresh (happens automatically in-app at 55min) ────
// In the load test we trigger it early to validate the refresh path under load
group('auth:refresh', () => {
const res = http.post(
`${BASE_URL}/api/auth/refresh`,
null,
{
headers: authHeaders(accessToken),
tags: { name: 'refresh' },
// k6 sends the httpOnly cookie from the jar automatically
}
);
refreshDuration.add(res.timings.duration);
tokenRefreshCount.add(1);
const ok = check(res, {
'refresh 200': r => r.status === 200,
'new access_token': r => r.json('access_token') !== undefined,
});
authErrorRate.add(!ok);
if (ok) accessToken = res.json('access_token');
});
sleep(1);
// ── 4. Drill into one report (cash-flow forecast typically slowest) ────
group('dashboard:drill', () => {
const res = http.get(
`${BASE_URL}/api/reports/cash-flow-forecast`,
{ headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } }
);
dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' });
dashboardErrorRate.add(res.status !== 200);
check(res, { 'forecast 200': r => r.status === 200 });
});
sleep(2);
// ── 5. Logout ────────────────────────────────────────────────────────────
group('auth:logout', () => {
const res = http.post(
`${BASE_URL}/api/auth/logout`,
null,
{ headers: authHeaders(accessToken), tags: { name: 'logout' } }
);
check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 });
});
sleep(1);
}

45
load-tests/baseline.json Normal file
View File

@@ -0,0 +1,45 @@
{
"_meta": {
"description": "Baseline p50/p95/p99 latency targets per endpoint. Update after each cycle where improvements are confirmed. Claude Code will tighten k6 thresholds in environments.json to match.",
"last_updated": "YYYY-MM-DD",
"last_run_cycle": 0,
"units": "milliseconds"
},
"auth": {
"POST /api/auth/login": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/auth/refresh": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/auth/logout": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/auth/profile": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"reports": {
"GET /api/reports/dashboard": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/balance-sheet": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/income-statement": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/cash-flow": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/cash-flow-forecast": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/aging": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/quarterly": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"accounts": {
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"journal_entries": {
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"budgets": {
"GET /api/budgets/:year": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/budgets/:year/vs-actual": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"invoices": {
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/invoices/generate-preview": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"payments": {
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null }
}
}

View File

@@ -0,0 +1,53 @@
{
"staging": {
"baseUrl": "https://staging.hoaledgeriq.com",
"stages": {
"rampUp": 10,
"steady": 25,
"rampDown": 0
},
"thresholds": {
"http_req_duration_p95": 2000,
"login_p95": 1500,
"dashboard_p95": 3000,
"crud_write_p95": 2000,
"crud_read_p95": 1500,
"error_rate": 0.05
},
"notes": "Staging environment smaller infra, relaxed thresholds"
},
"production": {
"baseUrl": "https://app.hoaledgeriq.com",
"stages": {
"rampUp": 20,
"steady": 50,
"rampDown": 0
},
"thresholds": {
"http_req_duration_p95": 1000,
"login_p95": 800,
"dashboard_p95": 1500,
"crud_write_p95": 1000,
"crud_read_p95": 800,
"error_rate": 0.01
},
"notes": "Production thresholds strict SLA targets"
},
"local": {
"baseUrl": "http://localhost:3000",
"stages": {
"rampUp": 5,
"steady": 10,
"rampDown": 0
},
"thresholds": {
"http_req_duration_p95": 3000,
"login_p95": 2000,
"dashboard_p95": 5000,
"crud_write_p95": 3000,
"crud_read_p95": 2000,
"error_rate": 0.10
},
"notes": "Local dev generous thresholds for single-machine testing"
}
}

View File

@@ -0,0 +1,11 @@
email,password,orgId,role
loadtest-treasurer-01@hoaledgeriq.test,LoadTest!Pass01,org-uuid-placeholder-1,treasurer
loadtest-treasurer-02@hoaledgeriq.test,LoadTest!Pass02,org-uuid-placeholder-1,treasurer
loadtest-admin-01@hoaledgeriq.test,LoadTest!Pass03,org-uuid-placeholder-1,admin
loadtest-admin-02@hoaledgeriq.test,LoadTest!Pass04,org-uuid-placeholder-2,admin
loadtest-president-01@hoaledgeriq.test,LoadTest!Pass05,org-uuid-placeholder-2,president
loadtest-manager-01@hoaledgeriq.test,LoadTest!Pass06,org-uuid-placeholder-2,manager
loadtest-member-01@hoaledgeriq.test,LoadTest!Pass07,org-uuid-placeholder-1,member_at_large
loadtest-viewer-01@hoaledgeriq.test,LoadTest!Pass08,org-uuid-placeholder-1,viewer
loadtest-homeowner-01@hoaledgeriq.test,LoadTest!Pass09,org-uuid-placeholder-2,homeowner
loadtest-homeowner-02@hoaledgeriq.test,LoadTest!Pass10,org-uuid-placeholder-2,homeowner
1 email password orgId role
2 loadtest-treasurer-01@hoaledgeriq.test LoadTest!Pass01 org-uuid-placeholder-1 treasurer
3 loadtest-treasurer-02@hoaledgeriq.test LoadTest!Pass02 org-uuid-placeholder-1 treasurer
4 loadtest-admin-01@hoaledgeriq.test LoadTest!Pass03 org-uuid-placeholder-1 admin
5 loadtest-admin-02@hoaledgeriq.test LoadTest!Pass04 org-uuid-placeholder-2 admin
6 loadtest-president-01@hoaledgeriq.test LoadTest!Pass05 org-uuid-placeholder-2 president
7 loadtest-manager-01@hoaledgeriq.test LoadTest!Pass06 org-uuid-placeholder-2 manager
8 loadtest-member-01@hoaledgeriq.test LoadTest!Pass07 org-uuid-placeholder-1 member_at_large
9 loadtest-viewer-01@hoaledgeriq.test LoadTest!Pass08 org-uuid-placeholder-1 viewer
10 loadtest-homeowner-01@hoaledgeriq.test LoadTest!Pass09 org-uuid-placeholder-2 homeowner
11 loadtest-homeowner-02@hoaledgeriq.test LoadTest!Pass10 org-uuid-placeholder-2 homeowner

259
load-tests/crud-flow.js Normal file
View File

@@ -0,0 +1,259 @@
/**
* HOALedgerIQ Core CRUD Workflow Load Test
* Journey: Login → Create Journal Entry → Post It → Create Invoice →
* Record Payment → View Accounts → Budget vs Actual → Logout
*
* This scenario exercises write-heavy paths gated by WriteAccessGuard
* and the TenantMiddleware schema-switch. Run this alongside
* auth-dashboard-flow.js to simulate a realistic mixed workload.
*
* Role used: treasurer (has full write access, most common power user)
*/
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { SharedArray } from 'k6/data';
import { Trend, Rate } from 'k6/metrics';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
// ── Custom metrics ──────────────────────────────────────────────────────────
const journalEntryDuration = new Trend('journal_entry_duration', true);
const invoiceDuration = new Trend('invoice_duration', true);
const paymentDuration = new Trend('payment_duration', true);
const accountsReadDuration = new Trend('accounts_read_duration', true);
const budgetDuration = new Trend('budget_vs_actual_duration',true);
const crudErrorRate = new Rate('crud_error_rate');
const writeGuardErrorRate = new Rate('write_guard_error_rate');
// ── User pool (treasurer + admin roles only for write access) ────────────────
const users = new SharedArray('users', function () {
return open('../config/user-pool.csv')
.split('\n')
.slice(1)
.filter(line => line.trim())
.map(line => {
const [email, password, orgId, role] = line.split(',');
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
})
.filter(u => ['treasurer', 'admin', 'president', 'manager'].includes(u.role));
});
// ── Environment config ───────────────────────────────────────────────────────
const ENV = __ENV.TARGET_ENV || 'staging';
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
const BASE_URL = envConfig.baseUrl;
// ── Test options ─────────────────────────────────────────────────────────────
export const options = {
scenarios: {
crud_workflow: {
executor: 'ramping-vus',
stages: [
{ duration: '2m', target: 10 }, // warm up (writes need more care)
{ duration: '5m', target: 50 }, // ramp to target
{ duration: '5m', target: 50 }, // sustained
{ duration: '3m', target: 100 }, // peak
{ duration: '2m', target: 0 }, // ramp down
],
},
},
thresholds: {
'journal_entry_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
'invoice_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
'payment_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
'accounts_read_duration': [`p(95)<${envConfig.thresholds.read_p95}`],
'budget_vs_actual_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
'crud_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
'write_guard_error_rate': ['rate<0.001'], // write-guard failures should be near-zero
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
},
};
// ── Helpers ──────────────────────────────────────────────────────────────────
function jsonHeaders(token) {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
}
function currentYear() {
return new Date().getFullYear();
}
// ── Main scenario ────────────────────────────────────────────────────────────
export default function () {
const user = users[__VU % users.length];
let accessToken = null;
// ── 1. Login ────────────────────────────────────────────────────────────
group('auth:login', () => {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ email: user.email, password: user.password }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
);
const ok = check(res, {
'login 200': r => r.status === 200,
'has access_token': r => r.json('access_token') !== undefined,
});
crudErrorRate.add(!ok);
if (!ok) { sleep(1); return; }
accessToken = res.json('access_token');
});
if (!accessToken) return;
sleep(1);
// ── 2. Read accounts (needed to pick valid account IDs for journal entry) ─
let debitAccountId = null;
let creditAccountId = null;
group('accounts:list', () => {
const res = http.get(
`${BASE_URL}/api/accounts`,
{ headers: jsonHeaders(accessToken), tags: { name: 'accounts_list' } }
);
accountsReadDuration.add(res.timings.duration);
const ok = check(res, {
'accounts 200': r => r.status === 200,
'accounts non-empty': r => Array.isArray(r.json()) && r.json().length > 0,
});
crudErrorRate.add(!ok);
if (ok) {
const accounts = res.json();
// Pick first two distinct accounts for the journal entry
debitAccountId = accounts[0]?.id;
creditAccountId = accounts[1]?.id;
}
});
if (!debitAccountId || !creditAccountId) { sleep(1); return; }
sleep(1.5);
// ── 3. Create journal entry (draft) ────────────────────────────────────
let journalEntryId = null;
group('journal:create', () => {
const payload = {
date: new Date().toISOString().split('T')[0],
description: `Load test entry ${uuidv4().slice(0, 8)}`,
lines: [
{ accountId: debitAccountId, type: 'debit', amount: 100.00, description: 'Load test debit' },
{ accountId: creditAccountId, type: 'credit', amount: 100.00, description: 'Load test credit' },
],
};
const res = http.post(
`${BASE_URL}/api/journal-entries`,
JSON.stringify(payload),
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_create' } }
);
journalEntryDuration.add(res.timings.duration);
// Watch for WriteAccessGuard rejections (403)
writeGuardErrorRate.add(res.status === 403);
const ok = check(res, {
'journal create 201': r => r.status === 201,
'journal has id': r => r.json('id') !== undefined,
});
crudErrorRate.add(!ok);
if (ok) journalEntryId = res.json('id');
});
sleep(1);
// ── 4. Post the journal entry ────────────────────────────────────────────
if (journalEntryId) {
group('journal:post', () => {
const res = http.post(
`${BASE_URL}/api/journal-entries/${journalEntryId}/post`,
null,
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_post' } }
);
journalEntryDuration.add(res.timings.duration);
writeGuardErrorRate.add(res.status === 403);
const ok = check(res, { 'journal post 200': r => r.status === 200 });
crudErrorRate.add(!ok);
});
sleep(1.5);
}
// ── 5. Generate invoice preview ─────────────────────────────────────────
let invoicePreviewOk = false;
group('invoice:preview', () => {
const res = http.post(
`${BASE_URL}/api/invoices/generate-preview`,
JSON.stringify({ period: currentYear() }),
{ headers: jsonHeaders(accessToken), tags: { name: 'invoice_preview' } }
);
invoiceDuration.add(res.timings.duration);
invoicePreviewOk = check(res, { 'invoice preview 200': r => r.status === 200 });
crudErrorRate.add(!invoicePreviewOk);
});
sleep(2); // user reviews invoice preview
// ── 6. Create a payment record ───────────────────────────────────────────
group('payment:create', () => {
const payload = {
amount: 150.00,
date: new Date().toISOString().split('T')[0],
method: 'check',
description: `Load test payment ${uuidv4().slice(0, 8)}`,
};
const res = http.post(
`${BASE_URL}/api/payments`,
JSON.stringify(payload),
{ headers: jsonHeaders(accessToken), tags: { name: 'payment_create' } }
);
paymentDuration.add(res.timings.duration);
writeGuardErrorRate.add(res.status === 403);
const ok = check(res, {
'payment create 201 or 200': r => r.status === 201 || r.status === 200,
});
crudErrorRate.add(!ok);
});
sleep(1.5);
// ── 7. Budget vs actual (typically the heaviest read query) ─────────────
group('budget:vs-actual', () => {
const year = currentYear();
const res = http.get(
`${BASE_URL}/api/budgets/${year}/vs-actual`,
{ headers: jsonHeaders(accessToken), tags: { name: 'budget_vs_actual' } }
);
budgetDuration.add(res.timings.duration);
const ok = check(res, { 'budget vs-actual 200': r => r.status === 200 });
crudErrorRate.add(!ok);
});
sleep(1);
// ── 8. Trial balance read ────────────────────────────────────────────────
group('accounts:trial-balance', () => {
const res = http.get(
`${BASE_URL}/api/accounts/trial-balance`,
{ headers: jsonHeaders(accessToken), tags: { name: 'trial_balance' } }
);
accountsReadDuration.add(res.timings.duration);
check(res, { 'trial balance 200': r => r.status === 200 });
});
sleep(1);
// ── 9. Logout ────────────────────────────────────────────────────────────
group('auth:logout', () => {
http.post(
`${BASE_URL}/api/auth/logout`,
null,
{ headers: jsonHeaders(accessToken), tags: { name: 'logout' } }
);
});
sleep(1);
}

View File

@@ -0,0 +1,117 @@
# HOALedgerIQ Load Test Improvement Report
**Cycle:** 001
**Date:** YYYY-MM-DD
**Test window:** HH:MM HH:MM UTC
**Environments:** Staging (`staging.hoaledgeriq.com`)
**Scenarios run:** `auth-dashboard-flow.js` + `crud-flow.js`
**Peak VUs:** 200 (dashboard) / 100 (CRUD)
**New Relic app:** `HOALedgerIQ_App`
---
## Executive Summary
> _[One paragraph: what load the system handled, what broke first, at what VU threshold, and the estimated user-facing impact. Written by Claude Code from New Relic data.]_
**Threshold breaches this cycle:**
| Metric | Target | Actual | Status |
|--------|--------|--------|--------|
| login p95 | < 300ms | | 🔴 / 🟢 |
| dashboard p95 | < 1000ms | | 🔴 / 🟢 |
| budget vs-actual p95 | < 1000ms | | 🔴 / 🟢 |
| journal entry write p95 | < 1200ms | | 🔴 / 🟢 |
| error rate | < 1% | | 🔴 / 🟢 |
---
## Findings
### 🔴 P0 Fix Before Next Deploy
#### Finding 001 [Short title]
- **Symptom:** _e.g., `GET /api/reports/cash-flow-forecast` p95 = 3,400ms at 100 VUs_
- **New Relic evidence:** _e.g., DatastoreSegment shows 47 sequential DB calls per request_
- **Root cause hypothesis:** _e.g., N+1 on `reserve_components` each component triggers a separate `SELECT` for `monthly_actuals`_
- **File:** `backend/src/modules/reports/cash-flow.service.ts:83`
- **Recommended fix:**
```typescript
// BEFORE N+1: one query per component
for (const component of components) {
const actuals = await this.actualsRepo.findBy({ componentId: component.id });
}
// AFTER batch load with WHERE IN
const actuals = await this.actualsRepo.findBy({
componentId: In(components.map(c => c.id))
});
```
- **Expected improvement:** ~70% latency reduction on this endpoint
- **Effort:** Low (12 hours)
---
### 🟠 P1 Fix Within This Sprint
#### Finding 002 [Short title]
- **Symptom:**
- **New Relic evidence:**
- **Root cause hypothesis:**
- **File:**
- **Recommended fix:**
- **Expected improvement:**
- **Effort:**
#### Finding 003 [Short title]
- _(same structure)_
---
### 🟡 P2 Backlog
#### Finding 004 [Short title]
- **Symptom:**
- **Root cause hypothesis:**
- **Recommended fix:**
- **Effort:**
---
## Regression Net — Re-Test Criteria
After implementing P0 + P1 fixes, the next BlazeMeter run must pass these gates before merging to staging:
| Endpoint | Previous p95 | Target p95 | k6 Threshold |
|----------|-------------|------------|-------------|
| `GET /api/reports/cash-flow-forecast` | — | — | `p(95)<XXX` |
| `POST /api/journal-entries` | — | — | `p(95)<XXX` |
| `GET /api/budgets/:year/vs-actual` | — | — | `p(95)<XXX` |
> **Claude Code update command (run after confirming fixes):**
> ```bash
> claude "Update load-tests/analysis/baseline.json with the p95 values from
> load-tests/reports/cycle-001.md findings. Tighten the k6 thresholds in
> load-tests/config/environments.json staging block to match. Do not loosen
> any threshold that already passes."
> ```
---
## Baseline Delta
| Endpoint | Cycle 000 p95 | Cycle 001 p95 | Δ |
|----------|--------------|--------------|---|
| _(populated after first run)_ | — | — | — |
---
## Notes & Observations
- _Any anomalies, flaky tests, or infrastructure events during the run_
- _Redis / BullMQ queue depth observations_
- _Rate limiter (Throttler) trip count — if >0, note which endpoints and at what VU count_
- _TenantMiddleware cache hit rate (if observable via New Relic custom attributes)_
---
_Generated by Claude Code. Source data in `load-tests/analysis/raw/`. Next cycle target: implement P0+P1, re-run at same peak VUs, update baselines._

View File

@@ -0,0 +1,38 @@
{
"local": {
"baseUrl": "http://localhost:3000",
"thresholds": {
"auth_p95": 500,
"refresh_p95": 300,
"read_p95": 1000,
"write_p95": 1500,
"dashboard_p95": 1500,
"global_p99": 3000,
"error_rate": 0.05
}
},
"staging": {
"baseUrl": "https://staging.hoaledgeriq.com",
"thresholds": {
"auth_p95": 300,
"refresh_p95": 200,
"read_p95": 800,
"write_p95": 1200,
"dashboard_p95": 1000,
"global_p99": 2000,
"error_rate": 0.01
}
},
"production": {
"baseUrl": "https://app.hoaledgeriq.com",
"thresholds": {
"auth_p95": 200,
"refresh_p95": 150,
"read_p95": 500,
"write_p95": 800,
"dashboard_p95": 700,
"global_p99": 1500,
"error_rate": 0.005
}
}
}

274
load-tests/nrql-queries.sql Normal file
View File

@@ -0,0 +1,274 @@
-- ============================================================
-- HOALedgerIQ New Relic NRQL Query Library
-- App name: HOALedgerIQ_App
-- Usage: Run in New Relic Query Builder. Replace time windows as needed.
-- ============================================================
-- ── SECTION 1: OVERVIEW HEALTH ────────────────────────────────────────────
-- 1.1 Apdex score over last test window
SELECT apdex(duration, t: 0.5) AS 'Apdex'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES 1 minute
-- 1.2 Overall throughput (requests per minute)
SELECT rate(count(*), 1 minute) AS 'RPM'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES 1 minute
-- 1.3 Error rate over time
SELECT percentage(count(*), WHERE error IS true) AS 'Error %'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES 1 minute
-- ── SECTION 2: LATENCY BY ENDPOINT ────────────────────────────────────────
-- 2.1 p50 / p95 / p99 latency by transaction name
SELECT percentile(duration, 50, 95, 99) AS 'ms'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET name
SINCE 1 hour ago
LIMIT 30
-- 2.2 Slowest endpoints (p95) during load test window
SELECT percentile(duration, 95) AS 'p95 ms'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET name
SINCE 1 hour ago
ORDER BY percentile(duration, 95) DESC
LIMIT 20
-- 2.3 Auth endpoint latency breakdown
SELECT percentile(duration, 50, 95, 99)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND name LIKE '%auth%'
FACET name
SINCE 1 hour ago
-- 2.4 Report endpoint latency (typically slowest reads)
SELECT percentile(duration, 50, 95, 99)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND name LIKE '%reports%'
FACET name
SINCE 1 hour ago
-- 2.5 Write endpoint latency (journal-entries, payments, invoices)
SELECT percentile(duration, 50, 95, 99)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND (name LIKE '%journal-entries%' OR name LIKE '%payments%' OR name LIKE '%invoices%')
FACET name
SINCE 1 hour ago
-- 2.6 Latency heatmap over time for dashboard load
SELECT histogram(duration, width: 100, buckets: 20)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND name LIKE '%reports/dashboard%'
SINCE 1 hour ago
-- ── SECTION 3: DATABASE PERFORMANCE ──────────────────────────────────────
-- 3.1 Slowest database queries (top 20)
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
FROM DatastoreSegment
WHERE appName = 'HOALedgerIQ_App'
FACET statement
SINCE 1 hour ago
ORDER BY average(duration) DESC
LIMIT 20
-- 3.2 Database call count by operation type
SELECT count(*)
FROM DatastoreSegment
WHERE appName = 'HOALedgerIQ_App'
FACET operation
SINCE 1 hour ago
-- 3.3 N+1 detection high-call-count queries
SELECT count(*) AS 'call count', average(duration) AS 'avg ms'
FROM DatastoreSegment
WHERE appName = 'HOALedgerIQ_App'
FACET statement
SINCE 1 hour ago
ORDER BY count(*) DESC
LIMIT 20
-- 3.4 DB time as % of total transaction time (per endpoint)
SELECT average(databaseDuration) / average(duration) * 100 AS '% DB time'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND databaseDuration IS NOT NULL
FACET name
SINCE 1 hour ago
ORDER BY average(databaseDuration) / average(duration) DESC
LIMIT 20
-- 3.5 Connection pool pressure (slow queries that may indicate pool exhaustion)
SELECT count(*) AS 'slow queries (>500ms)'
FROM DatastoreSegment
WHERE appName = 'HOALedgerIQ_App'
AND duration > 0.5
FACET statement
SINCE 1 hour ago
-- 3.6 Multi-tenant schema switch overhead (TenantMiddleware)
SELECT average(duration) AS 'avg ms'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND name NOT LIKE '%auth/login%'
AND name NOT LIKE '%auth/refresh%'
FACET name
SINCE 1 hour ago
ORDER BY average(duration) DESC
LIMIT 20
-- ── SECTION 4: ERROR ANALYSIS ─────────────────────────────────────────────
-- 4.1 All errors by class and message
SELECT count(*), latest(errorMessage)
FROM TransactionError
WHERE appName = 'HOALedgerIQ_App'
FACET errorClass, errorMessage
SINCE 1 hour ago
LIMIT 30
-- 4.2 Error rate by HTTP status code
SELECT count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode >= 400
FACET httpResponseCode
SINCE 1 hour ago
TIMESERIES 1 minute
-- 4.3 403 errors (WriteAccessGuard rejections under load)
SELECT count(*) AS '403 Forbidden'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode = 403
FACET name
SINCE 1 hour ago
-- 4.4 429 errors (rate limiter Throttler)
SELECT count(*) AS '429 Rate Limited'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode = 429
TIMESERIES 1 minute
SINCE 1 hour ago
-- 4.5 500 errors by endpoint
SELECT count(*), latest(errorMessage)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode = 500
FACET name, errorMessage
SINCE 1 hour ago
-- 4.6 JWT / auth failures
SELECT count(*)
FROM TransactionError
WHERE appName = 'HOALedgerIQ_App'
AND (errorMessage LIKE '%jwt%' OR errorMessage LIKE '%token%' OR errorMessage LIKE '%unauthorized%')
FACET errorMessage
SINCE 1 hour ago
-- ── SECTION 5: INFRASTRUCTURE (during test window) ───────────────────────
-- 5.1 CPU utilization
SELECT average(cpuPercent) AS 'CPU %'
FROM SystemSample
WHERE hostname LIKE '%hoaledgeriq%'
SINCE 1 hour ago
TIMESERIES 1 minute
-- 5.2 Memory utilization
SELECT average(memoryUsedPercent) AS 'Memory %'
FROM SystemSample
WHERE hostname LIKE '%hoaledgeriq%'
SINCE 1 hour ago
TIMESERIES 1 minute
-- 5.3 Network I/O
SELECT average(transmitBytesPerSecond) AS 'TX bytes/s',
average(receiveBytesPerSecond) AS 'RX bytes/s'
FROM NetworkSample
WHERE hostname LIKE '%hoaledgeriq%'
SINCE 1 hour ago
TIMESERIES 1 minute
-- ── SECTION 6: REDIS / BULLMQ ─────────────────────────────────────────────
-- 6.1 External call latency (Redis)
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
FROM ExternalSegment
WHERE appName = 'HOALedgerIQ_App'
AND (name LIKE '%redis%' OR host LIKE '%redis%')
FACET name
SINCE 1 hour ago
-- 6.2 All external service latency
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
FROM ExternalSegment
WHERE appName = 'HOALedgerIQ_App'
FACET host
SINCE 1 hour ago
ORDER BY average(duration) DESC
-- ── SECTION 7: BASELINE COMPARISON ───────────────────────────────────────
-- 7.1 Compare this run vs last run (adjust SINCE/UNTIL for your windows)
SELECT percentile(duration, 95) AS 'p95 this run'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET name
SINCE '2025-01-01 10:00:00' UNTIL '2025-01-01 11:00:00'
-- Run again with previous window dates to compare
-- 7.2 Regression check endpoints that crossed p95 threshold
SELECT percentile(duration, 95) AS 'p95 ms'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND percentile(duration, 95) > 800 -- adjust to your staging threshold
FACET name
SINCE 1 hour ago
-- ── SECTION 8: TENANT-AWARE ANALYSIS ──────────────────────────────────────
-- 8.1 Performance by org (if orgId is in custom attributes)
SELECT percentile(duration, 95) AS 'p95 ms', count(*) AS 'requests'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET custom.orgId
SINCE 1 hour ago
LIMIT 20
-- 8.2 Transactions without orgId (potential TenantMiddleware misses)
SELECT count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND custom.orgId IS NULL
AND name NOT LIKE '%auth/login%'
AND name NOT LIKE '%auth/register%'
AND name NOT LIKE '%health%'
FACET name
SINCE 1 hour ago

View File

@@ -0,0 +1,189 @@
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { Counter, Rate, Trend } from 'k6/metrics';
// ---------------------------------------------------------------------------
// Custom metrics
// ---------------------------------------------------------------------------
const loginDuration = new Trend('login_duration', true);
const refreshDuration = new Trend('refresh_duration', true);
const dashboardDuration = new Trend('dashboard_duration', true);
const profileDuration = new Trend('profile_duration', true);
const loginFailures = new Counter('login_failures');
const authErrors = new Rate('auth_error_rate');
// ---------------------------------------------------------------------------
// Test user pool parameterized from CSV
// ---------------------------------------------------------------------------
const users = new SharedArray('users', function () {
const lines = open('../config/user-pool.csv').split('\n').slice(1); // skip header
return lines
.filter((l) => l.trim().length > 0)
.map((line) => {
const [email, password, orgId, role] = line.split(',');
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
});
});
// ---------------------------------------------------------------------------
// Environment config
// ---------------------------------------------------------------------------
const ENV = JSON.parse(open('../config/environments.json'));
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
const BASE = CONF.baseUrl;
// ---------------------------------------------------------------------------
// k6 options ramp-up / steady / ramp-down
// ---------------------------------------------------------------------------
export const options = {
scenarios: {
auth_dashboard: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: CONF.stages.rampUp }, // ramp-up
{ duration: '5m', target: CONF.stages.steady }, // steady state
{ duration: '1m', target: 0 }, // ramp-down
],
gracefulStop: '30s',
},
},
thresholds: {
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
login_duration: [`p(95)<${CONF.thresholds.login_p95}`],
dashboard_duration: [`p(95)<${CONF.thresholds.dashboard_p95}`],
auth_error_rate: [`rate<${CONF.thresholds.error_rate}`],
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function authHeaders(accessToken) {
return {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
};
}
function jsonPost(url, body, params = {}) {
return http.post(url, JSON.stringify(body), {
headers: { 'Content-Type': 'application/json', ...((params.headers) || {}) },
tags: params.tags || {},
});
}
// ---------------------------------------------------------------------------
// Default VU function login → dashboard journey
// ---------------------------------------------------------------------------
export default function () {
const user = users[__VU % users.length];
let accessToken = null;
// ── 1. Login ─────────────────────────────────────────────────────────
group('01_login', () => {
const res = jsonPost(`${BASE}/api/auth/login`, {
email: user.email,
password: user.password,
}, { tags: { name: 'POST /api/auth/login' } });
loginDuration.add(res.timings.duration);
const ok = check(res, {
'login status 200|201': (r) => r.status === 200 || r.status === 201,
'login returns accessToken': (r) => {
try { return !!JSON.parse(r.body).accessToken; } catch { return false; }
},
});
if (!ok) {
loginFailures.add(1);
authErrors.add(1);
return; // abort journey cannot continue without token
}
authErrors.add(0);
const body = JSON.parse(res.body);
accessToken = body.accessToken;
});
if (!accessToken) return; // guard
sleep(0.5); // think-time between login and dashboard load
// ── 2. Fetch profile ────────────────────────────────────────────────
group('02_profile', () => {
const res = http.get(`${BASE}/api/auth/profile`, authHeaders(accessToken));
profileDuration.add(res.timings.duration);
check(res, {
'profile status 200': (r) => r.status === 200,
});
});
sleep(0.3);
// ── 3. Dashboard KPIs ───────────────────────────────────────────────
group('03_dashboard', () => {
const res = http.get(`${BASE}/api/reports/dashboard`, authHeaders(accessToken));
dashboardDuration.add(res.timings.duration);
check(res, {
'dashboard status 200': (r) => r.status === 200,
});
});
sleep(0.3);
// ── 4. Parallel dashboard widgets (batch) ───────────────────────────
group('04_dashboard_widgets', () => {
const now = new Date();
const year = now.getFullYear();
const fromDate = `${year}-01-01`;
const toDate = now.toISOString().slice(0, 10);
const responses = http.batch([
['GET', `${BASE}/api/accounts?fundType=operating`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/health-scores/latest`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/onboarding/progress`, null, authHeaders(accessToken)],
]);
responses.forEach((res, i) => {
check(res, {
[`widget_${i} status 200`]: (r) => r.status === 200,
});
});
});
sleep(0.5);
// ── 5. Refresh token ───────────────────────────────────────────────
group('05_refresh_token', () => {
const res = http.post(`${BASE}/api/auth/refresh`, null, {
headers: { 'Content-Type': 'application/json' },
tags: { name: 'POST /api/auth/refresh' },
});
refreshDuration.add(res.timings.duration);
check(res, {
'refresh status 200|201': (r) => r.status === 200 || r.status === 201,
});
});
sleep(0.5);
// ── 6. Logout ──────────────────────────────────────────────────────
group('06_logout', () => {
const res = http.post(`${BASE}/api/auth/logout`, null, authHeaders(accessToken));
check(res, {
'logout status 200|201': (r) => r.status === 200 || r.status === 201,
});
});
sleep(1); // pacing between iterations
}

View File

@@ -0,0 +1,377 @@
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { Counter, Rate, Trend } from 'k6/metrics';
// ---------------------------------------------------------------------------
// Custom metrics
// ---------------------------------------------------------------------------
const loginDuration = new Trend('crud_login_duration', true);
const createDuration = new Trend('crud_create_duration', true);
const readDuration = new Trend('crud_read_duration', true);
const updateDuration = new Trend('crud_update_duration', true);
const deleteDuration = new Trend('crud_delete_duration', true);
const listDuration = new Trend('crud_list_duration', true);
const crudErrors = new Rate('crud_error_rate');
// ---------------------------------------------------------------------------
// Test user pool
// ---------------------------------------------------------------------------
const users = new SharedArray('users', function () {
const lines = open('../config/user-pool.csv').split('\n').slice(1);
return lines
.filter((l) => l.trim().length > 0)
.map((line) => {
const [email, password, orgId, role] = line.split(',');
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
});
});
// ---------------------------------------------------------------------------
// Environment config
// ---------------------------------------------------------------------------
const ENV = JSON.parse(open('../config/environments.json'));
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
const BASE = CONF.baseUrl;
// ---------------------------------------------------------------------------
// k6 options
// ---------------------------------------------------------------------------
export const options = {
scenarios: {
crud_flow: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: CONF.stages.rampUp },
{ duration: '5m', target: CONF.stages.steady },
{ duration: '1m', target: 0 },
],
gracefulStop: '30s',
},
},
thresholds: {
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
crud_create_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
crud_update_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
crud_read_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
crud_list_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
crud_error_rate: [`rate<${CONF.thresholds.error_rate}`],
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function authHeaders(token) {
return {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
};
}
function jsonPost(url, body, token, tags) {
return http.post(url, JSON.stringify(body), {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
tags: tags || {},
});
}
function jsonPut(url, body, token, tags) {
return http.put(url, JSON.stringify(body), {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
tags: tags || {},
});
}
function login(user) {
const res = http.post(
`${BASE}/api/auth/login`,
JSON.stringify({ email: user.email, password: user.password }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'POST /api/auth/login' } },
);
loginDuration.add(res.timings.duration);
if (res.status !== 200 && res.status !== 201) return null;
try { return JSON.parse(res.body).accessToken; } catch { return null; }
}
// ---------------------------------------------------------------------------
// VU function CRUD journey across core entities
// ---------------------------------------------------------------------------
export default function () {
const user = users[__VU % users.length];
// ── 1. Login ─────────────────────────────────────────────────────────
const accessToken = login(user);
if (!accessToken) {
crudErrors.add(1);
return;
}
crudErrors.add(0);
sleep(0.5);
// ── 2. Units CRUD ───────────────────────────────────────────────────
let unitId = null;
group('units_crud', () => {
// List
group('list_units', () => {
const res = http.get(`${BASE}/api/units`, authHeaders(accessToken));
listDuration.add(res.timings.duration);
check(res, { 'list units 200': (r) => r.status === 200 });
});
sleep(0.3);
// Create
group('create_unit', () => {
const payload = {
unitNumber: `LT-${__VU}-${Date.now()}`,
address: `${__VU} Load Test Lane`,
ownerName: `Load Tester ${__VU}`,
ownerEmail: `lt-${__VU}@loadtest.local`,
squareFeet: 1200,
};
const res = jsonPost(`${BASE}/api/units`, payload, accessToken, { name: 'POST /api/units' });
createDuration.add(res.timings.duration);
const ok = check(res, {
'create unit 200|201': (r) => r.status === 200 || r.status === 201,
});
if (ok) {
try { unitId = JSON.parse(res.body).id; } catch { /* noop */ }
}
});
sleep(0.3);
// Read
if (unitId) {
group('read_unit', () => {
const res = http.get(`${BASE}/api/units/${unitId}`, authHeaders(accessToken));
readDuration.add(res.timings.duration);
check(res, { 'read unit 200': (r) => r.status === 200 });
});
sleep(0.3);
// Update
group('update_unit', () => {
const res = jsonPut(`${BASE}/api/units/${unitId}`, {
ownerName: `Updated Tester ${__VU}`,
}, accessToken, { name: 'PUT /api/units/:id' });
updateDuration.add(res.timings.duration);
check(res, { 'update unit 200': (r) => r.status === 200 });
});
sleep(0.3);
// Delete
group('delete_unit', () => {
const res = http.del(`${BASE}/api/units/${unitId}`, null, authHeaders(accessToken));
deleteDuration.add(res.timings.duration);
check(res, { 'delete unit 200': (r) => r.status === 200 });
});
}
});
sleep(0.5);
// ── 3. Vendors CRUD ─────────────────────────────────────────────────
let vendorId = null;
group('vendors_crud', () => {
group('list_vendors', () => {
const res = http.get(`${BASE}/api/vendors`, authHeaders(accessToken));
listDuration.add(res.timings.duration);
check(res, { 'list vendors 200': (r) => r.status === 200 });
});
sleep(0.3);
group('create_vendor', () => {
const payload = {
name: `LT Vendor ${__VU}-${Date.now()}`,
email: `vendor-${__VU}@loadtest.local`,
phone: '555-0100',
category: 'maintenance',
};
const res = jsonPost(`${BASE}/api/vendors`, payload, accessToken, { name: 'POST /api/vendors' });
createDuration.add(res.timings.duration);
const ok = check(res, {
'create vendor 200|201': (r) => r.status === 200 || r.status === 201,
});
if (ok) {
try { vendorId = JSON.parse(res.body).id; } catch { /* noop */ }
}
});
sleep(0.3);
if (vendorId) {
group('read_vendor', () => {
const res = http.get(`${BASE}/api/vendors/${vendorId}`, authHeaders(accessToken));
readDuration.add(res.timings.duration);
check(res, { 'read vendor 200': (r) => r.status === 200 });
});
sleep(0.3);
group('update_vendor', () => {
const res = jsonPut(`${BASE}/api/vendors/${vendorId}`, {
name: `Updated Vendor ${__VU}`,
}, accessToken, { name: 'PUT /api/vendors/:id' });
updateDuration.add(res.timings.duration);
check(res, { 'update vendor 200': (r) => r.status === 200 });
});
}
});
sleep(0.5);
// ── 4. Journal Entries workflow ─────────────────────────────────────
let journalEntryId = null;
group('journal_entries', () => {
group('list_journal_entries', () => {
const res = http.get(`${BASE}/api/journal-entries`, authHeaders(accessToken));
listDuration.add(res.timings.duration);
check(res, { 'list JE 200': (r) => r.status === 200 });
});
sleep(0.3);
// Fetch accounts first so we can build a valid entry
let accounts = [];
group('fetch_accounts_for_je', () => {
const res = http.get(`${BASE}/api/accounts`, authHeaders(accessToken));
check(res, { 'list accounts 200': (r) => r.status === 200 });
try { accounts = JSON.parse(res.body); } catch { /* noop */ }
});
sleep(0.3);
if (Array.isArray(accounts) && accounts.length >= 2) {
group('create_journal_entry', () => {
const payload = {
date: new Date().toISOString().slice(0, 10),
memo: `Load test JE VU-${__VU}`,
type: 'standard',
lines: [
{ accountId: accounts[0].id, debit: 100, credit: 0, memo: 'debit leg' },
{ accountId: accounts[1].id, debit: 0, credit: 100, memo: 'credit leg' },
],
};
const res = jsonPost(`${BASE}/api/journal-entries`, payload, accessToken, { name: 'POST /api/journal-entries' });
createDuration.add(res.timings.duration);
const ok = check(res, {
'create JE 200|201': (r) => r.status === 200 || r.status === 201,
});
if (ok) {
try { journalEntryId = JSON.parse(res.body).id; } catch { /* noop */ }
}
});
sleep(0.3);
if (journalEntryId) {
group('read_journal_entry', () => {
const res = http.get(`${BASE}/api/journal-entries/${journalEntryId}`, authHeaders(accessToken));
readDuration.add(res.timings.duration);
check(res, { 'read JE 200': (r) => r.status === 200 });
});
sleep(0.3);
// Post (finalize) the journal entry
group('post_journal_entry', () => {
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/post`, null, authHeaders(accessToken));
updateDuration.add(res.timings.duration);
check(res, { 'post JE 200': (r) => r.status === 200 });
});
sleep(0.3);
// Void the journal entry (cleanup)
group('void_journal_entry', () => {
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/void`, null, authHeaders(accessToken));
deleteDuration.add(res.timings.duration);
check(res, { 'void JE 200': (r) => r.status === 200 });
});
}
}
});
sleep(0.5);
// ── 5. Payments CRUD ────────────────────────────────────────────────
let paymentId = null;
group('payments_crud', () => {
group('list_payments', () => {
const res = http.get(`${BASE}/api/payments`, authHeaders(accessToken));
listDuration.add(res.timings.duration);
check(res, { 'list payments 200': (r) => r.status === 200 });
});
sleep(0.3);
group('create_payment', () => {
const payload = {
amount: 150.00,
date: new Date().toISOString().slice(0, 10),
method: 'check',
memo: `Load test payment VU-${__VU}`,
};
const res = jsonPost(`${BASE}/api/payments`, payload, accessToken, { name: 'POST /api/payments' });
createDuration.add(res.timings.duration);
const ok = check(res, {
'create payment 200|201': (r) => r.status === 200 || r.status === 201,
});
if (ok) {
try { paymentId = JSON.parse(res.body).id; } catch { /* noop */ }
}
});
sleep(0.3);
if (paymentId) {
group('read_payment', () => {
const res = http.get(`${BASE}/api/payments/${paymentId}`, authHeaders(accessToken));
readDuration.add(res.timings.duration);
check(res, { 'read payment 200': (r) => r.status === 200 });
});
sleep(0.3);
group('update_payment', () => {
const res = jsonPut(`${BASE}/api/payments/${paymentId}`, {
memo: `Updated payment VU-${__VU}`,
}, accessToken, { name: 'PUT /api/payments/:id' });
updateDuration.add(res.timings.duration);
check(res, { 'update payment 200': (r) => r.status === 200 });
});
sleep(0.3);
group('delete_payment', () => {
const res = http.del(`${BASE}/api/payments/${paymentId}`, null, authHeaders(accessToken));
deleteDuration.add(res.timings.duration);
check(res, { 'delete payment 200': (r) => r.status === 200 });
});
}
});
sleep(0.5);
// ── 6. Reports (read-heavy) ─────────────────────────────────────────
group('reports_read', () => {
const now = new Date();
const year = now.getFullYear();
const toDate = now.toISOString().slice(0, 10);
const fromDate = `${year}-01-01`;
const responses = http.batch([
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/cash-flow?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/accounts/trial-balance?asOfDate=${toDate}`, null, authHeaders(accessToken)],
]);
responses.forEach((res, i) => {
readDuration.add(res.timings.duration);
check(res, { [`report_${i} status 200`]: (r) => r.status === 200 });
});
});
sleep(1); // pacing
}

15
load-tests/user-pool.csv Normal file
View File

@@ -0,0 +1,15 @@
email,password,orgId,role
treasurer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,treasurer
treasurer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,treasurer
treasurer03@loadtest.hoaledgeriq.com,LoadTest123!,org-003,treasurer
admin01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,admin
admin02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,admin
president01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,president
president02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,president
manager01@loadtest.hoaledgeriq.com,LoadTest123!,org-003,manager
manager02@loadtest.hoaledgeriq.com,LoadTest123!,org-004,manager
viewer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,viewer
viewer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,viewer
homeowner01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,homeowner
homeowner02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,homeowner
member01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,member_at_large
1 email password orgId role
2 treasurer01@loadtest.hoaledgeriq.com LoadTest123! org-001 treasurer
3 treasurer02@loadtest.hoaledgeriq.com LoadTest123! org-002 treasurer
4 treasurer03@loadtest.hoaledgeriq.com LoadTest123! org-003 treasurer
5 admin01@loadtest.hoaledgeriq.com LoadTest123! org-001 admin
6 admin02@loadtest.hoaledgeriq.com LoadTest123! org-002 admin
7 president01@loadtest.hoaledgeriq.com LoadTest123! org-001 president
8 president02@loadtest.hoaledgeriq.com LoadTest123! org-002 president
9 manager01@loadtest.hoaledgeriq.com LoadTest123! org-003 manager
10 manager02@loadtest.hoaledgeriq.com LoadTest123! org-004 manager
11 viewer01@loadtest.hoaledgeriq.com LoadTest123! org-001 viewer
12 viewer02@loadtest.hoaledgeriq.com LoadTest123! org-002 viewer
13 homeowner01@loadtest.hoaledgeriq.com LoadTest123! org-001 homeowner
14 homeowner02@loadtest.hoaledgeriq.com LoadTest123! org-002 homeowner
15 member01@loadtest.hoaledgeriq.com LoadTest123! org-001 member_at_large

View File

@@ -12,6 +12,9 @@
#
# Replace "app.yourdomain.com" with your actual hostname throughout this file.
# Hide nginx version from Server header
server_tokens off;
# --- 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;
@@ -49,6 +52,12 @@ server {
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Security headers — applied to all routes
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# --- Proxy defaults ---
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@@ -8,6 +8,9 @@ upstream frontend {
keepalive 16;
}
# Hide nginx version from Server header
server_tokens off;
# Shared proxy settings
proxy_http_version 1.1;
proxy_set_header Connection ""; # enable keepalive to upstreams
@@ -30,6 +33,12 @@ server {
listen 80;
server_name _;
# Security headers — applied to all routes at the nginx layer
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# --- API routes → backend ---
location /api/ {
limit_req zone=api_limit burst=30 nodelay;

View File

@@ -0,0 +1,788 @@
#!/usr/bin/env tsx
/**
* Test Data Cleanup Utility
*
* Interactive CLI for managing test organizations, users, and tenant data.
* Supports listing, selective deletion, full purge, and re-seeding.
*
* Usage:
* cd scripts
* npx tsx cleanup-test-data.ts <command> [options]
*
* Commands:
* list Show all organizations and users
* delete-org <name-or-id> Delete an organization (drops tenant schema + shared data)
* delete-user <email-or-id> Delete a user (cascades through all related tables)
* purge-all Remove ALL orgs/users except platform owner
* reseed Purge all, then re-run db/seed/seed.sql
*
* Options:
* --dry-run Show what would be deleted without executing
* --force Skip confirmation prompts
*
* Environment:
* DATABASE_URL - PostgreSQL connection string (reads from ../.env)
*/
import * as dotenv from 'dotenv';
import { resolve } from 'path';
import { readFileSync } from 'fs';
import { Pool } from 'pg';
import * as readline from 'readline';
// ── Load environment ────────────────────────────────────────────────────────
dotenv.config({ path: resolve(__dirname, '..', '.env') });
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error(red('✗ DATABASE_URL not set. Check your .env file.'));
process.exit(1);
}
// ── CLI colors ──────────────────────────────────────────────────────────────
function red(s: string): string { return `\x1b[31m${s}\x1b[0m`; }
function green(s: string): string { return `\x1b[32m${s}\x1b[0m`; }
function yellow(s: string): string { return `\x1b[33m${s}\x1b[0m`; }
function cyan(s: string): string { return `\x1b[36m${s}\x1b[0m`; }
function bold(s: string): string { return `\x1b[1m${s}\x1b[0m`; }
function dim(s: string): string { return `\x1b[2m${s}\x1b[0m`; }
// ── CLI argument parsing ────────────────────────────────────────────────────
const args = process.argv.slice(2);
const command = args.find(a => !a.startsWith('--')) || '';
const target = args.filter(a => !a.startsWith('--')).slice(1).join(' ');
const dryRun = args.includes('--dry-run');
const force = args.includes('--force');
// ── Helpers ─────────────────────────────────────────────────────────────────
function isUUID(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
function padRight(s: string, len: number): string {
return s.length >= len ? s.substring(0, len) : s + ' '.repeat(len - s.length);
}
function truncate(s: string, len: number): string {
return s.length > len ? s.substring(0, len - 1) + '…' : s;
}
function formatDate(d: Date | string | null): string {
if (!d) return '—';
const date = typeof d === 'string' ? new Date(d) : d;
return date.toISOString().split('T')[0];
}
async function confirm(prompt: string): Promise<boolean> {
if (force) return true;
if (dryRun) return false;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`${prompt} [y/N]: `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === 'y');
});
});
}
function logDryRun(sql: string): void {
console.log(dim(` [DRY RUN] ${sql}`));
}
// ── Database pool ───────────────────────────────────────────────────────────
const pool = new Pool({ connectionString: DATABASE_URL });
async function query(sql: string, params?: any[]): Promise<any[]> {
const result = await pool.query(sql, params);
return result.rows;
}
// ── List command ────────────────────────────────────────────────────────────
async function listAll(): Promise<void> {
console.log(bold('\n📋 Organizations\n'));
const orgs = await query(`
SELECT
o.id, o.name, o.schema_name, o.status, o.plan_level,
o.billing_interval, o.collection_method,
o.stripe_customer_id, o.stripe_subscription_id,
o.trial_ends_at, o.created_at,
COUNT(uo.id) AS user_count
FROM shared.organizations o
LEFT JOIN shared.user_organizations uo ON uo.organization_id = o.id
GROUP BY o.id
ORDER BY o.created_at
`);
if (orgs.length === 0) {
console.log(dim(' No organizations found.\n'));
} else {
// Header
console.log(
' ' +
padRight('Name', 30) +
padRight('Status', 12) +
padRight('Plan', 16) +
padRight('Billing', 10) +
padRight('Users', 7) +
padRight('Stripe Customer', 22) +
'Created'
);
console.log(' ' + '─'.repeat(110));
for (const o of orgs) {
const statusColor = o.status === 'active' ? green : o.status === 'trial' ? cyan : o.status === 'past_due' ? yellow : red;
console.log(
' ' +
padRight(truncate(o.name, 28), 30) +
padRight(statusColor(o.status), 12 + 9) + // +9 for ANSI escape codes
padRight(`${o.plan_level}/${o.billing_interval || 'month'}`, 16) +
padRight(String(o.user_count), 7) +
padRight(o.stripe_customer_id ? truncate(o.stripe_customer_id, 20) : '—', 22) +
formatDate(o.created_at)
);
}
console.log(dim(`\n ${orgs.length} organization(s) total`));
// Show IDs for reference
console.log(dim('\n IDs:'));
for (const o of orgs) {
console.log(dim(` ${o.name}: ${o.id}`));
console.log(dim(` schema: ${o.schema_name}`));
}
}
console.log(bold('\n👤 Users\n'));
const users = await query(`
SELECT
u.id, u.email, u.first_name, u.last_name,
u.is_superadmin, u.is_platform_owner,
u.last_login_at, u.created_at,
COALESCE(
STRING_AGG(
o.name || ' (' || uo.role || ')',
', '
),
'—'
) AS memberships,
COUNT(uo.id) AS org_count
FROM shared.users u
LEFT JOIN shared.user_organizations uo ON uo.user_id = u.id
LEFT JOIN shared.organizations o ON o.id = uo.organization_id
GROUP BY u.id
ORDER BY u.created_at
`);
if (users.length === 0) {
console.log(dim(' No users found.\n'));
} else {
// Header
console.log(
' ' +
padRight('Email', 35) +
padRight('Name', 25) +
padRight('Flags', 18) +
padRight('Orgs', 6) +
'Created'
);
console.log(' ' + '─'.repeat(100));
for (const u of users) {
const flags: string[] = [];
if (u.is_platform_owner) flags.push(cyan('owner'));
if (u.is_superadmin) flags.push(yellow('super'));
const name = [u.first_name, u.last_name].filter(Boolean).join(' ') || '—';
console.log(
' ' +
padRight(truncate(u.email, 33), 35) +
padRight(truncate(name, 23), 25) +
padRight(flags.length ? flags.join(', ') : '—', 18 + (flags.length * 9)) +
padRight(String(u.org_count), 6) +
formatDate(u.created_at)
);
}
console.log(dim(`\n ${users.length} user(s) total`));
// Show memberships
console.log(dim('\n Memberships:'));
for (const u of users) {
console.log(dim(` ${u.email}: ${u.memberships}`));
}
}
// Tenant schemas
console.log(bold('\n🗄 Tenant Schemas\n'));
const schemas = await query(`
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
ORDER BY schema_name
`);
if (schemas.length === 0) {
console.log(dim(' No tenant schemas found.\n'));
} else {
for (const s of schemas) {
console.log(`${s.schema_name}`);
}
console.log(dim(`\n ${schemas.length} tenant schema(s) total\n`));
}
}
// ── Delete organization ─────────────────────────────────────────────────────
async function deleteOrg(identifier: string): Promise<void> {
if (!identifier) {
console.error(red('✗ Please provide an organization name or ID.'));
console.log(' Usage: npx tsx cleanup-test-data.ts delete-org <name-or-id>');
process.exit(1);
}
// Look up org
const whereClause = isUUID(identifier) ? 'id = $1' : 'LOWER(name) = LOWER($1)';
const orgs = await query(
`SELECT id, name, schema_name, status, stripe_customer_id, stripe_subscription_id
FROM shared.organizations WHERE ${whereClause}`,
[identifier]
);
if (orgs.length === 0) {
console.error(red(`✗ Organization not found: ${identifier}`));
process.exit(1);
}
const org = orgs[0];
// Show what will be deleted
console.log(bold(`\n🏢 Delete Organization: ${org.name}\n`));
console.log(` ID: ${org.id}`);
console.log(` Schema: ${org.schema_name}`);
console.log(` Status: ${org.status}`);
if (org.stripe_customer_id) {
console.log(yellow(`\n ⚠ Stripe Customer: ${org.stripe_customer_id}`));
console.log(yellow(` You should manually delete/archive this customer in the Stripe Dashboard.`));
}
if (org.stripe_subscription_id) {
console.log(yellow(` ⚠ Stripe Subscription: ${org.stripe_subscription_id}`));
console.log(yellow(` You should manually cancel this subscription in the Stripe Dashboard.`));
}
// Count related data
const userCount = (await query(
'SELECT COUNT(*) as cnt FROM shared.user_organizations WHERE organization_id = $1',
[org.id]
))[0].cnt;
const inviteCount = (await query(
'SELECT COUNT(*) as cnt FROM shared.invitations WHERE organization_id = $1',
[org.id]
))[0].cnt;
// Check if tenant schema exists
const schemaExists = (await query(
`SELECT COUNT(*) as cnt FROM information_schema.schemata WHERE schema_name = $1`,
[org.schema_name]
))[0].cnt > 0;
console.log(`\n Will delete:`);
console.log(` • Organization record from shared.organizations`);
console.log(`${userCount} user-organization membership(s) (users themselves are preserved)`);
console.log(`${inviteCount} invitation(s)`);
if (schemaExists) {
console.log(red(` • DROP SCHEMA ${org.schema_name} CASCADE (all tenant financial data)`));
} else {
console.log(dim(` • Schema ${org.schema_name} does not exist (skip)`));
}
console.log(` • Related rows in: onboarding_progress, stripe_events, email_log`);
if (dryRun) {
console.log(yellow('\n [DRY RUN] No changes made.\n'));
logDryRun(`DROP SCHEMA IF EXISTS ${org.schema_name} CASCADE`);
logDryRun(`DELETE FROM shared.onboarding_progress WHERE organization_id = '${org.id}'`);
logDryRun(`DELETE FROM shared.stripe_events WHERE ... (related to org)`);
logDryRun(`DELETE FROM shared.organizations WHERE id = '${org.id}'`);
return;
}
const confirmed = await confirm(red(`\n This is destructive and cannot be undone. Proceed?`));
if (!confirmed) {
console.log(dim(' Aborted.\n'));
return;
}
// Execute deletion
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Drop tenant schema
if (schemaExists) {
console.log(` Dropping schema ${org.schema_name}...`);
await client.query(`DROP SCHEMA IF EXISTS "${org.schema_name}" CASCADE`);
}
// 2. Clean up shared tables with org FK
await client.query('DELETE FROM shared.onboarding_progress WHERE organization_id = $1', [org.id]);
await client.query('DELETE FROM shared.invitations WHERE organization_id = $1', [org.id]);
// 3. Delete organization (cascades to user_organizations, invite_tokens)
await client.query('DELETE FROM shared.organizations WHERE id = $1', [org.id]);
await client.query('COMMIT');
console.log(green(`\n ✓ Organization "${org.name}" and schema "${org.schema_name}" deleted successfully.\n`));
} catch (err) {
await client.query('ROLLBACK');
console.error(red(`\n ✗ Error deleting organization: ${(err as Error).message}\n`));
throw err;
} finally {
client.release();
}
}
// ── Delete user ─────────────────────────────────────────────────────────────
async function deleteUser(identifier: string): Promise<void> {
if (!identifier) {
console.error(red('✗ Please provide a user email or ID.'));
console.log(' Usage: npx tsx cleanup-test-data.ts delete-user <email-or-id>');
process.exit(1);
}
const whereClause = isUUID(identifier) ? 'id = $1' : 'LOWER(email) = LOWER($1)';
const users = await query(
`SELECT id, email, first_name, last_name, is_superadmin, is_platform_owner
FROM shared.users WHERE ${whereClause}`,
[identifier]
);
if (users.length === 0) {
console.error(red(`✗ User not found: ${identifier}`));
process.exit(1);
}
const user = users[0];
const name = [user.first_name, user.last_name].filter(Boolean).join(' ') || '(no name)';
// Platform owner protection
if (user.is_platform_owner) {
console.error(red(`\n ✗ Cannot delete platform owner: ${user.email}`));
console.error(red(' The platform owner account is protected and cannot be removed.\n'));
process.exit(1);
}
console.log(bold(`\n👤 Delete User: ${user.email}\n`));
console.log(` ID: ${user.id}`);
console.log(` Name: ${name}`);
if (user.is_superadmin) {
console.log(yellow(' ⚠ This user is a SUPERADMIN'));
}
// Count related data
const memberships = await query(
`SELECT o.name, uo.role FROM shared.user_organizations uo
JOIN shared.organizations o ON o.id = uo.organization_id
WHERE uo.user_id = $1`,
[user.id]
);
const tokenCounts = {
refresh: (await query('SELECT COUNT(*) as cnt FROM shared.refresh_tokens WHERE user_id = $1', [user.id]))[0].cnt,
passkeys: (await query('SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1', [user.id]))[0].cnt,
loginHistory: (await query('SELECT COUNT(*) as cnt FROM shared.login_history WHERE user_id = $1', [user.id]))[0].cnt,
};
console.log(`\n Will delete:`);
console.log(` • User record from shared.users`);
console.log(`${memberships.length} org membership(s):`);
for (const m of memberships) {
console.log(` ${m.name} (${m.role})`);
}
console.log(`${tokenCounts.refresh} refresh token(s)`);
console.log(`${tokenCounts.passkeys} passkey(s)`);
console.log(`${tokenCounts.loginHistory} login history record(s)`);
console.log(` • Related: password_reset_tokens, invite_tokens (cascade)`);
if (dryRun) {
console.log(yellow('\n [DRY RUN] No changes made.\n'));
logDryRun(`DELETE FROM shared.users WHERE id = '${user.id}'`);
return;
}
const confirmMsg = user.is_superadmin
? red(`\n ⚠ This is a SUPERADMIN account. Are you SURE you want to delete it?`)
: red(`\n This is destructive and cannot be undone. Proceed?`);
const confirmed = await confirm(confirmMsg);
if (!confirmed) {
console.log(dim(' Aborted.\n'));
return;
}
// Execute deletion (cascade handles related tables)
await query('DELETE FROM shared.users WHERE id = $1', [user.id]);
console.log(green(`\n ✓ User "${user.email}" deleted successfully.\n`));
}
// ── Purge all ───────────────────────────────────────────────────────────────
async function purgeAll(): Promise<void> {
console.log(bold('\n🔥 Purge All Test Data\n'));
// Gather current state
const orgs = await query(
`SELECT id, name, schema_name, stripe_customer_id, stripe_subscription_id
FROM shared.organizations ORDER BY name`
);
const userCount = (await query(
'SELECT COUNT(*) as cnt FROM shared.users WHERE is_platform_owner = false'
))[0].cnt;
const platformOwner = (await query(
'SELECT email FROM shared.users WHERE is_platform_owner = true'
));
const schemas = await query(
`SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name`
);
// Stripe warnings
const stripeOrgs = orgs.filter((o: any) => o.stripe_customer_id || o.stripe_subscription_id);
console.log(` This will:`);
console.log(red(` • Drop ${schemas.length} tenant schema(s):`));
for (const s of schemas) {
console.log(red(` ${s.schema_name}`));
}
console.log(red(` • Delete ${orgs.length} organization(s):`));
for (const o of orgs) {
console.log(red(` ${o.name}`));
}
console.log(red(` • Delete ${userCount} non-owner user(s)`));
console.log(` • Truncate: user_organizations, invitations, refresh_tokens,`);
console.log(` password_reset_tokens, invite_tokens, user_passkeys,`);
console.log(` login_history, ai_recommendation_log, stripe_events,`);
console.log(` onboarding_progress, email_log`);
console.log(green(` • Preserve: platform owner (${platformOwner.length ? platformOwner[0].email : 'none found'})`));
console.log(green(` • Preserve: cd_rates (market data)`));
if (stripeOrgs.length > 0) {
console.log(yellow('\n ⚠ Stripe data that should be cleaned up manually:'));
for (const o of stripeOrgs) {
if (o.stripe_customer_id) {
console.log(yellow(` Customer: ${o.stripe_customer_id} (${o.name})`));
}
if (o.stripe_subscription_id) {
console.log(yellow(` Subscription: ${o.stripe_subscription_id} (${o.name})`));
}
}
}
if (dryRun) {
console.log(yellow('\n [DRY RUN] No changes made.\n'));
for (const s of schemas) {
logDryRun(`DROP SCHEMA "${s.schema_name}" CASCADE`);
}
logDryRun('TRUNCATE shared.user_organizations, shared.invitations, ...');
logDryRun('DELETE FROM shared.organizations');
logDryRun("DELETE FROM shared.users WHERE is_platform_owner = false");
return;
}
const confirmed = await confirm(red(`\n ⚠ THIS WILL DESTROY ALL DATA. Are you absolutely sure?`));
if (!confirmed) {
console.log(dim(' Aborted.\n'));
return;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Drop all tenant schemas
for (const s of schemas) {
console.log(` Dropping schema ${s.schema_name}...`);
await client.query(`DROP SCHEMA IF EXISTS "${s.schema_name}" CASCADE`);
}
// 2. Truncate shared junction/log tables (order matters for FK constraints)
console.log(' Truncating shared tables...');
// Tables with FK to users AND organizations — truncate first
await client.query('DELETE FROM shared.user_organizations');
await client.query('DELETE FROM shared.invitations');
await client.query('DELETE FROM shared.invite_tokens');
await client.query('DELETE FROM shared.onboarding_progress');
// Tables with FK to users only
await client.query('DELETE FROM shared.refresh_tokens');
await client.query('DELETE FROM shared.password_reset_tokens');
await client.query('DELETE FROM shared.user_passkeys');
await client.query('DELETE FROM shared.login_history');
// Tables with FK to organizations (ON DELETE SET NULL)
await client.query('DELETE FROM shared.ai_recommendation_log');
await client.query('DELETE FROM shared.stripe_events');
await client.query('DELETE FROM shared.email_log');
// 3. Delete organizations
console.log(' Deleting organizations...');
await client.query('DELETE FROM shared.organizations');
// 4. Delete non-owner users
console.log(' Deleting non-owner users...');
await client.query('DELETE FROM shared.users WHERE is_platform_owner = false');
await client.query('COMMIT');
console.log(green(`\n ✓ Purge complete.`));
console.log(green(` Dropped ${schemas.length} schema(s), deleted ${orgs.length} org(s), deleted ${userCount} user(s).`));
if (platformOwner.length) {
console.log(green(` Platform owner preserved: ${platformOwner[0].email}\n`));
}
} catch (err) {
await client.query('ROLLBACK');
console.error(red(`\n ✗ Error during purge: ${(err as Error).message}\n`));
throw err;
} finally {
client.release();
}
}
// ── Reseed ──────────────────────────────────────────────────────────────────
async function reseed(): Promise<void> {
console.log(bold('\n🌱 Purge All + Re-Seed\n'));
console.log(' This will purge all test data and then run db/seed/seed.sql');
console.log(' to restore the default test environment.\n');
if (!dryRun && !force) {
const confirmed = await confirm(red(' This will destroy all data and re-seed. Proceed?'));
if (!confirmed) {
console.log(dim(' Aborted.\n'));
return;
}
// Set force for the inner purge to avoid double-prompting
(global as any).__forceOverride = true;
}
// Run purge
const origForce = force;
try {
// Temporarily force purge to skip its own confirmation
if (!dryRun) {
Object.defineProperty(global, '__forceOverride', { value: true, writable: true, configurable: true });
}
await purgeAllInternal();
} finally {
delete (global as any).__forceOverride;
}
if (dryRun) {
logDryRun('Execute db/seed/seed.sql');
console.log(yellow('\n [DRY RUN] No changes made.\n'));
return;
}
// Run seed SQL
console.log('\n Running seed script...');
const seedPath = resolve(__dirname, '..', 'db', 'seed', 'seed.sql');
let seedSql: string;
try {
seedSql = readFileSync(seedPath, 'utf-8');
} catch (err) {
console.error(red(` ✗ Could not read seed file: ${seedPath}`));
console.error(red(` ${(err as Error).message}\n`));
process.exit(1);
}
const client = await pool.connect();
try {
await client.query(seedSql);
console.log(green(`\n ✓ Re-seed complete. Database restored to seed state.\n`));
} catch (err) {
console.error(red(`\n ✗ Error running seed: ${(err as Error).message}\n`));
throw err;
} finally {
client.release();
}
}
/**
* Internal purge that respects __forceOverride to skip confirmation
* when called from reseed().
*/
async function purgeAllInternal(): Promise<void> {
const orgs = await query(
`SELECT id, name, schema_name, stripe_customer_id, stripe_subscription_id
FROM shared.organizations ORDER BY name`
);
const userCount = (await query(
'SELECT COUNT(*) as cnt FROM shared.users WHERE is_platform_owner = false'
))[0].cnt;
const platformOwner = await query(
'SELECT email FROM shared.users WHERE is_platform_owner = true'
);
const schemas = await query(
`SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name`
);
const stripeOrgs = orgs.filter((o: any) => o.stripe_customer_id || o.stripe_subscription_id);
if (stripeOrgs.length > 0) {
console.log(yellow(' ⚠ Stripe data that should be cleaned up manually:'));
for (const o of stripeOrgs) {
if (o.stripe_customer_id) console.log(yellow(` Customer: ${o.stripe_customer_id} (${o.name})`));
if (o.stripe_subscription_id) console.log(yellow(` Subscription: ${o.stripe_subscription_id} (${o.name})`));
}
}
if (dryRun) {
for (const s of schemas) {
logDryRun(`DROP SCHEMA "${s.schema_name}" CASCADE`);
}
logDryRun('DELETE FROM shared tables...');
logDryRun('DELETE FROM shared.organizations');
logDryRun("DELETE FROM shared.users WHERE is_platform_owner = false");
return;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const s of schemas) {
console.log(` Dropping schema ${s.schema_name}...`);
await client.query(`DROP SCHEMA IF EXISTS "${s.schema_name}" CASCADE`);
}
console.log(' Truncating shared tables...');
await client.query('DELETE FROM shared.user_organizations');
await client.query('DELETE FROM shared.invitations');
await client.query('DELETE FROM shared.invite_tokens');
await client.query('DELETE FROM shared.onboarding_progress');
await client.query('DELETE FROM shared.refresh_tokens');
await client.query('DELETE FROM shared.password_reset_tokens');
await client.query('DELETE FROM shared.user_passkeys');
await client.query('DELETE FROM shared.login_history');
await client.query('DELETE FROM shared.ai_recommendation_log');
await client.query('DELETE FROM shared.stripe_events');
await client.query('DELETE FROM shared.email_log');
console.log(' Deleting organizations...');
await client.query('DELETE FROM shared.organizations');
console.log(' Deleting non-owner users...');
await client.query('DELETE FROM shared.users WHERE is_platform_owner = false');
await client.query('COMMIT');
console.log(green(` ✓ Purged ${schemas.length} schema(s), ${orgs.length} org(s), ${userCount} user(s).`));
if (platformOwner.length) {
console.log(green(` Platform owner preserved: ${platformOwner[0].email}`));
}
} catch (err) {
await client.query('ROLLBACK');
console.error(red(` ✗ Error during purge: ${(err as Error).message}`));
throw err;
} finally {
client.release();
}
}
// ── Help ────────────────────────────────────────────────────────────────────
function showHelp(): void {
console.log(`
${bold('HOA LedgerIQ — Test Data Cleanup Utility')}
${bold('Usage:')}
npx tsx cleanup-test-data.ts <command> [target] [options]
${bold('Commands:')}
${cyan('list')} Show all organizations, users, and tenant schemas
${cyan('delete-org')} <name-or-id> Delete an organization and its tenant schema
${cyan('delete-user')} <email-or-id> Delete a user and all related data
${cyan('purge-all')} Remove ALL data except the platform owner
${cyan('reseed')} Purge all, then re-run db/seed/seed.sql
${bold('Options:')}
${dim('--dry-run')} Show what would happen without making changes
${dim('--force')} Skip confirmation prompts
${bold('Examples:')}
npx tsx cleanup-test-data.ts list
npx tsx cleanup-test-data.ts delete-org "Sunrise Valley HOA"
npx tsx cleanup-test-data.ts delete-org 550e8400-e29b-41d4-a716-446655440000
npx tsx cleanup-test-data.ts delete-user admin@sunrisevalley.org
npx tsx cleanup-test-data.ts delete-user admin@sunrisevalley.org --dry-run
npx tsx cleanup-test-data.ts purge-all --dry-run
npx tsx cleanup-test-data.ts reseed --force
${bold('Safety:')}
• Platform owner account (is_platform_owner=true) is ${green('never deleted')}
• Superadmin deletions require extra confirmation
• Stripe customer/subscription IDs are shown as warnings for manual cleanup
• cd_rates market data is ${green('always preserved')}
`);
}
// ── Main ────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
if (dryRun) {
console.log(yellow('\n ── DRY RUN MODE ── No changes will be made ──\n'));
}
try {
switch (command) {
case 'list':
await listAll();
break;
case 'delete-org':
await deleteOrg(target);
break;
case 'delete-user':
await deleteUser(target);
break;
case 'purge-all':
await purgeAll();
break;
case 'reseed':
await reseed();
break;
case 'help':
case '--help':
case '-h':
showHelp();
break;
default:
if (command) {
console.error(red(`\n ✗ Unknown command: ${command}\n`));
}
showHelp();
process.exit(command ? 1 : 0);
}
} catch (err) {
console.error(red(`\nFatal error: ${(err as Error).message}`));
process.exit(1);
} finally {
await pool.end();
}
}
main();

View File

@@ -4,7 +4,8 @@
"private": true,
"description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)",
"scripts": {
"fetch-cd-rates": "tsx fetch-cd-rates.ts"
"fetch-cd-rates": "tsx fetch-cd-rates.ts",
"cleanup": "tsx cleanup-test-data.ts"
},
"dependencies": {
"dotenv": "^16.4.7",