Compare commits
119 Commits
a550a8d0be
...
feature-de
| Author | SHA1 | Date | |
|---|---|---|---|
| a025c9e979 | |||
| 19bd19b0c4 | |||
| 3e7463cf46 | |||
| 2aad137bd7 | |||
| e06ca74d1d | |||
| 95c83a57b6 | |||
| 83115c9b5c | |||
| c57dd3e155 | |||
| afe5633b0a | |||
| 43b10869f0 | |||
| f76c67f51a | |||
| 5fec296569 | |||
| c981676bc7 | |||
|
|
bd174fc22b | ||
| 827eef4f49 | |||
|
|
4797669591 | ||
| 629d112850 | |||
| 32506d6a2e | |||
| 9a60970837 | |||
| 1ade446187 | |||
|
|
d430b96b51 | ||
|
|
140cd7acb7 | ||
| 2f6297ae68 | |||
| 121b8138e3 | |||
| 2b331bb3ef | |||
| ae856bfb2f | |||
| 31f8274b8d | |||
| 06bc0181f8 | |||
| 66e2f87a96 | |||
| db8b520009 | |||
| e2d72223c8 | |||
| a996208cb8 | |||
| 5845334454 | |||
| 170461c359 | |||
| aacec1cce3 | |||
| 6b12fcd7d7 | |||
| 8e58d04568 | |||
| c2e52bee64 | |||
| 9cd641923d | |||
| 8abab40778 | |||
| 19fb2c037c | |||
| e62f3e7b07 | |||
| af68304692 | |||
| 20438b7ef5 | |||
| e3022f20c5 | |||
| e9738420ea | |||
| dfcd172ef3 | |||
| 9cd20a1867 | |||
| 420227d70c | |||
| e893319cfe | |||
| 93eeacfe8f | |||
| 17bdebfb52 | |||
| 267d92933e | |||
| 159c59734e | |||
| 7ba5c414b1 | |||
| a98a7192bb | |||
| 1d1073cba1 | |||
| cf061c1505 | |||
| 5ebfc4f3aa | |||
| f20f54b128 | |||
| f2b0b57535 | |||
| e6fe2314de | |||
| c8d77aaa48 | |||
| b13fbfe8c7 | |||
| 280a5996f6 | |||
| 9a082d2950 | |||
| 82433955bd | |||
| 8e2456dcae | |||
| 1acd8c3bff | |||
| 2de0cde94c | |||
| 94c7c90b91 | |||
| f47fbfcf93 | |||
| 04771f370c | |||
| 208c1dd7bc | |||
| 61a4f27af4 | |||
| a047144922 | |||
| 508a86d16c | |||
| 16e1ada261 | |||
| 6bd080f8c4 | |||
| be3a5191c5 | |||
| b0282b7f8b | |||
| ac72905ecb | |||
| 7d4df25d16 | |||
| 538828b91a | |||
| 14160854b9 | |||
| 36d486d78c | |||
| 9d137a40d3 | |||
| 3bf6b8c6c9 | |||
| 4759374883 | |||
| cb6e34d5ce | |||
| 2b72951e66 | |||
| 69dad7cc74 | |||
| efa5aca35f | |||
| 2b83defbc3 | |||
| a59dac7fe1 | |||
| 1e31595d7f | |||
| c429dcc033 | |||
| 9146118df1 | |||
| 07d15001ae | |||
| a0b366e94a | |||
| 3790a3bd9e | |||
| 0a07c61ca3 | |||
| 337b6061b2 | |||
| 467fdd2a6c | |||
| c12ad94b7f | |||
| 05e241c792 | |||
| 5ee4c71fc1 | |||
| 81908e48ea | |||
| 6230558b91 | |||
| 2c215353d4 | |||
| d526025926 | |||
| 411239bea4 | |||
| 7e6c4c16ce | |||
| ea0e3d6f29 | |||
| 8db89373e0 | |||
| e719f593de | |||
| 16adfd6f26 | |||
| 704f29362a | |||
| 42767e3119 |
29
.env.example
29
.env.example
@@ -12,3 +12,32 @@ AI_API_KEY=your_nvidia_api_key_here
|
|||||||
AI_MODEL=qwen/qwen3.5-397b-a17b
|
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
# Set to 'true' to enable detailed AI prompt/response logging
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
AI_DEBUG=false
|
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
|
||||||
|
NEW_RELIC_APP_NAME=HOALedgerIQ_App
|
||||||
|
|||||||
65
.gitea/workflows/deploy.yml
Normal file
65
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Production Deployment Workflow for HOA LedgerIQ
|
||||||
|
#
|
||||||
|
# Trigger: Manual only (workflow_dispatch) — production deploys are intentional.
|
||||||
|
# Runner: Self-hosted on the production server at /opt/hoa-ledgeriq.
|
||||||
|
#
|
||||||
|
# This workflow does NOT use actions/checkout. The runner operates directly
|
||||||
|
# on the production directory. The deploy script itself handles git pull.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
seed_existing:
|
||||||
|
description: "Mark existing migrations as applied without running them (first deployment only)"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: /opt/hoa-ledgeriq
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Pre-deploy info
|
||||||
|
run: |
|
||||||
|
echo "## Pre-Deploy Info" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Server:** $(hostname)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Directory:** $(pwd)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Current commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Branch:** $(git branch --show-current || echo 'detached')" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Triggered by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Seed existing:** ${{ inputs.seed_existing }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Started at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: Run deployment
|
||||||
|
run: |
|
||||||
|
DEPLOY_FLAGS=""
|
||||||
|
if [ "${{ inputs.seed_existing }}" = "true" ]; then
|
||||||
|
DEPLOY_FLAGS="--seed-existing"
|
||||||
|
fi
|
||||||
|
bash scripts/deploy-prod.sh $DEPLOY_FLAGS
|
||||||
|
env:
|
||||||
|
TERM: xterm
|
||||||
|
|
||||||
|
- name: Deployment result
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "## Deployment Result" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${{ job.status }}" = "success" ]; then
|
||||||
|
echo "- **Status:** Successful" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "- **Status:** FAILED (auto-rollback triggered)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commit (after rollback):** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Check the deploy log on the server for details" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
echo "- **Completed at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,6 +24,11 @@ postgres_data/
|
|||||||
redis_data/
|
redis_data/
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
|
# Database backups
|
||||||
|
backups/
|
||||||
|
*.dump
|
||||||
|
*.dump.gz
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
letsencrypt/
|
letsencrypt/
|
||||||
|
|
||||||
|
|||||||
229
CLAUDE.md
Normal file
229
CLAUDE.md
Normal 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
|
||||||
587
ONBOARDING-AND-AUTH.md
Normal file
587
ONBOARDING-AND-AUTH.md
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide
|
||||||
|
|
||||||
|
> **Version:** 2026.03.18
|
||||||
|
> **Last updated:** March 18, 2026
|
||||||
|
> **Migrations:** `db/migrations/015-saas-onboarding-auth.sql`, `db/migrations/017-billing-enhancements.sql`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [High-Level Flow](#1-high-level-flow)
|
||||||
|
2. [Stripe Billing & Checkout](#2-stripe-billing--checkout)
|
||||||
|
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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. High-Level Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Visitor hits /pricing
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Selects plan (Starter / Professional / Enterprise)
|
||||||
|
Chooses billing frequency (Monthly / Annual — 25% discount)
|
||||||
|
Enters email + business name
|
||||||
|
|
|
||||||
|
v
|
||||||
|
POST /api/billing/start-trial (no card required)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Backend creates Stripe customer + subscription with trial_period_days=14
|
||||||
|
Backend provisions: org -> schema -> user -> invite token -> email
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Frontend navigates to /onboarding/pending?session_id=xxx
|
||||||
|
(polls GET /api/billing/status every 3s)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Status returns "active" -> user is redirected to /login
|
||||||
|
|
|
||||||
|
v
|
||||||
|
User clicks activation link from email
|
||||||
|
|
|
||||||
|
v
|
||||||
|
GET /activate?token=xxx -> validates token
|
||||||
|
POST /activate -> sets password + name, issues session
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Redirect to /onboarding (4-step guided wizard)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
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 & Pricing
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### Stripe Products & Prices
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| Event | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| `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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Monthly / Annual Billing
|
||||||
|
|
||||||
|
### 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 signup
|
||||||
|
- `schema_name` = `tenant_{random_12_chars}`
|
||||||
|
- `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()`
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 ready
|
||||||
|
- `active` -- ready (includes `trial` status)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Account Activation (Magic Link)
|
||||||
|
|
||||||
|
### Validate Token
|
||||||
|
|
||||||
|
`GET /api/auth/activate?token=xxx` -- returns `{ valid, email, orgName, orgId, userId }`
|
||||||
|
|
||||||
|
### Activate Account
|
||||||
|
|
||||||
|
`POST /api/auth/activate` -- body `{ token, password, fullName }` -- sets password, issues session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Guided Onboarding Checklist
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### Session Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `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 session |
|
||||||
|
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
|
||||||
|
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Multi-Factor Authentication (TOTP)
|
||||||
|
|
||||||
|
### MFA Endpoints
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Single Sign-On (SSO)
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
SSO providers are conditionally loaded based on env vars.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Passkeys (WebAuthn)
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Environment Variables Reference
|
||||||
|
|
||||||
|
### Stripe (Required for billing)
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
Legacy single-price vars (`STRIPE_STARTER_PRICE_ID`, etc.) are still supported as fallback for monthly prices.
|
||||||
|
|
||||||
|
### Trial Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `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 |
|
||||||
|
| `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin |
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Manual Intervention & Ops Tasks
|
||||||
|
|
||||||
|
### Stripe Dashboard Setup
|
||||||
|
|
||||||
|
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 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. **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`, `JWT_SECRET`, `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_ORIGIN`
|
||||||
|
|
||||||
|
5. **Configure SSO providers** (optional)
|
||||||
|
|
||||||
|
### Ongoing Ops
|
||||||
|
|
||||||
|
- **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`
|
||||||
|
|
||||||
|
### Finding activation URLs (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;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. What's Stubbed vs. Production-Ready
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. API Endpoint Reference
|
||||||
|
|
||||||
|
### Billing
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `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 |
|
||||||
|
| `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
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `POST` | `/api/auth/register` | No | Register new user |
|
||||||
|
| `POST` | `/api/auth/login` | No | Login (may return MFA challenge) |
|
||||||
|
| `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 |
|
||||||
|
| `POST` | `/api/auth/activate` | No | Set password + activate |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
### Onboarding
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress |
|
||||||
|
| `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Tables & Columns
|
||||||
|
|
||||||
|
### Tables Added (Migration 015)
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `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 credentials |
|
||||||
|
| `shared.email_log` | Email audit trail |
|
||||||
|
|
||||||
|
### 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
22
PARKING-LOT.md
Normal 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
|
||||||
136
PLAN.md
Normal file
136
PLAN.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Phase 2 Bug Fix & Tweaks - Implementation Plan
|
||||||
|
|
||||||
|
## 1. Admin Panel: Tenant Creation, Contract/Plan Fields, Disable/Archive
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
- Add `contract_number VARCHAR(100)` and `plan_level VARCHAR(50) DEFAULT 'standard'` to `shared.organizations` (live DB ALTER + init SQL)
|
||||||
|
- Add `archived` to the status CHECK constraint: `('active', 'suspended', 'trial', 'archived')`
|
||||||
|
- Add to Organization entity: `contractNumber`, `planLevel` columns
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
- **admin.controller.ts**: Add two new endpoints:
|
||||||
|
- `POST /admin/tenants` — Creates org + first user + tenant schema in one call. Accepts: org name, email, address, contractNumber, planLevel, plus first user's email/password/firstName/lastName. Calls OrganizationsService.create() then sets up the user.
|
||||||
|
- `PUT /admin/organizations/:id/status` — Sets status to 'active', 'suspended', or 'archived'
|
||||||
|
- **auth.module.ts**: Import OrganizationsModule so AdminController can inject OrganizationsService
|
||||||
|
- **auth.service.ts**: In `login()`, after loading user with orgs, check if the default org's status is 'suspended' or 'archived' → throw UnauthorizedException("Your organization has been suspended/archived")
|
||||||
|
- **users.service.ts**: Update `findAllOrganizations()` query to include `contract_number, plan_level` in the SELECT
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **AdminPage.tsx**:
|
||||||
|
- Add "Create Tenant" button → opens a modal with: org name, address, email, phone, contract number, plan level (select: standard/premium/enterprise), first admin email, first admin password, first/last name
|
||||||
|
- Orgs table: add Contract #, Plan Level columns
|
||||||
|
- Orgs table: add Status dropdown/buttons (Active/Suspended/Archived) per row with confirmation
|
||||||
|
- Show status colors: active=green, trial=yellow, suspended=orange, archived=red
|
||||||
|
|
||||||
|
## 2. Units/Homeowners: Delete + Assessment Group Binding
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
- **units.controller.ts**: Add `@Delete(':id')` route
|
||||||
|
- **units.service.ts**:
|
||||||
|
- Add `delete(id)` method — checks for outstanding invoices first, then deletes
|
||||||
|
- Add `assessment_group_id` to `create()` INSERT and `update()` UPDATE queries
|
||||||
|
- Update `findAll()` to JOIN assessment_groups and return `assessment_group_name`
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **UnitsPage.tsx**:
|
||||||
|
- Add delete button (trash icon) per row with confirmation dialog
|
||||||
|
- Add Assessment Group dropdown (Select) in create/edit modal, populated from `/assessment-groups` query
|
||||||
|
- Show assessment group name in table
|
||||||
|
- When an assessment group is selected and no manual monthly_assessment is set, auto-fill from the group's regular_assessment
|
||||||
|
|
||||||
|
## 3. Assessment Groups: Frequency Field
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
- Add `frequency VARCHAR(20) DEFAULT 'monthly'` to `assessment_groups` table (live DB ALTER + tenant-schema DDL)
|
||||||
|
- CHECK constraint: `('monthly', 'quarterly', 'annual')`
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
- **assessment-groups.service.ts**:
|
||||||
|
- Add `frequency` to `create()` INSERT
|
||||||
|
- Add `frequency` to `update()` dynamic sets
|
||||||
|
- Update `findAll()` and `getSummary()` income calculations to adjust by frequency:
|
||||||
|
- monthly → multiply by 1 (×12/year)
|
||||||
|
- quarterly → amounts are per quarter, so monthly = amount/3
|
||||||
|
- annual → amounts are per year, so monthly = amount/12
|
||||||
|
- Summary labels should change to reflect "Monthly Equivalent" for mixed frequencies
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **AssessmentGroupsPage.tsx**:
|
||||||
|
- Add frequency Select in create/edit modal: Monthly, Quarterly, Annual
|
||||||
|
- Show frequency badge in table
|
||||||
|
- Update summary cards: labels → "Monthly Equivalent Operating" etc.
|
||||||
|
- Assessment amount label changes based on frequency ("Per Month" / "Per Quarter" / "Per Year")
|
||||||
|
|
||||||
|
## 4. UI Streamlining: Sidebar Grouping, Rename, Logo
|
||||||
|
|
||||||
|
### Sidebar Restructure
|
||||||
|
Group nav items into labeled sections:
|
||||||
|
```
|
||||||
|
Dashboard
|
||||||
|
─── FINANCIALS ───
|
||||||
|
Accounts (renamed from "Chart of Accounts")
|
||||||
|
Budgets
|
||||||
|
Investments
|
||||||
|
─── ASSESSMENTS ───
|
||||||
|
Units / Homeowners
|
||||||
|
Assessment Groups
|
||||||
|
─── TRANSACTIONS ───
|
||||||
|
Transactions
|
||||||
|
Invoices
|
||||||
|
Payments
|
||||||
|
─── PLANNING ───
|
||||||
|
Capital Projects
|
||||||
|
Reserves
|
||||||
|
Vendors
|
||||||
|
─── REPORTS ───
|
||||||
|
(collapsible with sub-items)
|
||||||
|
─── ADMIN ───
|
||||||
|
Year-End
|
||||||
|
Settings
|
||||||
|
─── PLATFORM ADMIN ─── (superadmin only)
|
||||||
|
Admin Panel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
- Copy SVG to `frontend/src/assets/logo.svg`
|
||||||
|
- In AppLayout.tsx: Replace `<Title order={3} c="blue">HOA LedgerIQ</Title>` with an `<img>` tag loading the SVG, sized to fit the 60px header (height ~40px with padding)
|
||||||
|
- SVG will be served directly (Vite handles SVG imports natively), no PNG conversion needed since browsers render SVG natively and it's cleaner
|
||||||
|
|
||||||
|
## 5. Capital Projects: PDF Table Export, Kanban Default, Future Category
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **CapitalProjectsPage.tsx**:
|
||||||
|
- Change default viewMode from `'table'` to `'kanban'`
|
||||||
|
- PDF export: temporarily switch to table view for print, then restore. Use `@media print` CSS to always show table layout regardless of current view
|
||||||
|
- Add "Future" column in kanban: projects with `target_year = 9999` (sentinel value) display as "Future"
|
||||||
|
- Update the form: Target Year select should include a "Future (Beyond 5-Year)" option that maps to year 9999
|
||||||
|
- Kanban year list: always include current year through +5, plus "Future" if any projects exist there
|
||||||
|
- Table view: group "Future" projects under a "Future" header
|
||||||
|
- Title: "Capital Projects" (remove "(5-Year Plan)" since we now have Future)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- No backend changes needed — target_year=9999 works with existing schema (integer column, no constraint)
|
||||||
|
|
||||||
|
## File Change Summary
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `db/init/00-init.sql` | Add contract_number, plan_level, update status CHECK |
|
||||||
|
| `backend/src/modules/organizations/entities/organization.entity.ts` | Add contractNumber, planLevel columns |
|
||||||
|
| `backend/src/modules/organizations/dto/create-organization.dto.ts` | Add contractNumber, planLevel fields |
|
||||||
|
| `backend/src/modules/auth/admin.controller.ts` | Add POST /admin/tenants, PUT /admin/organizations/:id/status |
|
||||||
|
| `backend/src/modules/auth/auth.module.ts` | Import OrganizationsModule |
|
||||||
|
| `backend/src/modules/auth/auth.service.ts` | Add org status check on login |
|
||||||
|
| `backend/src/modules/users/users.service.ts` | Update findAllOrganizations query |
|
||||||
|
| `backend/src/modules/units/units.controller.ts` | Add DELETE route |
|
||||||
|
| `backend/src/modules/units/units.service.ts` | Add delete(), assessment_group_id support |
|
||||||
|
| `backend/src/modules/assessment-groups/assessment-groups.service.ts` | Add frequency support + adjust income calcs |
|
||||||
|
| `backend/src/database/tenant-schema.service.ts` | Add frequency to assessment_groups DDL |
|
||||||
|
| `frontend/src/assets/logo.svg` | New — copy from /Users/claw/Downloads/logo_house.svg |
|
||||||
|
| `frontend/src/components/layout/AppLayout.tsx` | Replace text with logo |
|
||||||
|
| `frontend/src/components/layout/Sidebar.tsx` | Restructure with grouped sections |
|
||||||
|
| `frontend/src/pages/admin/AdminPage.tsx` | Create tenant modal, status management, new columns |
|
||||||
|
| `frontend/src/pages/units/UnitsPage.tsx` | Delete, assessment group dropdown |
|
||||||
|
| `frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx` | Frequency field |
|
||||||
|
| `frontend/src/pages/capital-projects/CapitalProjectsPage.tsx` | Kanban default, table PDF, Future category |
|
||||||
|
| Live DB | ALTER TABLE commands for contract_number, plan_level, frequency, status CHECK |
|
||||||
32
backend/Dockerfile
Normal file
32
backend/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# ---- Production Dockerfile for NestJS backend ----
|
||||||
|
# Multi-stage build: compile TypeScript, then run with minimal image
|
||||||
|
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Only install production dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy compiled output and New Relic preload from builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/newrelic-preload.js ./newrelic-preload.js
|
||||||
|
|
||||||
|
# New Relic agent — configured entirely via environment variables
|
||||||
|
ENV NEW_RELIC_NO_CONFIG_FILE=true
|
||||||
|
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
|
||||||
|
ENV NEW_RELIC_LOG=stdout
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Preload the New Relic agent (activates only when NEW_RELIC_ENABLED=true)
|
||||||
|
CMD ["node", "-r", "./newrelic-preload.js", "dist/main"]
|
||||||
@@ -7,6 +7,11 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# New Relic agent — configured entirely via environment variables
|
||||||
|
ENV NEW_RELIC_NO_CONFIG_FILE=true
|
||||||
|
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
|
||||||
|
ENV NEW_RELIC_LOG=stdout
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["npm", "run", "start:dev"]
|
CMD ["npm", "run", "start:dev"]
|
||||||
|
|||||||
7
backend/newrelic-preload.js
Normal file
7
backend/newrelic-preload.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Conditionally load the New Relic agent before any other modules.
|
||||||
|
// Controlled by the NEW_RELIC_ENABLED environment variable (.env).
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (process.env.NEW_RELIC_ENABLED === 'true') {
|
||||||
|
require('newrelic');
|
||||||
|
}
|
||||||
2593
backend/package-lock.json
generated
2593
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.2-beta",
|
"version": "2026.3.24",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node -r ./newrelic-preload.js dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -25,17 +25,29 @@
|
|||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"bullmq": "^5.71.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
|
"newrelic": "latest",
|
||||||
|
"otplib": "^13.3.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
"passport-azure-ad": "^4.3.5",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"resend": "^6.9.4",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"stripe": "^20.4.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
@@ -44,12 +56,15 @@
|
|||||||
"@nestjs/schematics": "^10.2.3",
|
"@nestjs/schematics": "^10.2.3",
|
||||||
"@nestjs/testing": "^10.4.15",
|
"@nestjs/testing": "^10.4.15",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.17.12",
|
"@types/node": "^20.17.12",
|
||||||
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||||
|
import { CapabilityGuard } from './common/guards/capability.guard';
|
||||||
|
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
@@ -27,6 +30,12 @@ import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.
|
|||||||
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||||
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||||
|
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
||||||
|
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';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -43,8 +52,19 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
|
// Connection pool — reuse connections instead of creating new ones per query
|
||||||
|
extra: {
|
||||||
|
max: 30, // max pool size (across all concurrent requests)
|
||||||
|
min: 5, // keep at least 5 idle connections warm
|
||||||
|
idleTimeoutMillis: 30000, // close idle connections after 30s
|
||||||
|
connectionTimeoutMillis: 5000, // fail fast if pool is exhausted
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
ThrottlerModule.forRoot([{
|
||||||
|
ttl: 60000, // 1-minute window
|
||||||
|
limit: 100, // 100 requests per minute (global default)
|
||||||
|
}]),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
@@ -67,6 +87,12 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
AttachmentsModule,
|
AttachmentsModule,
|
||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
HealthScoresModule,
|
HealthScoresModule,
|
||||||
|
BoardPlanningModule,
|
||||||
|
BillingModule,
|
||||||
|
EmailModule,
|
||||||
|
OnboardingModule,
|
||||||
|
IdeasModule,
|
||||||
|
ShadowAiModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
@@ -75,6 +101,14 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: WriteAccessGuard,
|
useClass: WriteAccessGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: CapabilityGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: NoCacheInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
14
backend/src/common/decorators/capability.decorator.ts
Normal file
14
backend/src/common/decorators/capability.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CAPABILITIES_KEY = 'required_capabilities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to require specific capabilities on an endpoint.
|
||||||
|
* User must have ALL listed capabilities to access the endpoint.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* @RequireCapability('financials.accounts.edit')
|
||||||
|
* @RequireCapability('financials.accounts.view', 'financials.accounts.edit')
|
||||||
|
*/
|
||||||
|
export const RequireCapability = (...capabilities: string[]) =>
|
||||||
|
SetMetadata(CAPABILITIES_KEY, capabilities);
|
||||||
83
backend/src/common/guards/capability.guard.ts
Normal file
83
backend/src/common/guards/capability.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CAPABILITIES_KEY } from '../decorators/capability.decorator';
|
||||||
|
import { resolveCapabilities } from '../permissions';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityGuard implements CanActivate {
|
||||||
|
// Cache org settings (including permissionOverrides) per orgId
|
||||||
|
private settingsCache = new Map<string, { settings: Record<string, any>; cachedAt: number }>();
|
||||||
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredCapabilities = this.reflector.getAllAndOverride<string[]>(CAPABILITIES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// No capabilities required — pass through (backward compatible)
|
||||||
|
if (!requiredCapabilities || requiredCapabilities.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
// No authenticated user — let other guards handle auth
|
||||||
|
if (!user) return true;
|
||||||
|
|
||||||
|
// Superadmins bypass all capability checks
|
||||||
|
if (user.isSuperadmin) return true;
|
||||||
|
|
||||||
|
const role = user.role;
|
||||||
|
const orgId = user.orgId;
|
||||||
|
|
||||||
|
if (!role || !orgId) return true;
|
||||||
|
|
||||||
|
// Get org settings (with caching)
|
||||||
|
const settings = await this.getOrgSettings(orgId);
|
||||||
|
const userCapabilities = resolveCapabilities(role, settings?.permissionOverrides);
|
||||||
|
|
||||||
|
// User must have ALL required capabilities
|
||||||
|
const hasAll = requiredCapabilities.every((cap) => userCapabilities.has(cap));
|
||||||
|
if (!hasAll) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You do not have the required permissions for this action.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrgSettings(orgId: string): Promise<Record<string, any> | null> {
|
||||||
|
const cached = this.settingsCache.get(orgId);
|
||||||
|
if (cached && Date.now() - cached.cachedAt < CapabilityGuard.CACHE_TTL) {
|
||||||
|
return cached.settings;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.dataSource.query(
|
||||||
|
`SELECT settings FROM shared.organizations WHERE id = $1`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
const settings = result[0].settings || {};
|
||||||
|
this.settingsCache.set(orgId, { settings, cachedAt: Date.now() });
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — fall through to use defaults only
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear cached settings for an org (call after settings update) */
|
||||||
|
clearCache(orgId: string) {
|
||||||
|
this.settingsCache.delete(orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,13 @@ export class WriteAccessGuard implements CanActivate {
|
|||||||
throw new ForbiddenException('Read-only users cannot modify data');
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
backend/src/common/interceptors/no-cache.interceptor.ts
Normal file
16
backend/src/common/interceptors/no-cache.interceptor.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/common/permissions/capabilities.ts
Normal file
65
backend/src/common/permissions/capabilities.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Capability taxonomy for the HOA Financial Platform.
|
||||||
|
*
|
||||||
|
* Pattern: {area}.{feature}.{action}
|
||||||
|
* Actions: view, edit, approve, manage
|
||||||
|
*
|
||||||
|
* Add new capabilities here when new features are built.
|
||||||
|
* The default role matrix in ./default-role-capabilities.ts must also be updated.
|
||||||
|
*/
|
||||||
|
export const CAPABILITIES = {
|
||||||
|
// Dashboard
|
||||||
|
DASHBOARD_VIEW: 'dashboard.view',
|
||||||
|
|
||||||
|
// Financials
|
||||||
|
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
|
||||||
|
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
|
||||||
|
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
|
||||||
|
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
|
||||||
|
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
|
||||||
|
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
|
||||||
|
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
|
||||||
|
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
|
||||||
|
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
|
||||||
|
|
||||||
|
// Assessments
|
||||||
|
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
|
||||||
|
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
|
||||||
|
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
|
||||||
|
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
|
||||||
|
|
||||||
|
// Board Planning
|
||||||
|
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
|
||||||
|
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
|
||||||
|
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
|
||||||
|
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
|
||||||
|
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
|
||||||
|
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
|
||||||
|
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
|
||||||
|
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
|
||||||
|
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
|
||||||
|
|
||||||
|
// Board Reference
|
||||||
|
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
|
||||||
|
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
|
||||||
|
|
||||||
|
// Transactions
|
||||||
|
TRANSACTIONS_VIEW: 'transactions.view',
|
||||||
|
TRANSACTIONS_EDIT: 'transactions.edit',
|
||||||
|
TRANSACTIONS_APPROVE: 'transactions.approve',
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
REPORTS_VIEW: 'reports.view',
|
||||||
|
|
||||||
|
// Settings & Administration
|
||||||
|
SETTINGS_ORG_VIEW: 'settings.org.view',
|
||||||
|
SETTINGS_ORG_EDIT: 'settings.org.edit',
|
||||||
|
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
|
||||||
|
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
|
||||||
|
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
|
||||||
|
|
||||||
|
/** Set of all valid capability strings, for validation */
|
||||||
|
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
|
||||||
157
backend/src/common/permissions/default-role-capabilities.ts
Normal file
157
backend/src/common/permissions/default-role-capabilities.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { CAPABILITIES, Capability } from './capabilities';
|
||||||
|
|
||||||
|
const C = CAPABILITIES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default capability sets per role.
|
||||||
|
*
|
||||||
|
* These represent sensible defaults for a typical HOA. Tenant admins can
|
||||||
|
* customize per-role capabilities via permission overrides in org settings.
|
||||||
|
*
|
||||||
|
* Roles not listed here (e.g. unknown future roles) get zero capabilities.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly Capability[]> = {
|
||||||
|
president: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||||
|
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||||
|
],
|
||||||
|
|
||||||
|
admin: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||||
|
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||||
|
],
|
||||||
|
|
||||||
|
vice_president: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.TRANSACTIONS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
treasurer: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
secretary: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
member_at_large: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
manager: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
homeowner: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
viewer: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.TRANSACTIONS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
};
|
||||||
5
backend/src/common/permissions/index.ts
Normal file
5
backend/src/common/permissions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { CAPABILITIES, ALL_CAPABILITIES } from './capabilities';
|
||||||
|
export type { Capability } from './capabilities';
|
||||||
|
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||||
|
export { resolveCapabilities, resolveCapabilitiesArray } from './resolve-permissions';
|
||||||
|
export type { PermissionOverrides } from './resolve-permissions';
|
||||||
57
backend/src/common/permissions/resolve-permissions.ts
Normal file
57
backend/src/common/permissions/resolve-permissions.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { ALL_CAPABILITIES } from './capabilities';
|
||||||
|
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||||
|
|
||||||
|
export interface PermissionOverrides {
|
||||||
|
[role: string]: {
|
||||||
|
grant?: string[];
|
||||||
|
revoke?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve effective capabilities for a role, applying tenant overrides.
|
||||||
|
*
|
||||||
|
* 1. Start with default capabilities for the role
|
||||||
|
* 2. Add any granted capabilities from overrides
|
||||||
|
* 3. Remove any revoked capabilities from overrides
|
||||||
|
*
|
||||||
|
* Unknown capabilities in grant/revoke are silently ignored (they may
|
||||||
|
* come from an older version of the overrides).
|
||||||
|
*/
|
||||||
|
export function resolveCapabilities(
|
||||||
|
role: string,
|
||||||
|
overrides?: PermissionOverrides | null,
|
||||||
|
): Set<string> {
|
||||||
|
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
|
||||||
|
const result = new Set<string>(defaults);
|
||||||
|
|
||||||
|
if (overrides && overrides[role]) {
|
||||||
|
const roleOverride = overrides[role];
|
||||||
|
|
||||||
|
if (roleOverride.grant) {
|
||||||
|
for (const cap of roleOverride.grant) {
|
||||||
|
if (ALL_CAPABILITIES.has(cap)) {
|
||||||
|
result.add(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleOverride.revoke) {
|
||||||
|
for (const cap of roleOverride.revoke) {
|
||||||
|
result.delete(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: resolve to a sorted array (for API responses).
|
||||||
|
*/
|
||||||
|
export function resolveCapabilitiesArray(
|
||||||
|
role: string,
|
||||||
|
overrides?: PermissionOverrides | null,
|
||||||
|
): string[] {
|
||||||
|
return Array.from(resolveCapabilities(role, overrides)).sort();
|
||||||
|
}
|
||||||
106
backend/src/common/utils/ai-caller.ts
Normal file
106
backend/src/common/utils/ai-caller.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* 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) => {
|
||||||
|
// Normalize: strip trailing slash and /chat/completions if user included it
|
||||||
|
let baseUrl = apiUrl.replace(/\/+$/, '');
|
||||||
|
if (baseUrl.endsWith('/chat/completions')) {
|
||||||
|
baseUrl = baseUrl.slice(0, -'/chat/completions'.length);
|
||||||
|
}
|
||||||
|
const url = new URL(`${baseUrl}/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -112,6 +112,8 @@ export class TenantSchemaService {
|
|||||||
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
||||||
unit_count INTEGER DEFAULT 0,
|
unit_count INTEGER DEFAULT 0,
|
||||||
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
|
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
|
||||||
|
due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}',
|
||||||
|
due_day INTEGER DEFAULT 1,
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
@@ -155,8 +157,11 @@ export class TenantSchemaService {
|
|||||||
amount DECIMAL(10,2) NOT NULL,
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
amount_paid DECIMAL(10,2) DEFAULT 0.00,
|
amount_paid DECIMAL(10,2) DEFAULT 0.00,
|
||||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
|
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
|
||||||
'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
|
'draft', 'pending', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
|
||||||
)),
|
)),
|
||||||
|
period_start DATE,
|
||||||
|
period_end DATE,
|
||||||
|
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
|
||||||
journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
|
journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
|
||||||
sent_at TIMESTAMPTZ,
|
sent_at TIMESTAMPTZ,
|
||||||
paid_at TIMESTAMPTZ,
|
paid_at TIMESTAMPTZ,
|
||||||
@@ -325,6 +330,8 @@ export class TenantSchemaService {
|
|||||||
risk_notes JSONB,
|
risk_notes JSONB,
|
||||||
requested_by UUID,
|
requested_by UUID,
|
||||||
response_time_ms INTEGER,
|
response_time_ms INTEGER,
|
||||||
|
status VARCHAR(20) DEFAULT 'complete',
|
||||||
|
error_message TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
@@ -359,6 +366,99 @@ export class TenantSchemaService {
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
// Board Planning - Scenarios
|
||||||
|
`CREATE TABLE "${s}".board_scenarios (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN ('investment', 'assessment')),
|
||||||
|
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'approved', 'archived')),
|
||||||
|
projection_months INTEGER DEFAULT 36,
|
||||||
|
projection_cache JSONB,
|
||||||
|
projection_cached_at TIMESTAMPTZ,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Board Planning - Scenario Investments
|
||||||
|
`CREATE TABLE "${s}".scenario_investments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
|
||||||
|
source_recommendation_id UUID,
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
investment_type VARCHAR(50) CHECK (investment_type IN ('cd', 'money_market', 'treasury', 'savings', 'other')),
|
||||||
|
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
|
||||||
|
principal DECIMAL(15,2) NOT NULL,
|
||||||
|
interest_rate DECIMAL(6,4),
|
||||||
|
term_months INTEGER,
|
||||||
|
institution VARCHAR(255),
|
||||||
|
purchase_date DATE,
|
||||||
|
maturity_date DATE,
|
||||||
|
auto_renew BOOLEAN DEFAULT FALSE,
|
||||||
|
executed_investment_id UUID,
|
||||||
|
notes TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Board Planning - Scenario Assessments
|
||||||
|
`CREATE TABLE "${s}".scenario_assessments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
|
||||||
|
change_type VARCHAR(30) NOT NULL CHECK (change_type IN ('dues_increase', 'special_assessment', 'dues_decrease')),
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
target_fund VARCHAR(20) CHECK (target_fund IN ('operating', 'reserve', 'both')),
|
||||||
|
percentage_change DECIMAL(6,3),
|
||||||
|
flat_amount_change DECIMAL(10,2),
|
||||||
|
special_total DECIMAL(15,2),
|
||||||
|
special_per_unit DECIMAL(10,2),
|
||||||
|
special_installments INTEGER DEFAULT 1,
|
||||||
|
effective_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
applies_to_group_id UUID,
|
||||||
|
notes TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Budget Plans
|
||||||
|
`CREATE TABLE "${s}".budget_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
fiscal_year INTEGER NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'approved', 'ratified')),
|
||||||
|
base_year INTEGER NOT NULL,
|
||||||
|
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
|
||||||
|
notes TEXT,
|
||||||
|
created_by UUID,
|
||||||
|
approved_by UUID,
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
ratified_by UUID,
|
||||||
|
ratified_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(fiscal_year)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Budget Plan Lines
|
||||||
|
`CREATE TABLE "${s}".budget_plan_lines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
budget_plan_id UUID NOT NULL REFERENCES "${s}".budget_plans(id) ON DELETE CASCADE,
|
||||||
|
account_id UUID NOT NULL REFERENCES "${s}".accounts(id),
|
||||||
|
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
|
||||||
|
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
|
||||||
|
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
|
||||||
|
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
|
||||||
|
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
|
||||||
|
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
|
||||||
|
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
|
||||||
|
is_manually_adjusted BOOLEAN DEFAULT FALSE,
|
||||||
|
notes TEXT,
|
||||||
|
UNIQUE(budget_plan_id, account_id, fund_type)
|
||||||
|
)`,
|
||||||
|
|
||||||
// Indexes
|
// Indexes
|
||||||
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
||||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
||||||
@@ -371,6 +471,12 @@ export class TenantSchemaService {
|
|||||||
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
|
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
|
||||||
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
|
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
|
||||||
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
|
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
|
||||||
|
`CREATE INDEX "idx_${s}_bs_type_status" ON "${s}".board_scenarios(scenario_type, status)`,
|
||||||
|
`CREATE INDEX "idx_${s}_si_scenario" ON "${s}".scenario_investments(scenario_id)`,
|
||||||
|
`CREATE INDEX "idx_${s}_sa_scenario" ON "${s}".scenario_assessments(scenario_id)`,
|
||||||
|
`CREATE INDEX "idx_${s}_bp_year" ON "${s}".budget_plans(fiscal_year)`,
|
||||||
|
`CREATE INDEX "idx_${s}_bp_status" ON "${s}".budget_plans(status)`,
|
||||||
|
`CREATE INDEX "idx_${s}_bpl_plan" ON "${s}".budget_plan_lines(budget_plan_id)`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ export interface TenantRequest extends Request {
|
|||||||
orgId?: string;
|
orgId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
|
orgPastDue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
// In-memory cache for org status to avoid DB hit per request
|
// In-memory cache for org info to avoid DB hit per request
|
||||||
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
|
||||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -30,23 +31,29 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
const secret = this.configService.get<string>('JWT_SECRET');
|
const secret = this.configService.get<string>('JWT_SECRET');
|
||||||
const decoded = jwt.verify(token, secret!) as any;
|
const decoded = jwt.verify(token, secret!) as any;
|
||||||
if (decoded?.orgSchema) {
|
if (decoded?.orgId) {
|
||||||
// Check if the org is still active (catches post-JWT suspension)
|
// Look up org info (status + schema) from orgId with caching
|
||||||
if (decoded.orgId) {
|
const orgInfo = await this.getOrgInfo(decoded.orgId);
|
||||||
const status = await this.getOrgStatus(decoded.orgId);
|
if (orgInfo) {
|
||||||
if (status && ['suspended', 'archived'].includes(status)) {
|
if (['suspended', 'archived'].includes(orgInfo.status)) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: `This organization has been ${status}. Please contact your administrator.`,
|
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// past_due: allow through with read-only flag (WriteAccessGuard enforces)
|
||||||
|
if (orgInfo.status === 'past_due') {
|
||||||
|
req.orgPastDue = true;
|
||||||
|
}
|
||||||
|
req.tenantSchema = orgInfo.schemaName;
|
||||||
}
|
}
|
||||||
|
|
||||||
req.tenantSchema = decoded.orgSchema;
|
|
||||||
req.orgId = decoded.orgId;
|
req.orgId = decoded.orgId;
|
||||||
req.userId = decoded.sub;
|
req.userId = decoded.sub;
|
||||||
req.userRole = decoded.role;
|
req.userRole = decoded.role;
|
||||||
|
} else if (decoded?.sub) {
|
||||||
|
// Superadmin or user without org — still set userId
|
||||||
|
req.userId = decoded.sub;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Token invalid or expired - let Passport handle the auth error
|
// Token invalid or expired - let Passport handle the auth error
|
||||||
@@ -55,19 +62,23 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrgStatus(orgId: string): Promise<string | null> {
|
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
|
||||||
const cached = this.orgStatusCache.get(orgId);
|
const cached = this.orgCache.get(orgId);
|
||||||
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||||
return cached.status;
|
return { status: cached.status, schemaName: cached.schemaName };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.dataSource.query(
|
const result = await this.dataSource.query(
|
||||||
`SELECT status FROM shared.organizations WHERE id = $1`,
|
`SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
|
||||||
[orgId],
|
[orgId],
|
||||||
);
|
);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
this.orgCache.set(orgId, {
|
||||||
return result[0].status;
|
status: result[0].status,
|
||||||
|
schemaName: result[0].schemaName,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { status: result[0].status, schemaName: result[0].schemaName };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — don't block requests on cache miss errors
|
// Non-critical — don't block requests on cache miss errors
|
||||||
|
|||||||
@@ -1,18 +1,78 @@
|
|||||||
|
import * as _cluster from 'node:cluster';
|
||||||
|
import * as os from 'node:os';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import * as cookieParser from 'cookie-parser';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clustering — fork one worker per CPU core in production
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WORKERS = isProduction
|
||||||
|
? Math.min(os.cpus().length, 4) // cap at 4 workers to stay within DB pool
|
||||||
|
: 1; // single process in dev
|
||||||
|
|
||||||
|
if (WORKERS > 1 && cluster.isPrimary) {
|
||||||
|
console.log(`Primary ${process.pid} forking ${WORKERS} workers ...`);
|
||||||
|
for (let i = 0; i < WORKERS; i++) {
|
||||||
|
cluster.fork();
|
||||||
|
}
|
||||||
|
cluster.on('exit', (worker: any, code: number) => {
|
||||||
|
console.warn(`Worker ${worker.process.pid} exited (code ${code}), restarting ...`);
|
||||||
|
cluster.fork();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
bootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NestJS bootstrap
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||||
|
// Enable raw body for Stripe webhook signature verification
|
||||||
|
rawBody: true,
|
||||||
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
// Request logging
|
// Cookie parser — needed for refresh token httpOnly cookies
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
|
||||||
|
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'", 'https://chat.hoaledgeriq.com'],
|
||||||
|
connectSrc: ["'self'", 'https://chat.hoaledgeriq.com', 'wss://chat.hoaledgeriq.com'],
|
||||||
|
imgSrc: ["'self'", 'data:', 'https://chat.hoaledgeriq.com'],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
frameSrc: ["'self'", 'https://chat.hoaledgeriq.com'],
|
||||||
|
fontSrc: ["'self'", 'data:'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request logging — only in development (too noisy / slow for prod)
|
||||||
|
if (!isProduction) {
|
||||||
app.use((req: any, _res: any, next: any) => {
|
app.use((req: any, _res: any, next: any) => {
|
||||||
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
|
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@@ -22,21 +82,24 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CORS — in production nginx handles this; accept all origins behind the proxy
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: ['http://localhost', 'http://localhost:5173'],
|
origin: isProduction ? true : ['http://localhost', 'http://localhost:5173'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Swagger docs — disabled in production to avoid exposing API surface
|
||||||
|
if (!isProduction) {
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('HOA LedgerIQ API')
|
.setTitle('HOA LedgerIQ API')
|
||||||
.setDescription('API for the HOA LedgerIQ')
|
.setDescription('API for the HOA LedgerIQ')
|
||||||
.setVersion('0.1.0')
|
.setVersion('2026.3.11')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
}
|
||||||
|
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
console.log('Backend running on port 3000');
|
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
||||||
}
|
}
|
||||||
bootstrap();
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { AccountsService } from './accounts.service';
|
import { AccountsService } from './accounts.service';
|
||||||
import { CreateAccountDto } from './dto/create-account.dto';
|
import { CreateAccountDto } from './dto/create-account.dto';
|
||||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||||
@@ -16,24 +17,28 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all accounts' })
|
@ApiOperation({ summary: 'List all accounts' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
||||||
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('trial-balance')
|
@Get('trial-balance')
|
||||||
@ApiOperation({ summary: 'Get trial balance' })
|
@ApiOperation({ summary: 'Get trial balance' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
||||||
return this.accountsService.getTrialBalance(asOfDate);
|
return this.accountsService.getTrialBalance(asOfDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/set-primary')
|
@Put(':id/set-primary')
|
||||||
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
setPrimary(@Param('id') id: string) {
|
setPrimary(@Param('id') id: string) {
|
||||||
return this.accountsService.setPrimary(id);
|
return this.accountsService.setPrimary(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-opening-balances')
|
@Post('bulk-opening-balances')
|
||||||
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
bulkSetOpeningBalances(
|
bulkSetOpeningBalances(
|
||||||
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
||||||
) {
|
) {
|
||||||
@@ -42,6 +47,7 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post(':id/opening-balance')
|
@Post(':id/opening-balance')
|
||||||
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
setOpeningBalance(
|
setOpeningBalance(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
@@ -51,6 +57,7 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post(':id/adjust-balance')
|
@Post(':id/adjust-balance')
|
||||||
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
adjustBalance(
|
adjustBalance(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
@@ -58,20 +65,32 @@ export class AccountsController {
|
|||||||
return this.accountsService.adjustBalance(id, dto);
|
return this.accountsService.adjustBalance(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('transfer')
|
||||||
|
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
|
transferFunds(
|
||||||
|
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
|
||||||
|
) {
|
||||||
|
return this.accountsService.transferFunds(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get account by ID' })
|
@ApiOperation({ summary: 'Get account by ID' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.accountsService.findOne(id);
|
return this.accountsService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new account' })
|
@ApiOperation({ summary: 'Create a new account' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
create(@Body() dto: CreateAccountDto) {
|
create(@Body() dto: CreateAccountDto) {
|
||||||
return this.accountsService.create(dto);
|
return this.accountsService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'Update an account' })
|
@ApiOperation({ summary: 'Update an account' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
||||||
return this.accountsService.update(id, dto);
|
return this.accountsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ export class AccountsService {
|
|||||||
|
|
||||||
// Create opening balance journal entry if initialBalance is provided and non-zero
|
// Create opening balance journal entry if initialBalance is provided and non-zero
|
||||||
if (dto.initialBalance && dto.initialBalance !== 0) {
|
if (dto.initialBalance && dto.initialBalance !== 0) {
|
||||||
const now = new Date();
|
const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date();
|
||||||
const year = now.getFullYear();
|
const year = balanceDate.getFullYear();
|
||||||
const month = now.getMonth() + 1;
|
const month = balanceDate.getMonth() + 1;
|
||||||
|
|
||||||
// Find the current fiscal period
|
// Find the current fiscal period
|
||||||
const periods = await this.tenant.query(
|
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(
|
const jeInsert = await this.tenant.query(
|
||||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
`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`,
|
RETURNING id`,
|
||||||
[
|
[
|
||||||
|
entryDate,
|
||||||
`Opening balance for ${dto.name}`,
|
`Opening balance for ${dto.name}`,
|
||||||
fiscalPeriodId,
|
fiscalPeriodId,
|
||||||
'00000000-0000-0000-0000-000000000000',
|
'00000000-0000-0000-0000-000000000000',
|
||||||
@@ -358,6 +360,62 @@ export class AccountsService {
|
|||||||
return journalEntry;
|
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) {
|
async getTrialBalance(asOfDate?: string) {
|
||||||
const dateFilter = asOfDate
|
const dateFilter = asOfDate
|
||||||
? `AND je.entry_date <= $1`
|
? `AND je.entry_date <= $1`
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export class CreateAccountDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
initialBalance?: number;
|
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' })
|
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
interestRate?: number;
|
interestRate?: number;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { AssessmentGroupsService } from './assessment-groups.service';
|
import { AssessmentGroupsService } from './assessment-groups.service';
|
||||||
|
|
||||||
@ApiTags('assessment-groups')
|
@ApiTags('assessment-groups')
|
||||||
@@ -11,23 +12,30 @@ export class AssessmentGroupsController {
|
|||||||
constructor(private service: AssessmentGroupsService) {}
|
constructor(private service: AssessmentGroupsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
getSummary() { return this.service.getSummary(); }
|
getSummary() { return this.service.getSummary(); }
|
||||||
|
|
||||||
@Get('default')
|
@Get('default')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
getDefault() { return this.service.getDefault(); }
|
getDefault() { return this.service.getDefault(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
@Put(':id/set-default')
|
@Put(':id/set-default')
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { TenantService } from '../../database/tenant.service';
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
|
||||||
|
const DEFAULT_DUE_MONTHS: Record<string, number[]> = {
|
||||||
|
monthly: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||||
|
quarterly: [1, 4, 7, 10],
|
||||||
|
annual: [1],
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssessmentGroupsService {
|
export class AssessmentGroupsService {
|
||||||
constructor(private tenant: TenantService) {}
|
constructor(private tenant: TenantService) {}
|
||||||
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
|
|||||||
return rows.length ? rows[0] : null;
|
return rows.length ? rows[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validateDueMonths(frequency: string, dueMonths: number[]) {
|
||||||
|
if (!dueMonths || !dueMonths.length) {
|
||||||
|
throw new BadRequestException('Due months are required');
|
||||||
|
}
|
||||||
|
// Validate all values are 1-12
|
||||||
|
if (dueMonths.some((m) => m < 1 || m > 12 || !Number.isInteger(m))) {
|
||||||
|
throw new BadRequestException('Due months must be integers between 1 and 12');
|
||||||
|
}
|
||||||
|
switch (frequency) {
|
||||||
|
case 'monthly':
|
||||||
|
if (dueMonths.length !== 12) {
|
||||||
|
throw new BadRequestException('Monthly frequency must include all 12 months');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'quarterly':
|
||||||
|
if (dueMonths.length !== 4) {
|
||||||
|
throw new BadRequestException('Quarterly frequency must have exactly 4 due months');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'annual':
|
||||||
|
if (dueMonths.length !== 1) {
|
||||||
|
throw new BadRequestException('Annual frequency must have exactly 1 due month');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: any) {
|
async create(dto: any) {
|
||||||
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
|
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
|
||||||
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
|
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
|
||||||
@@ -51,17 +84,23 @@ export class AssessmentGroupsService {
|
|||||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const frequency = dto.frequency || 'monthly';
|
||||||
|
const dueMonths = dto.dueMonths || DEFAULT_DUE_MONTHS[frequency] || DEFAULT_DUE_MONTHS.monthly;
|
||||||
|
const dueDay = Math.min(Math.max(dto.dueDay || 1, 1), 28);
|
||||||
|
|
||||||
|
this.validateDueMonths(frequency, dueMonths);
|
||||||
|
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
|
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day, is_default)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||||
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
|
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
|
||||||
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault],
|
dto.unitCount || 0, frequency, dueMonths, dueDay, shouldBeDefault],
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: any) {
|
async update(id: string, dto: any) {
|
||||||
await this.findOne(id);
|
const existing = await this.findOne(id);
|
||||||
|
|
||||||
if (dto.isDefault === true) {
|
if (dto.isDefault === true) {
|
||||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||||
@@ -80,6 +119,24 @@ export class AssessmentGroupsService {
|
|||||||
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
|
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
|
||||||
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
|
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
|
||||||
|
|
||||||
|
// Handle due_months: if frequency changed and no explicit dueMonths, auto-populate defaults
|
||||||
|
const effectiveFrequency = dto.frequency || existing.frequency;
|
||||||
|
if (dto.dueMonths !== undefined) {
|
||||||
|
this.validateDueMonths(effectiveFrequency, dto.dueMonths);
|
||||||
|
sets.push(`due_months = $${idx++}`);
|
||||||
|
params.push(dto.dueMonths);
|
||||||
|
} else if (dto.frequency !== undefined && dto.frequency !== existing.frequency) {
|
||||||
|
// Frequency changed, auto-populate due_months
|
||||||
|
const newDueMonths = DEFAULT_DUE_MONTHS[dto.frequency] || DEFAULT_DUE_MONTHS.monthly;
|
||||||
|
sets.push(`due_months = $${idx++}`);
|
||||||
|
params.push(newDueMonths);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.dueDay !== undefined) {
|
||||||
|
sets.push(`due_day = $${idx++}`);
|
||||||
|
params.push(Math.min(Math.max(dto.dueDay, 1), 28));
|
||||||
|
}
|
||||||
|
|
||||||
if (!sets.length) return this.findOne(id);
|
if (!sets.length) return this.findOne(id);
|
||||||
|
|
||||||
sets.push('updated_at = NOW()');
|
sets.push('updated_at = NOW()');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { OrganizationsService } from '../organizations/organizations.service';
|
import { OrganizationsService } from '../organizations/organizations.service';
|
||||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
|
import { IdeasService } from '../ideas/ideas.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@@ -17,6 +18,7 @@ export class AdminController {
|
|||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private orgService: OrganizationsService,
|
private orgService: OrganizationsService,
|
||||||
private analyticsService: AdminAnalyticsService,
|
private analyticsService: AdminAnalyticsService,
|
||||||
|
private ideasService: IdeasService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async requireSuperadmin(req: any) {
|
private async requireSuperadmin(req: any) {
|
||||||
@@ -196,4 +198,45 @@ export class AdminController {
|
|||||||
|
|
||||||
return { success: true, organization: org };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
Get,
|
Get,
|
||||||
|
Res,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { Response } from 'express';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -16,24 +23,103 @@ import { SwitchOrgDto } from './dto/switch-org.dto';
|
|||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
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, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRefreshCookie(res: Response) {
|
||||||
|
res.clearCookie(COOKIE_NAME, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/api/auth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
|
||||||
async register(@Body() dto: RegisterDto) {
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
return this.authService.register(dto);
|
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);
|
||||||
|
}
|
||||||
|
const { refreshToken, ...response } = result;
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
@UseGuards(AuthGuard('local'))
|
@UseGuards(AuthGuard('local'))
|
||||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
async login(@Request() req: any, @Body() _dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
const ua = req.headers['user-agent'];
|
const ua = req.headers['user-agent'];
|
||||||
return this.authService.login(req.user, ip, ua);
|
const result = await this.authService.login(req.user, ip, ua);
|
||||||
|
|
||||||
|
// MFA challenge — no cookie, just return the challenge token
|
||||||
|
if ('mfaRequired' in result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('refreshToken' in result && result.refreshToken) {
|
||||||
|
setRefreshCookie(res, result.refreshToken);
|
||||||
|
}
|
||||||
|
const { refreshToken: _rt, ...response } = result as any;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@ApiOperation({ summary: 'Refresh access token using httpOnly cookie' })
|
||||||
|
async refresh(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const rawToken = req.cookies?.[COOKIE_NAME];
|
||||||
|
if (!rawToken) {
|
||||||
|
throw new BadRequestException('No refresh token');
|
||||||
|
}
|
||||||
|
return this.authService.refreshAccessToken(rawToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
await this.authService.logout(rawToken);
|
||||||
|
}
|
||||||
|
clearRefreshCookie(res);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout-everywhere')
|
||||||
|
@ApiOperation({ summary: 'Revoke all sessions' })
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
||||||
|
await this.authService.logoutEverywhere(req.user.sub);
|
||||||
|
clearRefreshCookie(res);
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('profile')
|
@Get('profile')
|
||||||
@@ -59,9 +145,99 @@ export class AuthController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto, @Res({ passthrough: true }) res: Response) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
const ua = req.headers['user-agent'];
|
const ua = req.headers['user-agent'];
|
||||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
const result = await this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
||||||
|
if (result.refreshToken) {
|
||||||
|
setRefreshCookie(res, result.refreshToken);
|
||||||
|
}
|
||||||
|
const { refreshToken, ...response } = result;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Activation Endpoints ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('activate')
|
||||||
|
@ApiOperation({ summary: 'Validate an activation token' })
|
||||||
|
async validateActivation(@Query('token') token: string) {
|
||||||
|
if (!token) throw new BadRequestException('Token required');
|
||||||
|
return this.authService.validateInviteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('activate')
|
||||||
|
@ApiOperation({ summary: 'Activate user account with password' })
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
|
async activate(
|
||||||
|
@Body() body: { token: string; password: string; fullName: string },
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
if (!body.token || !body.password || !body.fullName) {
|
||||||
|
throw new BadRequestException('Token, password, and fullName are required');
|
||||||
|
}
|
||||||
|
if (body.password.length < 8) {
|
||||||
|
throw new BadRequestException('Password must be at least 8 characters');
|
||||||
|
}
|
||||||
|
const result = await this.authService.activateUser(body.token, body.password, body.fullName);
|
||||||
|
if (result.refreshToken) {
|
||||||
|
setRefreshCookie(res, result.refreshToken);
|
||||||
|
}
|
||||||
|
const { refreshToken, ...response } = result;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('resend-activation')
|
||||||
|
@ApiOperation({ summary: 'Resend activation email' })
|
||||||
|
@Throttle({ default: { limit: 2, ttl: 60000 } })
|
||||||
|
async resendActivation(@Body() body: { email: string }) {
|
||||||
|
// 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.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,29 +4,53 @@ import { PassportModule } from '@nestjs/passport';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
|
import { MfaController } from './mfa.controller';
|
||||||
|
import { SsoController } from './sso.controller';
|
||||||
|
import { PasskeyController } from './passkey.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
|
import { RefreshTokenService } from './refresh-token.service';
|
||||||
|
import { MfaService } from './mfa.service';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
import { PasskeyService } from './passkey.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||||
|
import { IdeasModule } from '../ideas/ideas.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
IdeasModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
signOptions: { expiresIn: '24h' },
|
signOptions: { expiresIn: '1h' },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController, AdminController],
|
controllers: [
|
||||||
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
|
AuthController,
|
||||||
exports: [AuthService],
|
AdminController,
|
||||||
|
MfaController,
|
||||||
|
SsoController,
|
||||||
|
PasskeyController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
AdminAnalyticsService,
|
||||||
|
RefreshTokenService,
|
||||||
|
MfaService,
|
||||||
|
SsoService,
|
||||||
|
PasskeyService,
|
||||||
|
JwtStrategy,
|
||||||
|
LocalStrategy,
|
||||||
|
],
|
||||||
|
exports: [AuthService, RefreshTokenService, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -4,21 +4,38 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes, createHash } from 'crypto';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { EmailService } from '../email/email.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
|
import { RefreshTokenService } from './refresh-token.service';
|
||||||
|
import { resolveCapabilitiesArray } from '../../common/permissions';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
private readonly inviteSecret: string;
|
||||||
|
private readonly appUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
|
private configService: ConfigService,
|
||||||
private dataSource: DataSource,
|
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) {
|
async register(dto: RegisterDto) {
|
||||||
const existing = await this.usersService.findByEmail(dto.email);
|
const existing = await this.usersService.findByEmail(dto.email);
|
||||||
@@ -72,9 +89,27 @@ export class AuthService {
|
|||||||
// Record login in history (org_id is null at initial login)
|
// Record login in history (org_id is null at initial login)
|
||||||
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
|
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
|
||||||
|
|
||||||
|
// If MFA is enabled, return a challenge token instead of full session
|
||||||
|
if (u.mfaEnabled && u.mfaSecret) {
|
||||||
|
const mfaToken = this.jwtService.sign(
|
||||||
|
{ sub: u.id, type: 'mfa_challenge' },
|
||||||
|
{ expiresIn: '5m' },
|
||||||
|
);
|
||||||
|
return { mfaRequired: true, mfaToken };
|
||||||
|
}
|
||||||
|
|
||||||
return this.generateTokenResponse(u);
|
return this.generateTokenResponse(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete login after MFA verification — generate full session tokens.
|
||||||
|
*/
|
||||||
|
async completeMfaLogin(userId: string): Promise<any> {
|
||||||
|
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||||
|
if (!user) throw new UnauthorizedException('User not found');
|
||||||
|
return this.generateTokenResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
async getProfile(userId: string) {
|
async getProfile(userId: string) {
|
||||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -85,6 +120,7 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
mfaEnabled: user.mfaEnabled || false,
|
||||||
organizations: user.userOrganizations?.map((uo) => ({
|
organizations: user.userOrganizations?.map((uo) => ({
|
||||||
id: uo.organization.id,
|
id: uo.organization.id,
|
||||||
name: uo.organization.name,
|
name: uo.organization.name,
|
||||||
@@ -118,28 +154,272 @@ export class AuthService {
|
|||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
orgId: membership.organizationId,
|
orgId: membership.organizationId,
|
||||||
orgSchema: membership.organization.schemaName,
|
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Record org switch in login history
|
// Record org switch in login history
|
||||||
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
|
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
|
||||||
|
|
||||||
|
// Generate new refresh token for org switch
|
||||||
|
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
||||||
|
|
||||||
|
const orgSettings = membership.organization.settings || {};
|
||||||
|
const capabilities = resolveCapabilitiesArray(
|
||||||
|
membership.role,
|
||||||
|
orgSettings.permissionOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
|
refreshToken,
|
||||||
organization: {
|
organization: {
|
||||||
id: membership.organization.id,
|
id: membership.organization.id,
|
||||||
name: membership.organization.name,
|
name: membership.organization.name,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
settings: membership.organization.settings || {},
|
settings: orgSettings,
|
||||||
|
capabilities,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh an access token using a valid refresh token.
|
||||||
|
*/
|
||||||
|
async refreshAccessToken(rawRefreshToken: string) {
|
||||||
|
const userId = await this.refreshTokenService.validateRefreshToken(rawRefreshToken);
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new access token (keep same org context if available)
|
||||||
|
const orgs = (user.userOrganizations || []).filter(
|
||||||
|
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
|
||||||
|
);
|
||||||
|
const defaultOrg = orgs[0];
|
||||||
|
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (defaultOrg) {
|
||||||
|
payload.orgId = defaultOrg.organizationId;
|
||||||
|
payload.role = defaultOrg.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: this.jwtService.sign(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout: revoke the refresh token.
|
||||||
|
*/
|
||||||
|
async logout(rawRefreshToken: string): Promise<void> {
|
||||||
|
if (rawRefreshToken) {
|
||||||
|
await this.refreshTokenService.revokeToken(rawRefreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout everywhere: revoke all refresh tokens for a user.
|
||||||
|
*/
|
||||||
|
async logoutEverywhere(userId: string): Promise<void> {
|
||||||
|
await this.refreshTokenService.revokeAllUserTokens(userId);
|
||||||
|
}
|
||||||
|
|
||||||
async markIntroSeen(userId: string): Promise<void> {
|
async markIntroSeen(userId: string): Promise<void> {
|
||||||
await this.usersService.markIntroSeen(userId);
|
await this.usersService.markIntroSeen(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Invite Token (Activation) Methods ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an invite/activation token.
|
||||||
|
*/
|
||||||
|
async validateInviteToken(token: string) {
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify(token, { secret: this.inviteSecret });
|
||||||
|
if (payload.type !== 'invite') throw new Error('Not an invite token');
|
||||||
|
|
||||||
|
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT it.*, o.name as org_name FROM shared.invite_tokens it
|
||||||
|
JOIN shared.organizations o ON o.id = it.organization_id
|
||||||
|
WHERE it.token_hash = $1`,
|
||||||
|
[tokenHash],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) throw new Error('Token not found');
|
||||||
|
const row = rows[0];
|
||||||
|
if (row.used_at) throw new BadRequestException('This activation link has already been used');
|
||||||
|
if (new Date(row.expires_at) < new Date()) throw new BadRequestException('This activation link has expired');
|
||||||
|
|
||||||
|
return { valid: true, email: payload.email, orgName: row.org_name, orgId: payload.orgId, userId: payload.userId };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BadRequestException) throw err;
|
||||||
|
throw new BadRequestException('Invalid or expired activation link');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate a user from an invite token (set password, activate, issue session).
|
||||||
|
*/
|
||||||
|
async activateUser(token: string, password: string, fullName: string) {
|
||||||
|
const info = await this.validateInviteToken(token);
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const [firstName, ...rest] = fullName.trim().split(' ');
|
||||||
|
const lastName = rest.join(' ') || '';
|
||||||
|
|
||||||
|
// Update user record
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET password_hash = $1, first_name = $2, last_name = $3,
|
||||||
|
is_email_verified = true, updated_at = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[passwordHash, firstName, lastName, info.userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark invite token as used
|
||||||
|
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.invite_tokens SET used_at = NOW() WHERE token_hash = $1`,
|
||||||
|
[tokenHash],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Issue session
|
||||||
|
const user = await this.usersService.findByIdWithOrgs(info.userId);
|
||||||
|
if (!user) throw new NotFoundException('User not found after activation');
|
||||||
|
|
||||||
|
return this.generateTokenResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a signed invite token for a user/org pair.
|
||||||
|
*/
|
||||||
|
async generateInviteToken(userId: string, orgId: string, email: string): Promise<string> {
|
||||||
|
const token = this.jwtService.sign(
|
||||||
|
{ type: 'invite', userId, orgId, email },
|
||||||
|
{ secret: this.inviteSecret, expiresIn: '72h' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||||
|
const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.invite_tokens (organization_id, user_id, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[orgId, userId, tokenHash, expiresAt],
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
private async recordLoginHistory(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string | null,
|
organizationId: string | null,
|
||||||
@@ -157,7 +437,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateTokenResponse(user: User, impersonatedBy?: string) {
|
async generateTokenResponse(user: User, impersonatedBy?: string) {
|
||||||
const allOrgs = user.userOrganizations || [];
|
const allOrgs = user.userOrganizations || [];
|
||||||
// Filter out suspended/archived organizations
|
// Filter out suspended/archived organizations
|
||||||
const orgs = allOrgs.filter(
|
const orgs = allOrgs.filter(
|
||||||
@@ -177,12 +457,15 @@ export class AuthService {
|
|||||||
|
|
||||||
if (defaultOrg) {
|
if (defaultOrg) {
|
||||||
payload.orgId = defaultOrg.organizationId;
|
payload.orgId = defaultOrg.organizationId;
|
||||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
|
||||||
payload.role = defaultOrg.role;
|
payload.role = defaultOrg.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create refresh token
|
||||||
|
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
|
refreshToken,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -191,14 +474,18 @@ export class AuthService {
|
|||||||
isSuperadmin: user.isSuperadmin || false,
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
isPlatformOwner: user.isPlatformOwner || false,
|
isPlatformOwner: user.isPlatformOwner || false,
|
||||||
hasSeenIntro: user.hasSeenIntro || false,
|
hasSeenIntro: user.hasSeenIntro || false,
|
||||||
|
mfaEnabled: user.mfaEnabled || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => {
|
||||||
|
const settings = uo.organization?.settings || {};
|
||||||
|
return {
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
name: uo.organization?.name,
|
name: uo.organization?.name,
|
||||||
schemaName: uo.organization?.schemaName,
|
|
||||||
status: uo.organization?.status,
|
status: uo.organization?.status,
|
||||||
role: uo.role,
|
role: uo.role,
|
||||||
})),
|
capabilities: resolveCapabilitiesArray(uo.role, settings.permissionOverrides),
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
backend/src/modules/auth/mfa.controller.ts
Normal file
121
backend/src/modules/auth/mfa.controller.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
Res,
|
||||||
|
BadRequestException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { MfaService } from './mfa.service';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
|
||||||
|
const COOKIE_NAME = 'ledgeriq_rt';
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth/mfa')
|
||||||
|
export class MfaController {
|
||||||
|
constructor(
|
||||||
|
private mfaService: MfaService,
|
||||||
|
private authService: AuthService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('setup')
|
||||||
|
@ApiOperation({ summary: 'Generate MFA setup (QR code + secret)' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async setup(@Request() req: any) {
|
||||||
|
return this.mfaService.generateSetup(req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('enable')
|
||||||
|
@ApiOperation({ summary: 'Enable MFA after verifying TOTP code' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async enable(@Request() req: any, @Body() body: { token: string }) {
|
||||||
|
if (!body.token) throw new BadRequestException('TOTP code required');
|
||||||
|
return this.mfaService.enableMfa(req.user.sub, body.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('verify')
|
||||||
|
@ApiOperation({ summary: 'Verify MFA during login flow' })
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
|
async verify(
|
||||||
|
@Body() body: { mfaToken: string; token: string; useRecovery?: boolean },
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
if (!body.mfaToken || !body.token) {
|
||||||
|
throw new BadRequestException('mfaToken and verification code required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the MFA challenge token
|
||||||
|
let payload: any;
|
||||||
|
try {
|
||||||
|
payload = this.jwtService.verify(body.mfaToken);
|
||||||
|
if (payload.type !== 'mfa_challenge') throw new Error('Wrong token type');
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid or expired MFA challenge');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = payload.sub;
|
||||||
|
let verified = false;
|
||||||
|
|
||||||
|
if (body.useRecovery) {
|
||||||
|
verified = await this.mfaService.verifyRecoveryCode(userId, body.token);
|
||||||
|
} else {
|
||||||
|
verified = await this.mfaService.verifyMfa(userId, body.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
throw new UnauthorizedException('Invalid verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// MFA passed — issue full session
|
||||||
|
const result = await this.authService.completeMfaLogin(userId);
|
||||||
|
if (result.refreshToken) {
|
||||||
|
res.cookie(COOKIE_NAME, result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { refreshToken: _rt, ...response } = result;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('disable')
|
||||||
|
@ApiOperation({ summary: 'Disable MFA (requires password)' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async disable(@Request() req: any, @Body() body: { password: string }) {
|
||||||
|
if (!body.password) throw new BadRequestException('Password required to disable MFA');
|
||||||
|
|
||||||
|
// Verify password first
|
||||||
|
const user = await this.authService.validateUser(req.user.email, body.password);
|
||||||
|
if (!user) throw new UnauthorizedException('Invalid password');
|
||||||
|
|
||||||
|
await this.mfaService.disableMfa(req.user.sub);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@ApiOperation({ summary: 'Get MFA status' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@AllowViewer()
|
||||||
|
async status(@Request() req: any) {
|
||||||
|
return this.mfaService.getStatus(req.user.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
backend/src/modules/auth/mfa.service.ts
Normal file
154
backend/src/modules/auth/mfa.service.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { generateSecret, generateURI, verifySync } from 'otplib';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MfaService {
|
||||||
|
private readonly logger = new Logger(MfaService.name);
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate MFA setup data (secret + QR code) for a user.
|
||||||
|
*/
|
||||||
|
async generateSetup(userId: string): Promise<{ secret: string; qrDataUrl: string; otpauthUrl: string }> {
|
||||||
|
const userRows = await this.dataSource.query(
|
||||||
|
`SELECT email, mfa_enabled FROM shared.users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (userRows.length === 0) throw new BadRequestException('User not found');
|
||||||
|
|
||||||
|
const secret = generateSecret();
|
||||||
|
const otpauthUrl = generateURI({ secret, issuer: 'HOA LedgerIQ', label: userRows[0].email });
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(otpauthUrl);
|
||||||
|
|
||||||
|
// Store the secret temporarily (not verified yet)
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET mfa_secret = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
[secret, userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { secret, qrDataUrl, otpauthUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable MFA after verifying the initial TOTP code.
|
||||||
|
* Returns recovery codes.
|
||||||
|
*/
|
||||||
|
async enableMfa(userId: string, token: string): Promise<{ recoveryCodes: string[] }> {
|
||||||
|
const userRows = await this.dataSource.query(
|
||||||
|
`SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (userRows.length === 0) throw new BadRequestException('User not found');
|
||||||
|
if (!userRows[0].mfa_secret) throw new BadRequestException('MFA setup not initiated');
|
||||||
|
if (userRows[0].mfa_enabled) throw new BadRequestException('MFA is already enabled');
|
||||||
|
|
||||||
|
// Verify the token
|
||||||
|
const result = verifySync({ token, secret: userRows[0].mfa_secret });
|
||||||
|
if (!result.valid) throw new BadRequestException('Invalid verification code');
|
||||||
|
|
||||||
|
// Generate recovery codes
|
||||||
|
const recoveryCodes = Array.from({ length: 10 }, () =>
|
||||||
|
randomBytes(4).toString('hex').toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hash recovery codes for storage
|
||||||
|
const hashedCodes = await Promise.all(
|
||||||
|
recoveryCodes.map((code) => bcrypt.hash(code, 10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable MFA
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET
|
||||||
|
mfa_enabled = true,
|
||||||
|
totp_verified_at = NOW(),
|
||||||
|
recovery_codes = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[JSON.stringify(hashedCodes), userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`MFA enabled for user ${userId}`);
|
||||||
|
return { recoveryCodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a TOTP code during login.
|
||||||
|
*/
|
||||||
|
async verifyMfa(userId: string, token: string): Promise<boolean> {
|
||||||
|
const userRows = await this.dataSource.query(
|
||||||
|
`SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (userRows.length === 0 || !userRows[0].mfa_enabled) return false;
|
||||||
|
|
||||||
|
const result = verifySync({ token, secret: userRows[0].mfa_secret });
|
||||||
|
return result.valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a recovery code (consumes it on success).
|
||||||
|
*/
|
||||||
|
async verifyRecoveryCode(userId: string, code: string): Promise<boolean> {
|
||||||
|
const userRows = await this.dataSource.query(
|
||||||
|
`SELECT recovery_codes FROM shared.users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (userRows.length === 0 || !userRows[0].recovery_codes) return false;
|
||||||
|
|
||||||
|
const hashedCodes: string[] = JSON.parse(userRows[0].recovery_codes);
|
||||||
|
|
||||||
|
for (let i = 0; i < hashedCodes.length; i++) {
|
||||||
|
const match = await bcrypt.compare(code.toUpperCase(), hashedCodes[i]);
|
||||||
|
if (match) {
|
||||||
|
// Remove the used code
|
||||||
|
hashedCodes.splice(i, 1);
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET recovery_codes = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
[JSON.stringify(hashedCodes), userId],
|
||||||
|
);
|
||||||
|
this.logger.log(`Recovery code used for user ${userId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable MFA (requires password verification done by caller).
|
||||||
|
*/
|
||||||
|
async disableMfa(userId: string): Promise<void> {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET
|
||||||
|
mfa_enabled = false,
|
||||||
|
mfa_secret = NULL,
|
||||||
|
totp_verified_at = NULL,
|
||||||
|
recovery_codes = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
this.logger.log(`MFA disabled for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MFA status for a user.
|
||||||
|
*/
|
||||||
|
async getStatus(userId: string): Promise<{ enabled: boolean; hasRecoveryCodes: boolean }> {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT mfa_enabled, recovery_codes FROM shared.users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return { enabled: false, hasRecoveryCodes: false };
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: rows[0].mfa_enabled || false,
|
||||||
|
hasRecoveryCodes: !!rows[0].recovery_codes && JSON.parse(rows[0].recovery_codes || '[]').length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
112
backend/src/modules/auth/passkey.controller.ts
Normal file
112
backend/src/modules/auth/passkey.controller.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
Res,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { PasskeyService } from './passkey.service';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
|
||||||
|
const COOKIE_NAME = 'ledgeriq_rt';
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth/passkeys')
|
||||||
|
export class PasskeyController {
|
||||||
|
constructor(
|
||||||
|
private passkeyService: PasskeyService,
|
||||||
|
private authService: AuthService,
|
||||||
|
private usersService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('register-options')
|
||||||
|
@ApiOperation({ summary: 'Get passkey registration options' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getRegistrationOptions(@Request() req: any) {
|
||||||
|
return this.passkeyService.generateRegistrationOptions(req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: 'Register a new passkey' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async register(
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() body: { response: any; deviceName?: string },
|
||||||
|
) {
|
||||||
|
if (!body.response) throw new BadRequestException('Attestation response required');
|
||||||
|
return this.passkeyService.verifyRegistration(req.user.sub, body.response, body.deviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login-options')
|
||||||
|
@ApiOperation({ summary: 'Get passkey login options' })
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
|
async getLoginOptions(@Body() body: { email?: string }) {
|
||||||
|
return this.passkeyService.generateAuthenticationOptions(body.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: 'Authenticate with passkey' })
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
|
async login(
|
||||||
|
@Body() body: { response: any; challenge: string },
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
if (!body.response || !body.challenge) {
|
||||||
|
throw new BadRequestException('Assertion response and challenge required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await this.passkeyService.verifyAuthentication(body.response, body.challenge);
|
||||||
|
|
||||||
|
// Get user with orgs and generate session
|
||||||
|
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||||
|
if (!user) throw new BadRequestException('User not found');
|
||||||
|
|
||||||
|
await this.usersService.updateLastLogin(userId);
|
||||||
|
const result = await this.authService.generateTokenResponse(user);
|
||||||
|
|
||||||
|
if (result.refreshToken) {
|
||||||
|
res.cookie(COOKIE_NAME, result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { refreshToken: _rt, ...response } = result;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List registered passkeys' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@AllowViewer()
|
||||||
|
async list(@Request() req: any) {
|
||||||
|
return this.passkeyService.listPasskeys(req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Remove a passkey' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async remove(@Request() req: any, @Param('id') passkeyId: string) {
|
||||||
|
await this.passkeyService.removePasskey(req.user.sub, passkeyId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
246
backend/src/modules/auth/passkey.service.ts
Normal file
246
backend/src/modules/auth/passkey.service.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
} from '@simplewebauthn/server';
|
||||||
|
|
||||||
|
// Use inline type aliases to avoid ESM-only @simplewebauthn/types import issue
|
||||||
|
type RegistrationResponseJSON = any;
|
||||||
|
type AuthenticationResponseJSON = any;
|
||||||
|
type AuthenticatorTransportFuture = any;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PasskeyService {
|
||||||
|
private readonly logger = new Logger(PasskeyService.name);
|
||||||
|
private rpID: string;
|
||||||
|
private rpName: string;
|
||||||
|
private origin: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
this.rpID = this.configService.get<string>('WEBAUTHN_RP_ID') || 'localhost';
|
||||||
|
this.rpName = 'HOA LedgerIQ';
|
||||||
|
this.origin = this.configService.get<string>('WEBAUTHN_RP_ORIGIN') || 'http://localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate registration options for navigator.credentials.create().
|
||||||
|
*/
|
||||||
|
async generateRegistrationOptions(userId: string) {
|
||||||
|
const userRows = await this.dataSource.query(
|
||||||
|
`SELECT id, email, first_name, last_name FROM shared.users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (userRows.length === 0) throw new BadRequestException('User not found');
|
||||||
|
const user = userRows[0];
|
||||||
|
|
||||||
|
// Get existing passkeys for exclusion
|
||||||
|
const existingKeys = await this.dataSource.query(
|
||||||
|
`SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: this.rpName,
|
||||||
|
rpID: this.rpID,
|
||||||
|
userID: new TextEncoder().encode(userId),
|
||||||
|
userName: user.email,
|
||||||
|
userDisplayName: `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email,
|
||||||
|
attestationType: 'none',
|
||||||
|
excludeCredentials: existingKeys.map((k: any) => ({
|
||||||
|
id: k.credential_id,
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: k.transports || [],
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'preferred',
|
||||||
|
userVerification: 'preferred',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store challenge temporarily
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET webauthn_challenge = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
[options.challenge, userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify and store a passkey registration.
|
||||||
|
*/
|
||||||
|
async verifyRegistration(userId: string, response: RegistrationResponseJSON, deviceName?: string) {
|
||||||
|
const userRows = await this.dataSource.query(
|
||||||
|
`SELECT webauthn_challenge FROM shared.users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (userRows.length === 0) throw new BadRequestException('User not found');
|
||||||
|
const expectedChallenge = userRows[0].webauthn_challenge;
|
||||||
|
if (!expectedChallenge) throw new BadRequestException('No registration challenge found');
|
||||||
|
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: this.origin,
|
||||||
|
expectedRPID: this.rpID,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
throw new BadRequestException('Passkey registration verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credential } = verification.registrationInfo;
|
||||||
|
|
||||||
|
// Store the passkey
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.user_passkeys (user_id, credential_id, public_key, counter, device_name, transports)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
Buffer.from(credential.id).toString('base64url'),
|
||||||
|
Buffer.from(credential.publicKey).toString('base64url'),
|
||||||
|
credential.counter,
|
||||||
|
deviceName || 'Passkey',
|
||||||
|
credential.transports || [],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear challenge
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET webauthn_challenge = NULL WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Passkey registered for user ${userId}`);
|
||||||
|
return { verified: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate authentication options for navigator.credentials.get().
|
||||||
|
*/
|
||||||
|
async generateAuthenticationOptions(email?: string) {
|
||||||
|
let allowCredentials: any[] | undefined;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const userRows = await this.dataSource.query(
|
||||||
|
`SELECT u.id FROM shared.users u WHERE u.email = $1`,
|
||||||
|
[email],
|
||||||
|
);
|
||||||
|
if (userRows.length > 0) {
|
||||||
|
const passkeys = await this.dataSource.query(
|
||||||
|
`SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`,
|
||||||
|
[userRows[0].id],
|
||||||
|
);
|
||||||
|
allowCredentials = passkeys.map((k: any) => ({
|
||||||
|
id: k.credential_id,
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: k.transports || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: this.rpID,
|
||||||
|
allowCredentials,
|
||||||
|
userVerification: 'preferred',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store challenge — for passkey login we need a temporary storage
|
||||||
|
// Since we don't know the user yet, store in a shared way
|
||||||
|
// In production, use Redis/session. For now, we'll pass it back and verify client-side.
|
||||||
|
return { ...options, challenge: options.challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify authentication and return the user.
|
||||||
|
*/
|
||||||
|
async verifyAuthentication(response: AuthenticationResponseJSON, expectedChallenge: string) {
|
||||||
|
// Find the credential
|
||||||
|
const credId = response.id;
|
||||||
|
const passkeys = await this.dataSource.query(
|
||||||
|
`SELECT p.*, u.id as user_id, u.email
|
||||||
|
FROM shared.user_passkeys p
|
||||||
|
JOIN shared.users u ON u.id = p.user_id
|
||||||
|
WHERE p.credential_id = $1`,
|
||||||
|
[credId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (passkeys.length === 0) {
|
||||||
|
throw new UnauthorizedException('Passkey not recognized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passkey = passkeys[0];
|
||||||
|
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: this.origin,
|
||||||
|
expectedRPID: this.rpID,
|
||||||
|
credential: {
|
||||||
|
id: passkey.credential_id,
|
||||||
|
publicKey: Buffer.from(passkey.public_key, 'base64url'),
|
||||||
|
counter: Number(passkey.counter),
|
||||||
|
transports: (passkey.transports || []) as AuthenticatorTransportFuture[],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verification.verified) {
|
||||||
|
throw new UnauthorizedException('Passkey authentication failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counter and last_used_at
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.user_passkeys SET counter = $1, last_used_at = NOW() WHERE id = $2`,
|
||||||
|
[verification.authenticationInfo.newCounter, passkey.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { userId: passkey.user_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List user's registered passkeys.
|
||||||
|
*/
|
||||||
|
async listPasskeys(userId: string) {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT id, device_name, created_at, last_used_at
|
||||||
|
FROM shared.user_passkeys
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a passkey.
|
||||||
|
*/
|
||||||
|
async removePasskey(userId: string, passkeyId: string): Promise<void> {
|
||||||
|
// Check that user has password or other passkeys
|
||||||
|
const [userRows, passkeyCount] = await Promise.all([
|
||||||
|
this.dataSource.query(`SELECT password_hash FROM shared.users WHERE id = $1`, [userId]),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1`,
|
||||||
|
[userId],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasPassword = !!userRows[0]?.password_hash;
|
||||||
|
const count = parseInt(passkeyCount[0]?.cnt || '0', 10);
|
||||||
|
|
||||||
|
if (!hasPassword && count <= 1) {
|
||||||
|
throw new BadRequestException('Cannot remove your only passkey without a password set');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`DELETE FROM shared.user_passkeys WHERE id = $1 AND user_id = $2`,
|
||||||
|
[passkeyId, userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/src/modules/auth/refresh-token.service.ts
Normal file
98
backend/src/modules/auth/refresh-token.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { randomBytes, createHash } from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RefreshTokenService {
|
||||||
|
private readonly logger = new Logger(RefreshTokenService.name);
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new refresh token for a user.
|
||||||
|
* Returns the raw (unhashed) token to be sent as an httpOnly cookie.
|
||||||
|
*/
|
||||||
|
async createRefreshToken(userId: string): Promise<string> {
|
||||||
|
const rawToken = randomBytes(64).toString('base64url');
|
||||||
|
const tokenHash = this.hashToken(rawToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.refresh_tokens (user_id, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[userId, tokenHash, expiresAt],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rawToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a refresh token. Returns the user_id if valid, null otherwise.
|
||||||
|
*/
|
||||||
|
async validateRefreshToken(rawToken: string): Promise<string | null> {
|
||||||
|
const tokenHash = this.hashToken(rawToken);
|
||||||
|
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT user_id, expires_at, revoked_at
|
||||||
|
FROM shared.refresh_tokens
|
||||||
|
WHERE token_hash = $1`,
|
||||||
|
[tokenHash],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const { user_id, expires_at, revoked_at } = rows[0];
|
||||||
|
|
||||||
|
// Check if revoked
|
||||||
|
if (revoked_at) return null;
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (new Date(expires_at) < new Date()) return null;
|
||||||
|
|
||||||
|
return user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a single refresh token.
|
||||||
|
*/
|
||||||
|
async revokeToken(rawToken: string): Promise<void> {
|
||||||
|
const tokenHash = this.hashToken(rawToken);
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`,
|
||||||
|
[tokenHash],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all refresh tokens for a user ("log out everywhere").
|
||||||
|
*/
|
||||||
|
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.refresh_tokens SET revoked_at = NOW()
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove expired / revoked tokens older than 7 days.
|
||||||
|
* Called periodically to keep the table clean.
|
||||||
|
*/
|
||||||
|
async cleanupExpired(): Promise<number> {
|
||||||
|
const result = await this.dataSource.query(
|
||||||
|
`DELETE FROM shared.refresh_tokens
|
||||||
|
WHERE (expires_at < NOW() - INTERVAL '7 days')
|
||||||
|
OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')`,
|
||||||
|
);
|
||||||
|
const deleted = result?.[1] ?? 0;
|
||||||
|
if (deleted > 0) {
|
||||||
|
this.logger.log(`Cleaned up ${deleted} expired/revoked refresh tokens`);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashToken(rawToken: string): string {
|
||||||
|
return createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
105
backend/src/modules/auth/sso.controller.ts
Normal file
105
backend/src/modules/auth/sso.controller.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
Res,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
const COOKIE_NAME = 'ledgeriq_rt';
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class SsoController {
|
||||||
|
constructor(
|
||||||
|
private ssoService: SsoService,
|
||||||
|
private authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('sso/providers')
|
||||||
|
@ApiOperation({ summary: 'Get available SSO providers' })
|
||||||
|
getProviders() {
|
||||||
|
return this.ssoService.getAvailableProviders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google OAuth routes would be:
|
||||||
|
// GET /auth/google → passport.authenticate('google')
|
||||||
|
// GET /auth/google/callback → passport callback
|
||||||
|
// These are registered conditionally in auth.module.ts if env vars are set.
|
||||||
|
// For now, we'll add the callback handler:
|
||||||
|
|
||||||
|
@Get('google/callback')
|
||||||
|
@ApiOperation({ summary: 'Google OAuth callback' })
|
||||||
|
async googleCallback(@Request() req: any, @Res() res: Response) {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.redirect('/login?error=sso_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.authService.generateTokenResponse(req.user);
|
||||||
|
|
||||||
|
// Set refresh token cookie
|
||||||
|
if (result.refreshToken) {
|
||||||
|
res.cookie(COOKIE_NAME, result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to app with access token in URL fragment (for SPA to pick up)
|
||||||
|
return res.redirect(`/sso-callback?token=${result.accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('azure/callback')
|
||||||
|
@ApiOperation({ summary: 'Azure AD OAuth callback' })
|
||||||
|
async azureCallback(@Request() req: any, @Res() res: Response) {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.redirect('/login?error=sso_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.authService.generateTokenResponse(req.user);
|
||||||
|
|
||||||
|
if (result.refreshToken) {
|
||||||
|
res.cookie(COOKIE_NAME, result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect(`/sso-callback?token=${result.accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sso/link')
|
||||||
|
@ApiOperation({ summary: 'Link SSO provider to current user' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async linkAccount(@Request() req: any) {
|
||||||
|
// This would typically be done via the OAuth redirect flow
|
||||||
|
// For now, it's a placeholder
|
||||||
|
throw new BadRequestException('Use the OAuth redirect flow to link accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('sso/unlink/:provider')
|
||||||
|
@ApiOperation({ summary: 'Unlink SSO provider from current user' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async unlinkAccount(@Request() req: any, @Param('provider') provider: string) {
|
||||||
|
await this.ssoService.unlinkSsoAccount(req.user.sub, provider);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/src/modules/auth/sso.service.ts
Normal file
97
backend/src/modules/auth/sso.service.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
interface SsoProfile {
|
||||||
|
provider: string;
|
||||||
|
providerId: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SsoService {
|
||||||
|
private readonly logger = new Logger(SsoService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dataSource: DataSource,
|
||||||
|
private usersService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing user by SSO provider+id, or by email match, or create new.
|
||||||
|
*/
|
||||||
|
async findOrCreateSsoUser(profile: SsoProfile) {
|
||||||
|
// 1. Try to find by provider + provider ID
|
||||||
|
const byProvider = await this.dataSource.query(
|
||||||
|
`SELECT * FROM shared.users WHERE oauth_provider = $1 AND oauth_provider_id = $2`,
|
||||||
|
[profile.provider, profile.providerId],
|
||||||
|
);
|
||||||
|
if (byProvider.length > 0) {
|
||||||
|
return this.usersService.findByIdWithOrgs(byProvider[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try to find by email match (link accounts)
|
||||||
|
const byEmail = await this.usersService.findByEmail(profile.email);
|
||||||
|
if (byEmail) {
|
||||||
|
// Link the SSO provider to existing account
|
||||||
|
await this.linkSsoAccount(byEmail.id, profile.provider, profile.providerId);
|
||||||
|
return this.usersService.findByIdWithOrgs(byEmail.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create new user
|
||||||
|
const newUser = await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.users (email, first_name, last_name, oauth_provider, oauth_provider_id, is_email_verified)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, true)
|
||||||
|
RETURNING id`,
|
||||||
|
[profile.email, profile.firstName || '', profile.lastName || '', profile.provider, profile.providerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.usersService.findByIdWithOrgs(newUser[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link an SSO provider to an existing user.
|
||||||
|
*/
|
||||||
|
async linkSsoAccount(userId: string, provider: string, providerId: string): Promise<void> {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET oauth_provider = $1, oauth_provider_id = $2, updated_at = NOW() WHERE id = $3`,
|
||||||
|
[provider, providerId, userId],
|
||||||
|
);
|
||||||
|
this.logger.log(`Linked ${provider} SSO to user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink SSO from a user (only if they have a password set).
|
||||||
|
*/
|
||||||
|
async unlinkSsoAccount(userId: string, provider: string): Promise<void> {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT password_hash, oauth_provider FROM shared.users WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) throw new BadRequestException('User not found');
|
||||||
|
if (!rows[0].password_hash) {
|
||||||
|
throw new BadRequestException('Cannot unlink SSO — you must set a password first');
|
||||||
|
}
|
||||||
|
if (rows[0].oauth_provider !== provider) {
|
||||||
|
throw new BadRequestException('SSO provider mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.users SET oauth_provider = NULL, oauth_provider_id = NULL, updated_at = NOW() WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
this.logger.log(`Unlinked ${provider} SSO from user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which SSO providers are configured.
|
||||||
|
*/
|
||||||
|
getAvailableProviders(): { google: boolean; azure: boolean } {
|
||||||
|
return {
|
||||||
|
google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
|
||||||
|
azure: !!(process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
orgId: payload.orgId,
|
orgId: payload.orgId,
|
||||||
orgSchema: payload.orgSchema,
|
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
isSuperadmin: payload.isSuperadmin || false,
|
isSuperadmin: payload.isSuperadmin || false,
|
||||||
impersonatedBy: payload.impersonatedBy || null,
|
impersonatedBy: payload.impersonatedBy || null,
|
||||||
|
|||||||
133
backend/src/modules/billing/billing.controller.ts
Normal file
133
backend/src/modules/billing/billing.controller.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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,
|
||||||
|
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; billingInterval?: 'month' | 'year'; email?: string; businessName?: string },
|
||||||
|
) {
|
||||||
|
if (!body.planId) throw new BadRequestException('planId is required');
|
||||||
|
return this.billingService.createCheckoutSession(
|
||||||
|
body.planId,
|
||||||
|
body.billingInterval || 'month',
|
||||||
|
body.email,
|
||||||
|
body.businessName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('webhooks/stripe')
|
||||||
|
@ApiOperation({ summary: 'Stripe webhook endpoint' })
|
||||||
|
async handleWebhook(@Req() req: RawBodyRequest<ExpressRequest>) {
|
||||||
|
const signature = req.headers['stripe-signature'] as string;
|
||||||
|
if (!signature) throw new BadRequestException('Missing Stripe signature');
|
||||||
|
if (!req.rawBody) throw new BadRequestException('Missing raw body');
|
||||||
|
await this.billingService.handleWebhook(req.rawBody, signature);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('billing/status')
|
||||||
|
@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) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
if (!orgId) throw new BadRequestException('No organization 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/modules/billing/billing.module.ts
Normal file
13
backend/src/modules/billing/billing.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { BillingController } from './billing.controller';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { DatabaseModule } from '../../database/database.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, DatabaseModule],
|
||||||
|
controllers: [BillingController],
|
||||||
|
providers: [BillingService],
|
||||||
|
exports: [BillingService],
|
||||||
|
})
|
||||||
|
export class BillingModule {}
|
||||||
678
backend/src/modules/billing/billing.service.ts
Normal file
678
backend/src/modules/billing/billing.service.ts
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException, RawBodyRequest } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { EmailService } from '../email/email.service';
|
||||||
|
|
||||||
|
const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
|
||||||
|
starter: { name: 'Starter', unitLimit: 50 },
|
||||||
|
professional: { name: 'Professional', unitLimit: 200 },
|
||||||
|
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, { monthly: string; annual: string }>;
|
||||||
|
private requirePaymentForTrial: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
private tenantSchemaService: TenantSchemaService,
|
||||||
|
private authService: AuthService,
|
||||||
|
private emailService: EmailService,
|
||||||
|
) {
|
||||||
|
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
||||||
|
if (secretKey && !secretKey.includes('placeholder')) {
|
||||||
|
this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' as any });
|
||||||
|
this.logger.log('Stripe initialized');
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Stripe not configured — billing endpoints will return stubs');
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
||||||
|
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,
|
||||||
|
billingInterval: BillingInterval = 'month',
|
||||||
|
email?: string,
|
||||||
|
businessName?: string,
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
||||||
|
|
||||||
|
const priceId = this.getPriceId(planId, billingInterval);
|
||||||
|
|
||||||
|
const sessionConfig: Stripe.Checkout.SessionCreateParams = {
|
||||||
|
mode: 'subscription',
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
|
success_url: `${this.getAppUrl()}/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${this.getAppUrl()}/pricing`,
|
||||||
|
customer_email: email || undefined,
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
async handleWebhook(rawBody: Buffer, signature: string): Promise<void> {
|
||||||
|
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Webhook signature verification failed: ${err.message}`);
|
||||||
|
throw new BadRequestException('Invalid webhook signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency check
|
||||||
|
const existing = await this.dataSource.query(
|
||||||
|
`SELECT id FROM shared.stripe_events WHERE id = $1`,
|
||||||
|
[event.id],
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
this.logger.log(`Duplicate Stripe event ${event.id}, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record event
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.stripe_events (id, type, payload) VALUES ($1, $2, $3)`,
|
||||||
|
[event.id, event.type, JSON.stringify(event.data)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispatch
|
||||||
|
switch (event.type) {
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
||||||
|
break;
|
||||||
|
case 'invoice.payment_succeeded':
|
||||||
|
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
|
||||||
|
break;
|
||||||
|
case 'invoice.payment_failed':
|
||||||
|
await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
|
||||||
|
break;
|
||||||
|
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 OR subscription ID.
|
||||||
|
*/
|
||||||
|
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
|
||||||
|
if (!this.stripe) return { status: 'not_configured' };
|
||||||
|
|
||||||
|
// 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' };
|
||||||
|
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT id, status FROM shared.organizations WHERE stripe_customer_id = $1`,
|
||||||
|
[customerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return { status: 'provisioning' };
|
||||||
|
if (['active', 'trial'].includes(rows[0].status)) return { status: 'active' };
|
||||||
|
return { status: 'provisioning' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stripe Customer Portal ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Customer Portal session for managing subscription.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
return_url: `${this.getAppUrl()}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url: session.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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;
|
||||||
|
const subscriptionId = session.subscription as string;
|
||||||
|
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 {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
|
||||||
|
const customerId = invoice.customer as string;
|
||||||
|
// 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 IN ('trial', 'past_due')`,
|
||||||
|
[customerId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
||||||
|
const customerId = invoice.customer as string;
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`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');
|
||||||
|
}
|
||||||
|
this.logger.warn(`Payment failed for customer ${customerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
||||||
|
const customerId = subscription.customer as string;
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.organizations SET status = 'archived', updated_at = NOW()
|
||||||
|
WHERE stripe_customer_id = $1`,
|
||||||
|
[customerId],
|
||||||
|
);
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
async provisionOrganization(
|
||||||
|
customerId: string,
|
||||||
|
subscriptionId: string,
|
||||||
|
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, 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 = EXCLUDED.status,
|
||||||
|
billing_interval = EXCLUDED.billing_interval,
|
||||||
|
trial_ends_at = EXCLUDED.trial_ends_at,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, schema_name`,
|
||||||
|
[businessName, schemaName, status, planId, customerId, subscriptionId, email, billingInterval, trialEndsAt || null],
|
||||||
|
);
|
||||||
|
|
||||||
|
const orgId = orgRows[0].id;
|
||||||
|
const actualSchema = orgRows[0].schema_name;
|
||||||
|
|
||||||
|
// 2. Create tenant schema
|
||||||
|
try {
|
||||||
|
await this.tenantSchemaService.createTenantSchema(actualSchema);
|
||||||
|
this.logger.log(`Created tenant schema: ${actualSchema}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes('already exists')) {
|
||||||
|
this.logger.log(`Schema ${actualSchema} already exists, skipping creation`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create or find user
|
||||||
|
let userRows = await this.dataSource.query(
|
||||||
|
`SELECT id FROM shared.users WHERE email = $1`,
|
||||||
|
[email],
|
||||||
|
);
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
if (userRows.length === 0) {
|
||||||
|
const newUser = await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.users (email, is_email_verified)
|
||||||
|
VALUES ($1, false)
|
||||||
|
RETURNING id`,
|
||||||
|
[email],
|
||||||
|
);
|
||||||
|
userId = newUser[0].id;
|
||||||
|
} else {
|
||||||
|
userId = userRows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create membership (president role)
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.user_organizations (user_id, organization_id, role)
|
||||||
|
VALUES ($1, $2, 'president')
|
||||||
|
ON CONFLICT (user_id, organization_id) DO NOTHING`,
|
||||||
|
[userId, orgId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Generate invite token and "send" activation email
|
||||||
|
const inviteToken = await this.authService.generateInviteToken(userId, orgId, email);
|
||||||
|
const activationUrl = `${this.getAppUrl()}/activate?token=${inviteToken}`;
|
||||||
|
await this.emailService.sendActivationEmail(email, businessName, activationUrl);
|
||||||
|
|
||||||
|
// 6. Initialize onboarding progress
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.onboarding_progress (organization_id) VALUES ($1) ON CONFLICT DO NOTHING`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Provisioning complete for org=${orgId}, user=${userId}, status=${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAppUrl(): string {
|
||||||
|
return this.configService.get<string>('APP_URL') || 'http://localhost';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
|
||||||
|
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||||
|
|
||||||
|
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BoardPlanningProjectionService {
|
||||||
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
|
/** Return cached projection if fresh, otherwise compute. */
|
||||||
|
async getProjection(scenarioId: string) {
|
||||||
|
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||||
|
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||||
|
const scenario = rows[0];
|
||||||
|
|
||||||
|
// Return cache if it exists and is less than 1 hour old
|
||||||
|
if (scenario.projection_cache && scenario.projection_cached_at) {
|
||||||
|
const age = Date.now() - new Date(scenario.projection_cached_at).getTime();
|
||||||
|
if (age < 3600000) return scenario.projection_cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.computeProjection(scenarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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],
|
||||||
|
);
|
||||||
|
const assessments = await this.tenant.query(
|
||||||
|
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY effective_date', [scenarioId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const months = scenario.projection_months || 36;
|
||||||
|
const now = new Date();
|
||||||
|
const startYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth() + 1;
|
||||||
|
|
||||||
|
// ── 1. Baseline state (mirrors reports.service.ts getCashFlowForecast) ──
|
||||||
|
const baseline = await this.getBaselineState(startYear, months);
|
||||||
|
|
||||||
|
// ── 2. Build month-by-month projection ──
|
||||||
|
let { opCash, resCash, opInv, resInv } = baseline.openingBalances;
|
||||||
|
const datapoints: any[] = [];
|
||||||
|
let totalInterestEarned = 0;
|
||||||
|
const interestByInvestment: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < months; i++) {
|
||||||
|
const year = startYear + Math.floor(i / 12);
|
||||||
|
const month = (i % 12) + 1;
|
||||||
|
const key = `${year}-${month}`;
|
||||||
|
const label = `${monthLabels[month - 1]} ${year}`;
|
||||||
|
const isHistorical = year < startYear || (year === startYear && month < currentMonth);
|
||||||
|
|
||||||
|
// Baseline income/expenses from budget
|
||||||
|
const budget = baseline.budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
const baseAssessment = this.getAssessmentIncome(baseline.assessmentGroups, month);
|
||||||
|
const existingMaturity = baseline.maturityIndex[key] || { operating: 0, reserve: 0 };
|
||||||
|
const project = baseline.projectIndex[key] || { operating: 0, reserve: 0 };
|
||||||
|
|
||||||
|
// Scenario investment deltas for this month
|
||||||
|
const invDelta = this.computeInvestmentDelta(investments, year, month);
|
||||||
|
totalInterestEarned += invDelta.interestEarned;
|
||||||
|
for (const [invId, amt] of Object.entries(invDelta.interestByInvestment)) {
|
||||||
|
interestByInvestment[invId] = (interestByInvestment[invId] || 0) + amt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario assessment deltas for this month
|
||||||
|
const asmtDelta = this.computeAssessmentDelta(assessments, baseline.assessmentGroups, year, month);
|
||||||
|
|
||||||
|
if (isHistorical) {
|
||||||
|
// Historical months: use actual changes + scenario deltas
|
||||||
|
const opChange = baseline.histIndex[`${year}-${month}-operating`] || 0;
|
||||||
|
const resChange = baseline.histIndex[`${year}-${month}-reserve`] || 0;
|
||||||
|
opCash += opChange + invDelta.opCashFlow + asmtDelta.operating;
|
||||||
|
resCash += resChange + invDelta.resCashFlow + asmtDelta.reserve;
|
||||||
|
} else {
|
||||||
|
// Forecast months: budget + assessments + scenario deltas
|
||||||
|
const opIncomeMonth = (budget.opIncome > 0 ? budget.opIncome : baseAssessment.operating) + asmtDelta.operating;
|
||||||
|
const resIncomeMonth = (budget.resIncome > 0 ? budget.resIncome : baseAssessment.reserve) + asmtDelta.reserve;
|
||||||
|
|
||||||
|
opCash += opIncomeMonth - budget.opExpense - project.operating + existingMaturity.operating + invDelta.opCashFlow;
|
||||||
|
resCash += resIncomeMonth - budget.resExpense - project.reserve + existingMaturity.reserve + invDelta.resCashFlow;
|
||||||
|
|
||||||
|
// Existing maturities reduce investment balances
|
||||||
|
if (existingMaturity.operating > 0) {
|
||||||
|
opInv -= existingMaturity.operating * 0.96; // approximate principal
|
||||||
|
if (opInv < 0) opInv = 0;
|
||||||
|
}
|
||||||
|
if (existingMaturity.reserve > 0) {
|
||||||
|
resInv -= existingMaturity.reserve * 0.96;
|
||||||
|
if (resInv < 0) resInv = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario investment balance changes
|
||||||
|
opInv += invDelta.opInvChange;
|
||||||
|
resInv += invDelta.resInvChange;
|
||||||
|
if (opInv < 0) opInv = 0;
|
||||||
|
if (resInv < 0) resInv = 0;
|
||||||
|
|
||||||
|
datapoints.push({
|
||||||
|
month: label,
|
||||||
|
year,
|
||||||
|
monthNum: month,
|
||||||
|
is_forecast: !isHistorical,
|
||||||
|
operating_cash: round2(opCash),
|
||||||
|
operating_investments: round2(opInv),
|
||||||
|
reserve_cash: round2(resCash),
|
||||||
|
reserve_investments: round2(resInv),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Summary metrics ──
|
||||||
|
const summary = this.computeSummary(datapoints, baseline, assessments, investments, totalInterestEarned, interestByInvestment);
|
||||||
|
|
||||||
|
const result = { datapoints, summary };
|
||||||
|
|
||||||
|
// ── 4. Cache ──
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE board_scenarios SET projection_cache = $1, projection_cached_at = NOW() WHERE id = $2`,
|
||||||
|
[JSON.stringify(result), scenarioId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare multiple scenarios side-by-side. */
|
||||||
|
async compareScenarios(scenarioIds: string[]) {
|
||||||
|
if (!scenarioIds.length || scenarioIds.length > 4) {
|
||||||
|
throw new NotFoundException('Provide 1 to 4 scenario IDs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarios = await Promise.all(
|
||||||
|
scenarioIds.map(async (id) => {
|
||||||
|
const rows = await this.tenant.query('SELECT id, name, scenario_type, status FROM board_scenarios WHERE id = $1', [id]);
|
||||||
|
if (!rows.length) throw new NotFoundException(`Scenario ${id} not found`);
|
||||||
|
const projection = await this.getProjection(id);
|
||||||
|
return { ...rows[0], projection };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { scenarios };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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(`
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
const resCashRows = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`);
|
||||||
|
const opInvRows = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
|
||||||
|
`);
|
||||||
|
const resInvRows = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Opening balances at start of startYear
|
||||||
|
const openingOp = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
const openingRes = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
|
||||||
|
// Assessment groups
|
||||||
|
const assessmentGroups = await this.tenant.query(
|
||||||
|
`SELECT frequency, regular_assessment, special_assessment, unit_count FROM assessment_groups WHERE is_active = true`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Budgets (official + planned budget fallback)
|
||||||
|
const budgetsByYearMonth: Record<string, any> = {};
|
||||||
|
const endYear = startYear + Math.ceil(months / 12) + 1;
|
||||||
|
for (let yr = startYear; yr <= endYear; yr++) {
|
||||||
|
let budgetRows: any[];
|
||||||
|
try {
|
||||||
|
budgetRows = await this.tenant.query(
|
||||||
|
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
|
||||||
|
SELECT b.account_id, 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,
|
||||||
|
1 as source_priority
|
||||||
|
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT bpl.account_id, bpl.fund_type, a.account_type,
|
||||||
|
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt,
|
||||||
|
2 as source_priority
|
||||||
|
FROM budget_plan_lines bpl
|
||||||
|
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
|
||||||
|
JOIN accounts a ON a.id = bpl.account_id
|
||||||
|
WHERE bp.fiscal_year = $1
|
||||||
|
) combined
|
||||||
|
ORDER BY account_id, fund_type, source_priority`, [yr],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// budget_plan_lines may not exist yet - fall back to official only
|
||||||
|
budgetRows = await this.tenant.query(
|
||||||
|
`SELECT b.fund_type, a.account_type, b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||||
|
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1`, [yr],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let m = 0; m < 12; m++) {
|
||||||
|
const key = `${yr}-${m + 1}`;
|
||||||
|
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
for (const row of budgetRows) {
|
||||||
|
const amt = parseFloat(row[monthNames[m]]) || 0;
|
||||||
|
if (amt === 0) continue;
|
||||||
|
const isOp = row.fund_type === 'operating';
|
||||||
|
if (row.account_type === 'income') {
|
||||||
|
if (isOp) budgetsByYearMonth[key].opIncome += amt;
|
||||||
|
else budgetsByYearMonth[key].resIncome += amt;
|
||||||
|
} else if (row.account_type === 'expense') {
|
||||||
|
if (isOp) budgetsByYearMonth[key].opExpense += amt;
|
||||||
|
else budgetsByYearMonth[key].resExpense += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historical cash changes
|
||||||
|
const historicalCash = await this.tenant.query(`
|
||||||
|
SELECT EXTRACT(YEAR FROM je.entry_date)::int as yr, EXTRACT(MONTH FROM je.entry_date)::int as mo,
|
||||||
|
a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as net_change
|
||||||
|
FROM journal_entry_lines jel
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
JOIN accounts a ON a.id = jel.account_id AND a.account_type = 'asset' AND a.is_active = true
|
||||||
|
WHERE je.entry_date >= $1::date
|
||||||
|
GROUP BY yr, mo, a.fund_type ORDER BY yr, mo
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
|
||||||
|
const histIndex: Record<string, number> = {};
|
||||||
|
for (const row of historicalCash) {
|
||||||
|
histIndex[`${row.yr}-${row.mo}-${row.fund_type}`] = parseFloat(row.net_change) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Investment maturities
|
||||||
|
const maturities = await this.tenant.query(`
|
||||||
|
SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date
|
||||||
|
FROM investment_accounts WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
||||||
|
`);
|
||||||
|
const maturityIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const inv of maturities) {
|
||||||
|
const d = new Date(inv.maturity_date);
|
||||||
|
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||||
|
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
|
||||||
|
const val = parseFloat(inv.current_value) || 0;
|
||||||
|
const rate = parseFloat(inv.interest_rate) || 0;
|
||||||
|
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||||
|
const matDate = new Date(inv.maturity_date);
|
||||||
|
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||||
|
const interestEarned = val * (rate / 100) * (daysHeld / 365);
|
||||||
|
const maturityTotal = val + interestEarned;
|
||||||
|
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
|
||||||
|
else maturityIndex[key].reserve += maturityTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capital project expenses (from unified projects table)
|
||||||
|
const projectExpenses = await this.tenant.query(`
|
||||||
|
SELECT estimated_cost, target_year, target_month, fund_source
|
||||||
|
FROM projects WHERE is_active = true AND status IN ('planned', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
|
||||||
|
`);
|
||||||
|
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const p of projectExpenses) {
|
||||||
|
const yr = parseInt(p.target_year);
|
||||||
|
const mo = parseInt(p.target_month) || 6;
|
||||||
|
const key = `${yr}-${mo}`;
|
||||||
|
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||||
|
const cost = parseFloat(p.estimated_cost) || 0;
|
||||||
|
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||||
|
else projectIndex[key].reserve += cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also include capital_projects table (Capital Planning page)
|
||||||
|
try {
|
||||||
|
const capitalProjectExpenses = await this.tenant.query(`
|
||||||
|
SELECT estimated_cost, target_year, target_month, fund_source
|
||||||
|
FROM capital_projects WHERE status IN ('planned', 'approved', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
|
||||||
|
`);
|
||||||
|
for (const p of capitalProjectExpenses) {
|
||||||
|
const yr = parseInt(p.target_year);
|
||||||
|
const mo = parseInt(p.target_month) || 6;
|
||||||
|
const key = `${yr}-${mo}`;
|
||||||
|
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||||
|
const cost = parseFloat(p.estimated_cost) || 0;
|
||||||
|
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||||
|
else projectIndex[key].reserve += cost;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// capital_projects table may not exist in all tenants
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openingBalances: {
|
||||||
|
opCash: parseFloat(openingOp[0]?.total || '0'),
|
||||||
|
resCash: parseFloat(openingRes[0]?.total || '0'),
|
||||||
|
opInv: parseFloat(opInvRows[0]?.total || '0'),
|
||||||
|
resInv: parseFloat(resInvRows[0]?.total || '0'),
|
||||||
|
},
|
||||||
|
assessmentGroups,
|
||||||
|
budgetsByYearMonth,
|
||||||
|
histIndex,
|
||||||
|
maturityIndex,
|
||||||
|
projectIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAssessmentIncome(assessmentGroups: any[], month: number) {
|
||||||
|
let operating = 0;
|
||||||
|
let reserve = 0;
|
||||||
|
for (const g of assessmentGroups) {
|
||||||
|
const units = parseInt(g.unit_count) || 0;
|
||||||
|
const regular = parseFloat(g.regular_assessment) || 0;
|
||||||
|
const special = parseFloat(g.special_assessment) || 0;
|
||||||
|
const freq = g.frequency || 'monthly';
|
||||||
|
let applies = false;
|
||||||
|
if (freq === 'monthly') applies = true;
|
||||||
|
else if (freq === 'quarterly') applies = [1, 4, 7, 10].includes(month);
|
||||||
|
else if (freq === 'annual') applies = month === 1;
|
||||||
|
if (applies) {
|
||||||
|
operating += regular * units;
|
||||||
|
reserve += special * units;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { operating, reserve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute investment cash flow and balance deltas for a given month from scenario investments. */
|
||||||
|
private computeInvestmentDelta(investments: any[], year: number, month: number) {
|
||||||
|
let opCashFlow = 0;
|
||||||
|
let resCashFlow = 0;
|
||||||
|
let opInvChange = 0;
|
||||||
|
let resInvChange = 0;
|
||||||
|
let interestEarned = 0;
|
||||||
|
const interestByInvestment: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const inv of investments) {
|
||||||
|
if (inv.executed_investment_id) continue; // skip already-executed investments
|
||||||
|
|
||||||
|
const principal = parseFloat(inv.principal) || 0;
|
||||||
|
const rate = parseFloat(inv.interest_rate) || 0;
|
||||||
|
const isOp = inv.fund_type === 'operating';
|
||||||
|
|
||||||
|
// Purchase: cash leaves, investment balance increases
|
||||||
|
if (inv.purchase_date) {
|
||||||
|
const pd = new Date(inv.purchase_date);
|
||||||
|
if (pd.getFullYear() === year && pd.getMonth() + 1 === month) {
|
||||||
|
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
||||||
|
else { resCashFlow -= principal; resInvChange += principal; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maturity: investment returns to cash with interest
|
||||||
|
if (inv.maturity_date) {
|
||||||
|
const md = new Date(inv.maturity_date);
|
||||||
|
if (md.getFullYear() === year && md.getMonth() + 1 === month) {
|
||||||
|
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||||
|
const daysHeld = Math.max((md.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||||
|
const invInterest = principal * (rate / 100) * (daysHeld / 365);
|
||||||
|
const maturityTotal = principal + invInterest;
|
||||||
|
|
||||||
|
interestEarned += invInterest;
|
||||||
|
interestByInvestment[inv.id] = (interestByInvestment[inv.id] || 0) + invInterest;
|
||||||
|
|
||||||
|
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
||||||
|
else { resCashFlow += maturityTotal; 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { opCashFlow, resCashFlow, opInvChange, resInvChange, interestEarned, interestByInvestment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute assessment income delta for a given month from scenario assessment changes. */
|
||||||
|
private computeAssessmentDelta(scenarioAssessments: any[], assessmentGroups: any[], year: number, month: number) {
|
||||||
|
let operating = 0;
|
||||||
|
let reserve = 0;
|
||||||
|
|
||||||
|
const monthDate = new Date(year, month - 1, 1);
|
||||||
|
|
||||||
|
// Get total units across all assessment groups
|
||||||
|
let totalUnits = 0;
|
||||||
|
for (const g of assessmentGroups) {
|
||||||
|
totalUnits += parseInt(g.unit_count) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of scenarioAssessments) {
|
||||||
|
const effectiveDate = new Date(a.effective_date);
|
||||||
|
const endDate = a.end_date ? new Date(a.end_date) : null;
|
||||||
|
|
||||||
|
// Only apply if within the active window
|
||||||
|
if (monthDate < effectiveDate) continue;
|
||||||
|
if (endDate && monthDate > endDate) continue;
|
||||||
|
|
||||||
|
if (a.change_type === 'dues_increase' || a.change_type === 'dues_decrease') {
|
||||||
|
const baseIncome = this.getAssessmentIncome(assessmentGroups, month);
|
||||||
|
const pctChange = parseFloat(a.percentage_change) || 0;
|
||||||
|
const flatChange = parseFloat(a.flat_amount_change) || 0;
|
||||||
|
const sign = a.change_type === 'dues_decrease' ? -1 : 1;
|
||||||
|
|
||||||
|
let delta = 0;
|
||||||
|
if (pctChange > 0) {
|
||||||
|
// Percentage change of base assessment income
|
||||||
|
const target = a.target_fund || 'operating';
|
||||||
|
if (target === 'operating' || target === 'both') {
|
||||||
|
delta = baseIncome.operating * (pctChange / 100) * sign;
|
||||||
|
operating += delta;
|
||||||
|
}
|
||||||
|
if (target === 'reserve' || target === 'both') {
|
||||||
|
delta = baseIncome.reserve * (pctChange / 100) * sign;
|
||||||
|
reserve += delta;
|
||||||
|
}
|
||||||
|
} else if (flatChange > 0) {
|
||||||
|
// Flat per-unit change times total units
|
||||||
|
const target = a.target_fund || 'operating';
|
||||||
|
if (target === 'operating' || target === 'both') {
|
||||||
|
operating += flatChange * totalUnits * sign;
|
||||||
|
}
|
||||||
|
if (target === 'reserve' || target === 'both') {
|
||||||
|
reserve += flatChange * totalUnits * sign;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (a.change_type === 'special_assessment') {
|
||||||
|
// Special assessment distributed across installments
|
||||||
|
const perUnit = parseFloat(a.special_per_unit) || 0;
|
||||||
|
const installments = parseInt(a.special_installments) || 1;
|
||||||
|
const monthsFromStart = (year - effectiveDate.getFullYear()) * 12 + (month - (effectiveDate.getMonth() + 1));
|
||||||
|
|
||||||
|
if (monthsFromStart >= 0 && monthsFromStart < installments) {
|
||||||
|
const monthlyIncome = (perUnit * totalUnits) / installments;
|
||||||
|
const target = a.target_fund || 'reserve';
|
||||||
|
if (target === 'operating' || target === 'both') operating += monthlyIncome;
|
||||||
|
if (target === 'reserve' || target === 'both') reserve += monthlyIncome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { operating, reserve };
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeSummary(
|
||||||
|
datapoints: any[], baseline: any, scenarioAssessments: any[],
|
||||||
|
investments?: any[], totalInterestEarned = 0, interestByInvestment: Record<string, number> = {},
|
||||||
|
) {
|
||||||
|
if (!datapoints.length) return {};
|
||||||
|
|
||||||
|
const last = datapoints[datapoints.length - 1];
|
||||||
|
const first = datapoints[0];
|
||||||
|
|
||||||
|
const allLiquidity = datapoints.map(
|
||||||
|
(d) => d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||||
|
);
|
||||||
|
const minLiquidity = Math.min(...allLiquidity);
|
||||||
|
const endLiquidity = allLiquidity[allLiquidity.length - 1];
|
||||||
|
|
||||||
|
// Reserve coverage: reserve balance / avg monthly reserve expenditure from planned capital projects
|
||||||
|
let totalReserveProjectCost = 0;
|
||||||
|
const projectionYears = Math.max(1, Math.ceil(datapoints.length / 12));
|
||||||
|
for (const key of Object.keys(baseline.projectIndex)) {
|
||||||
|
totalReserveProjectCost += baseline.projectIndex[key].reserve || 0;
|
||||||
|
}
|
||||||
|
const avgMonthlyReserveExpenditure = totalReserveProjectCost > 0
|
||||||
|
? totalReserveProjectCost / (projectionYears * 12)
|
||||||
|
: 0;
|
||||||
|
const reserveCoverageMonths = avgMonthlyReserveExpenditure > 0
|
||||||
|
? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure
|
||||||
|
: 0; // No planned projects = show 0 (N/A)
|
||||||
|
|
||||||
|
// Calculate total principal from scenario investments
|
||||||
|
let totalPrincipal = 0;
|
||||||
|
const investmentInterestDetails: Array<{ id: string; label: string; principal: number; interest: number }> = [];
|
||||||
|
if (investments) {
|
||||||
|
for (const inv of investments) {
|
||||||
|
if (inv.executed_investment_id) continue;
|
||||||
|
const principal = parseFloat(inv.principal) || 0;
|
||||||
|
totalPrincipal += principal;
|
||||||
|
const interest = interestByInvestment[inv.id] || 0;
|
||||||
|
investmentInterestDetails.push({
|
||||||
|
id: inv.id,
|
||||||
|
label: inv.label,
|
||||||
|
principal: round2(principal),
|
||||||
|
interest: round2(interest),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
end_liquidity: round2(endLiquidity),
|
||||||
|
min_liquidity: round2(minLiquidity),
|
||||||
|
reserve_coverage_months: round2(reserveCoverageMonths),
|
||||||
|
end_operating_cash: last.operating_cash,
|
||||||
|
end_reserve_cash: last.reserve_cash,
|
||||||
|
end_operating_investments: last.operating_investments,
|
||||||
|
end_reserve_investments: last.reserve_investments,
|
||||||
|
period_change: round2(endLiquidity - allLiquidity[0]),
|
||||||
|
total_interest_earned: round2(totalInterestEarned),
|
||||||
|
total_principal_invested: round2(totalPrincipal),
|
||||||
|
roi_percentage: totalPrincipal > 0 ? round2((totalInterestEarned / totalPrincipal) * 100) : 0,
|
||||||
|
investment_interest_details: investmentInterestDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
229
backend/src/modules/board-planning/board-planning.controller.ts
Normal file
229
backend/src/modules/board-planning/board-planning.controller.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
|
import { BoardPlanningService } from './board-planning.service';
|
||||||
|
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||||
|
import { BudgetPlanningService } from './budget-planning.service';
|
||||||
|
|
||||||
|
@ApiTags('board-planning')
|
||||||
|
@Controller('board-planning')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class BoardPlanningController {
|
||||||
|
constructor(
|
||||||
|
private service: BoardPlanningService,
|
||||||
|
private projection: BoardPlanningProjectionService,
|
||||||
|
private budgetPlanning: BudgetPlanningService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Scenarios ──
|
||||||
|
|
||||||
|
@Get('scenarios')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
listScenarios(@Query('type') type?: string) {
|
||||||
|
return this.service.listScenarios(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('scenarios/:id')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
getScenario(@Param('id') id: string) {
|
||||||
|
return this.service.getScenario(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
createScenario(@Body() dto: any, @Req() req: any) {
|
||||||
|
return this.service.createScenario(dto, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('scenarios/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
||||||
|
return this.service.updateScenario(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('scenarios/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
deleteScenario(@Param('id') id: string) {
|
||||||
|
return this.service.deleteScenario(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario Investments ──
|
||||||
|
|
||||||
|
@Get('scenarios/:scenarioId/investments')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
listInvestments(@Param('scenarioId') scenarioId: string) {
|
||||||
|
return this.service.listInvestments(scenarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios/:scenarioId/investments')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
|
return this.service.addInvestment(scenarioId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
|
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('investments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
||||||
|
return this.service.updateInvestment(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('investments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
removeInvestment(@Param('id') id: string) {
|
||||||
|
return this.service.removeInvestment(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario Assessments ──
|
||||||
|
|
||||||
|
@Get('scenarios/:scenarioId/assessments')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
listAssessments(@Param('scenarioId') scenarioId: string) {
|
||||||
|
return this.service.listAssessments(scenarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios/:scenarioId/assessments')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
|
return this.service.addAssessment(scenarioId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('assessments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
||||||
|
return this.service.updateAssessment(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('assessments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
removeAssessment(@Param('id') id: string) {
|
||||||
|
return this.service.removeAssessment(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projections ──
|
||||||
|
|
||||||
|
@Get('scenarios/:id/projection')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
getProjection(@Param('id') id: string) {
|
||||||
|
return this.projection.getProjection(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios/:id/projection/refresh')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
refreshProjection(@Param('id') id: string) {
|
||||||
|
return this.projection.computeProjection(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comparison ──
|
||||||
|
|
||||||
|
@Get('compare')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
compareScenarios(@Query('ids') ids: string) {
|
||||||
|
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
return this.projection.compareScenarios(scenarioIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execute Investment ──
|
||||||
|
|
||||||
|
@Post('investments/:id/execute')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
executeInvestment(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: { executionDate: string },
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
return this.service.executeInvestment(id, dto.executionDate, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget Planning ──
|
||||||
|
|
||||||
|
@Get('budget-plans')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
listBudgetPlans() {
|
||||||
|
return this.budgetPlanning.listPlans();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('budget-plans/available-years')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
getAvailableYears() {
|
||||||
|
return this.budgetPlanning.getAvailableYears();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('budget-plans/:year')
|
||||||
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
getBudgetPlan(@Param('year') year: string) {
|
||||||
|
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('budget-plans')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
||||||
|
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('budget-plans/:year/lines')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
||||||
|
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('budget-plans/:year/inflation')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
||||||
|
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('budget-plans/:year/status')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
||||||
|
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('budget-plans/:year/import')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
importBudgetPlanLines(
|
||||||
|
@Param('year') year: string,
|
||||||
|
@Body() lines: any[],
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
return this.budgetPlanning.importLines(parseInt(year, 10), lines, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('budget-plans/:year/template')
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
|
async getBudgetPlanTemplate(
|
||||||
|
@Param('year') year: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const csv = await this.budgetPlanning.getTemplate(parseInt(year, 10));
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'text/csv',
|
||||||
|
'Content-Disposition': `attachment; filename="budget_template_${year}.csv"`,
|
||||||
|
});
|
||||||
|
res.send(csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('budget-plans/:year')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
|
deleteBudgetPlan(@Param('year') year: string) {
|
||||||
|
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/modules/board-planning/board-planning.module.ts
Normal file
12
backend/src/modules/board-planning/board-planning.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BoardPlanningController } from './board-planning.controller';
|
||||||
|
import { BoardPlanningService } from './board-planning.service';
|
||||||
|
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||||
|
import { BudgetPlanningService } from './budget-planning.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BoardPlanningController],
|
||||||
|
providers: [BoardPlanningService, BoardPlanningProjectionService, BudgetPlanningService],
|
||||||
|
exports: [BoardPlanningService, BudgetPlanningService],
|
||||||
|
})
|
||||||
|
export class BoardPlanningModule {}
|
||||||
383
backend/src/modules/board-planning/board-planning.service.ts
Normal file
383
backend/src/modules/board-planning/board-planning.service.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BoardPlanningService {
|
||||||
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
|
// ── Scenarios ──
|
||||||
|
|
||||||
|
async listScenarios(type?: string) {
|
||||||
|
let sql = `
|
||||||
|
SELECT bs.*,
|
||||||
|
(SELECT COUNT(*) FROM scenario_investments si WHERE si.scenario_id = bs.id) as investment_count,
|
||||||
|
(SELECT COALESCE(SUM(si.principal), 0) FROM scenario_investments si WHERE si.scenario_id = bs.id) as total_principal,
|
||||||
|
(SELECT COUNT(*) FROM scenario_assessments sa WHERE sa.scenario_id = bs.id) as assessment_count
|
||||||
|
FROM board_scenarios bs
|
||||||
|
WHERE bs.status != 'archived'
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
if (type) {
|
||||||
|
params.push(type);
|
||||||
|
sql += ` AND bs.scenario_type = $${params.length}`;
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY bs.updated_at DESC';
|
||||||
|
return this.tenant.query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScenario(id: string) {
|
||||||
|
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
|
||||||
|
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||||
|
const scenario = rows[0];
|
||||||
|
|
||||||
|
const investments = await this.tenant.query(
|
||||||
|
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
const assessments = await this.tenant.query(
|
||||||
|
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...scenario, investments, assessments };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createScenario(dto: any, userId: string) {
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO board_scenarios (name, description, scenario_type, projection_months, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||||
|
[dto.name, dto.description || null, dto.scenarioType, dto.projectionMonths || 36, userId],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateScenario(id: string, dto: any) {
|
||||||
|
await this.getScenarioRow(id);
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`UPDATE board_scenarios SET
|
||||||
|
name = COALESCE($2, name),
|
||||||
|
description = COALESCE($3, description),
|
||||||
|
status = COALESCE($4, status),
|
||||||
|
projection_months = COALESCE($5, projection_months),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[id, dto.name, dto.description, dto.status, dto.projectionMonths],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteScenario(id: string) {
|
||||||
|
await this.getScenarioRow(id);
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE board_scenarios SET status = 'archived', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario Investments ──
|
||||||
|
|
||||||
|
async listInvestments(scenarioId: string) {
|
||||||
|
return this.tenant.query(
|
||||||
|
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
|
||||||
|
[scenarioId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addInvestment(scenarioId: string, dto: any) {
|
||||||
|
await this.getScenarioRow(scenarioId);
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO scenario_investments
|
||||||
|
(scenario_id, source_recommendation_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, $11, $12, $13, $14)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
scenarioId, dto.sourceRecommendationId || null, dto.label,
|
||||||
|
dto.investmentType || null, dto.fundType,
|
||||||
|
dto.principal, dto.interestRate || null, dto.termMonths || null,
|
||||||
|
dto.institution || null, dto.purchaseDate || null, dto.maturityDate || null,
|
||||||
|
dto.autoRenew || false, dto.notes || null, dto.sortOrder || 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await this.invalidateProjectionCache(scenarioId);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async addInvestmentFromRecommendation(scenarioId: string, dto: any) {
|
||||||
|
await this.getScenarioRow(scenarioId);
|
||||||
|
|
||||||
|
// Helper: compute maturity date from purchase date + term months
|
||||||
|
const computeMaturityDate = (purchaseDate: string | null, termMonths: number | null): string | null => {
|
||||||
|
if (!purchaseDate || !termMonths) return null;
|
||||||
|
const d = new Date(purchaseDate);
|
||||||
|
d.setMonth(d.getMonth() + termMonths);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDate = dto.startDate || null; // ISO date string e.g. "2026-03-16"
|
||||||
|
|
||||||
|
// If the recommendation has components (e.g. CD ladder with multiple CDs), create one row per component
|
||||||
|
const components = dto.components as any[] | undefined;
|
||||||
|
if (components && Array.isArray(components) && components.length > 0) {
|
||||||
|
const results: any[] = [];
|
||||||
|
for (let i = 0; i < components.length; i++) {
|
||||||
|
const comp = components[i];
|
||||||
|
const termMonths = comp.term_months || null;
|
||||||
|
const maturityDate = computeMaturityDate(startDate, termMonths);
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO scenario_investments
|
||||||
|
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||||
|
principal, interest_rate, term_months, institution, purchase_date, maturity_date,
|
||||||
|
notes, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
scenarioId, dto.sourceRecommendationId || null,
|
||||||
|
comp.label || `${dto.title || 'AI Recommendation'} - Part ${i + 1}`,
|
||||||
|
comp.investment_type || dto.investmentType || null,
|
||||||
|
dto.fundType || 'reserve',
|
||||||
|
comp.amount || 0, comp.rate || null,
|
||||||
|
termMonths, comp.bank_name || dto.bankName || null,
|
||||||
|
startDate, maturityDate,
|
||||||
|
dto.rationale || dto.notes || null,
|
||||||
|
i,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
results.push(rows[0]);
|
||||||
|
}
|
||||||
|
await this.invalidateProjectionCache(scenarioId);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single investment (no components)
|
||||||
|
const termMonths = dto.termMonths || null;
|
||||||
|
const maturityDate = computeMaturityDate(startDate, termMonths);
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO scenario_investments
|
||||||
|
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||||
|
principal, interest_rate, term_months, institution, purchase_date, maturity_date, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
scenarioId, dto.sourceRecommendationId || null,
|
||||||
|
dto.title || dto.label || 'AI Recommendation',
|
||||||
|
dto.investmentType || null, dto.fundType || 'reserve',
|
||||||
|
dto.suggestedAmount || 0, dto.suggestedRate || null,
|
||||||
|
termMonths, dto.bankName || null,
|
||||||
|
startDate, maturityDate,
|
||||||
|
dto.rationale || dto.notes || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await this.invalidateProjectionCache(scenarioId);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInvestment(id: string, dto: any) {
|
||||||
|
const inv = await this.getInvestmentRow(id);
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`UPDATE scenario_investments SET
|
||||||
|
label = COALESCE($2, label),
|
||||||
|
investment_type = COALESCE($3, investment_type),
|
||||||
|
fund_type = COALESCE($4, fund_type),
|
||||||
|
principal = COALESCE($5, principal),
|
||||||
|
interest_rate = COALESCE($6, interest_rate),
|
||||||
|
term_months = COALESCE($7, term_months),
|
||||||
|
institution = COALESCE($8, institution),
|
||||||
|
purchase_date = COALESCE($9, purchase_date),
|
||||||
|
maturity_date = COALESCE($10, maturity_date),
|
||||||
|
auto_renew = COALESCE($11, auto_renew),
|
||||||
|
notes = COALESCE($12, notes),
|
||||||
|
sort_order = COALESCE($13, sort_order),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[
|
||||||
|
id, dto.label, dto.investmentType, dto.fundType,
|
||||||
|
dto.principal, dto.interestRate, dto.termMonths,
|
||||||
|
dto.institution, dto.purchaseDate, dto.maturityDate,
|
||||||
|
dto.autoRenew, dto.notes, dto.sortOrder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await this.invalidateProjectionCache(inv.scenario_id);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeInvestment(id: string) {
|
||||||
|
const inv = await this.getInvestmentRow(id);
|
||||||
|
await this.tenant.query('DELETE FROM scenario_investments WHERE id = $1', [id]);
|
||||||
|
await this.invalidateProjectionCache(inv.scenario_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario Assessments ──
|
||||||
|
|
||||||
|
async listAssessments(scenarioId: string) {
|
||||||
|
return this.tenant.query(
|
||||||
|
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
|
||||||
|
[scenarioId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAssessment(scenarioId: string, dto: any) {
|
||||||
|
await this.getScenarioRow(scenarioId);
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO scenario_assessments
|
||||||
|
(scenario_id, change_type, label, target_fund, percentage_change,
|
||||||
|
flat_amount_change, special_total, special_per_unit, special_installments,
|
||||||
|
effective_date, end_date, applies_to_group_id, notes, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
scenarioId, dto.changeType, dto.label, dto.targetFund || 'operating',
|
||||||
|
dto.percentageChange || null, dto.flatAmountChange || null,
|
||||||
|
dto.specialTotal || null, dto.specialPerUnit || null,
|
||||||
|
dto.specialInstallments || 1, dto.effectiveDate,
|
||||||
|
dto.endDate || null, dto.appliesToGroupId || null,
|
||||||
|
dto.notes || null, dto.sortOrder || 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await this.invalidateProjectionCache(scenarioId);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAssessment(id: string, dto: any) {
|
||||||
|
const asmt = await this.getAssessmentRow(id);
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`UPDATE scenario_assessments SET
|
||||||
|
change_type = COALESCE($2, change_type),
|
||||||
|
label = COALESCE($3, label),
|
||||||
|
target_fund = COALESCE($4, target_fund),
|
||||||
|
percentage_change = COALESCE($5, percentage_change),
|
||||||
|
flat_amount_change = COALESCE($6, flat_amount_change),
|
||||||
|
special_total = COALESCE($7, special_total),
|
||||||
|
special_per_unit = COALESCE($8, special_per_unit),
|
||||||
|
special_installments = COALESCE($9, special_installments),
|
||||||
|
effective_date = COALESCE($10, effective_date),
|
||||||
|
end_date = COALESCE($11, end_date),
|
||||||
|
applies_to_group_id = COALESCE($12, applies_to_group_id),
|
||||||
|
notes = COALESCE($13, notes),
|
||||||
|
sort_order = COALESCE($14, sort_order),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[
|
||||||
|
id, dto.changeType, dto.label, dto.targetFund,
|
||||||
|
dto.percentageChange, dto.flatAmountChange,
|
||||||
|
dto.specialTotal, dto.specialPerUnit, dto.specialInstallments,
|
||||||
|
dto.effectiveDate, dto.endDate, dto.appliesToGroupId,
|
||||||
|
dto.notes, dto.sortOrder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await this.invalidateProjectionCache(asmt.scenario_id);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAssessment(id: string) {
|
||||||
|
const asmt = await this.getAssessmentRow(id);
|
||||||
|
await this.tenant.query('DELETE FROM scenario_assessments WHERE id = $1', [id]);
|
||||||
|
await this.invalidateProjectionCache(asmt.scenario_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execute Investment (Story 1D) ──
|
||||||
|
|
||||||
|
async executeInvestment(investmentId: string, executionDate: string, userId: string) {
|
||||||
|
const inv = await this.getInvestmentRow(investmentId);
|
||||||
|
if (inv.executed_investment_id) {
|
||||||
|
throw new BadRequestException('This investment has already been executed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create real investment_accounts record
|
||||||
|
const invRows = await this.tenant.query(
|
||||||
|
`INSERT INTO investment_accounts
|
||||||
|
(name, institution, investment_type, fund_type, principal, interest_rate,
|
||||||
|
maturity_date, purchase_date, current_value, notes, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
inv.label, inv.institution, inv.investment_type || 'cd',
|
||||||
|
inv.fund_type, inv.principal, inv.interest_rate || 0,
|
||||||
|
inv.maturity_date, executionDate, inv.principal,
|
||||||
|
`Executed from scenario investment. ${inv.notes || ''}`.trim(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const realInvestment = invRows[0];
|
||||||
|
|
||||||
|
// 2. Create journal entry at the execution date
|
||||||
|
const entryDate = new Date(executionDate);
|
||||||
|
const year = entryDate.getFullYear();
|
||||||
|
const month = entryDate.getMonth() + 1;
|
||||||
|
|
||||||
|
const periods = await this.tenant.query(
|
||||||
|
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
if (periods.length) {
|
||||||
|
const primaryRows = await this.tenant.query(
|
||||||
|
`SELECT id, name FROM accounts WHERE is_primary = true AND fund_type = $1 AND is_active = true LIMIT 1`,
|
||||||
|
[inv.fund_type],
|
||||||
|
);
|
||||||
|
const equityAccountNumber = inv.fund_type === 'reserve' ? '3100' : '3000';
|
||||||
|
const equityRows = await this.tenant.query(
|
||||||
|
'SELECT id FROM accounts WHERE account_number = $1',
|
||||||
|
[equityAccountNumber],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (primaryRows.length && equityRows.length) {
|
||||||
|
const memo = `Transfer to investment: ${inv.label}`;
|
||||||
|
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 *`,
|
||||||
|
[executionDate, memo, periods[0].id, userId],
|
||||||
|
);
|
||||||
|
const je = jeRows[0];
|
||||||
|
// Credit primary asset account (reduces cash)
|
||||||
|
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, primaryRows[0].id, inv.principal, memo],
|
||||||
|
);
|
||||||
|
// Debit equity offset account
|
||||||
|
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, equityRows[0].id, inv.principal, memo],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Link back to scenario investment
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE scenario_investments SET executed_investment_id = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
[realInvestment.id, investmentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.invalidateProjectionCache(inv.scenario_id);
|
||||||
|
return realInvestment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
private async getScenarioRow(id: string) {
|
||||||
|
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
|
||||||
|
if (!rows.length) throw new NotFoundException('Scenario not found');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInvestmentRow(id: string) {
|
||||||
|
const rows = await this.tenant.query('SELECT * FROM scenario_investments WHERE id = $1', [id]);
|
||||||
|
if (!rows.length) throw new NotFoundException('Scenario investment not found');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAssessmentRow(id: string) {
|
||||||
|
const rows = await this.tenant.query('SELECT * FROM scenario_assessments WHERE id = $1', [id]);
|
||||||
|
if (!rows.length) throw new NotFoundException('Scenario assessment not found');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateProjectionCache(scenarioId: string) {
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE board_scenarios SET projection_cache = NULL, projection_cached_at = NULL, updated_at = NOW() WHERE id = $1`,
|
||||||
|
[scenarioId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
407
backend/src/modules/board-planning/budget-planning.service.ts
Normal file
407
backend/src/modules/board-planning/budget-planning.service.ts
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
|
||||||
|
const monthCols = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BudgetPlanningService {
|
||||||
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
|
// ── Plans CRUD ──
|
||||||
|
|
||||||
|
async listPlans() {
|
||||||
|
return this.tenant.query(
|
||||||
|
`SELECT bp.*,
|
||||||
|
(SELECT COUNT(*) FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = bp.id) as line_count
|
||||||
|
FROM budget_plans bp ORDER BY bp.fiscal_year`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlan(fiscalYear: number) {
|
||||||
|
const plans = await this.tenant.query(
|
||||||
|
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||||
|
);
|
||||||
|
if (!plans.length) return null;
|
||||||
|
|
||||||
|
const plan = plans[0];
|
||||||
|
const lines = await this.tenant.query(
|
||||||
|
`SELECT bpl.*, a.account_number, a.name as account_name, a.account_type, a.fund_type as account_fund_type
|
||||||
|
FROM budget_plan_lines bpl
|
||||||
|
JOIN accounts a ON a.id = bpl.account_id
|
||||||
|
WHERE bpl.budget_plan_id = $1
|
||||||
|
ORDER BY a.account_number`,
|
||||||
|
[plan.id],
|
||||||
|
);
|
||||||
|
return { ...plan, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableYears() {
|
||||||
|
// Find the latest year that has official budgets
|
||||||
|
const result = await this.tenant.query(
|
||||||
|
'SELECT MAX(fiscal_year) as max_year FROM budgets',
|
||||||
|
);
|
||||||
|
const rawMaxYear = result[0]?.max_year;
|
||||||
|
const latestBudgetYear = rawMaxYear || null; // null means no budgets exist at all
|
||||||
|
const baseYear = rawMaxYear || new Date().getFullYear();
|
||||||
|
|
||||||
|
// Also find years that already have plans
|
||||||
|
const existingPlans = await this.tenant.query(
|
||||||
|
'SELECT fiscal_year, status FROM budget_plans ORDER BY fiscal_year',
|
||||||
|
);
|
||||||
|
const planYears = existingPlans.map((p: any) => ({
|
||||||
|
year: p.fiscal_year,
|
||||||
|
status: p.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Return next 5 years (or current year + 4 if no budgets exist)
|
||||||
|
const years = [];
|
||||||
|
const startOffset = rawMaxYear ? 1 : 0; // include current year if no budgets exist
|
||||||
|
for (let i = startOffset; i <= startOffset + 4; i++) {
|
||||||
|
const yr = baseYear + i;
|
||||||
|
const existing = planYears.find((p: any) => p.year === yr);
|
||||||
|
years.push({
|
||||||
|
year: yr,
|
||||||
|
hasPlan: !!existing,
|
||||||
|
status: existing?.status || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { latestBudgetYear, years, existingPlans: planYears };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPlan(fiscalYear: number, baseYear: number, inflationRate: number, userId: string) {
|
||||||
|
// Check no existing plan for this year
|
||||||
|
const existing = await this.tenant.query(
|
||||||
|
'SELECT id FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||||
|
);
|
||||||
|
if (existing.length) {
|
||||||
|
throw new BadRequestException(`A budget plan already exists for ${fiscalYear}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the plan
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
|
[fiscalYear, baseYear, inflationRate, userId],
|
||||||
|
);
|
||||||
|
const plan = rows[0];
|
||||||
|
|
||||||
|
// Generate inflated lines from base year
|
||||||
|
await this.generateLines(plan.id, baseYear, inflationRate, fiscalYear);
|
||||||
|
|
||||||
|
return this.getPlan(fiscalYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateLines(planId: string, baseYear: number, inflationRate: number, fiscalYear: number) {
|
||||||
|
// Delete existing non-manually-adjusted lines (or all if fresh)
|
||||||
|
await this.tenant.query(
|
||||||
|
'DELETE FROM budget_plan_lines WHERE budget_plan_id = $1 AND is_manually_adjusted = false',
|
||||||
|
[planId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try official budgets first, then fall back to budget_plan_lines for base year
|
||||||
|
let baseLines = await this.tenant.query(
|
||||||
|
`SELECT b.account_id, b.fund_type, ${monthCols.join(', ')}
|
||||||
|
FROM budgets b WHERE b.fiscal_year = $1`,
|
||||||
|
[baseYear],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!baseLines.length) {
|
||||||
|
// Fall back to budget_plan_lines for base year (for chained plans)
|
||||||
|
baseLines = await this.tenant.query(
|
||||||
|
`SELECT bpl.account_id, bpl.fund_type, ${monthCols.join(', ')}
|
||||||
|
FROM budget_plan_lines bpl
|
||||||
|
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
|
||||||
|
WHERE bp.fiscal_year = $1`,
|
||||||
|
[baseYear],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseLines.length) return;
|
||||||
|
|
||||||
|
// Compound inflation: (1 + rate/100)^yearsGap
|
||||||
|
const yearsGap = Math.max(1, fiscalYear - baseYear);
|
||||||
|
const multiplier = Math.pow(1 + inflationRate / 100, yearsGap);
|
||||||
|
|
||||||
|
// Get existing manually-adjusted lines to avoid duplicates
|
||||||
|
const manualLines = await this.tenant.query(
|
||||||
|
`SELECT account_id, fund_type FROM budget_plan_lines
|
||||||
|
WHERE budget_plan_id = $1 AND is_manually_adjusted = true`,
|
||||||
|
[planId],
|
||||||
|
);
|
||||||
|
const manualKeys = new Set(manualLines.map((l: any) => `${l.account_id}-${l.fund_type}`));
|
||||||
|
|
||||||
|
for (const line of baseLines) {
|
||||||
|
const key = `${line.account_id}-${line.fund_type}`;
|
||||||
|
if (manualKeys.has(key)) continue; // Don't overwrite manual edits
|
||||||
|
|
||||||
|
const inflated = monthCols.map((m) => {
|
||||||
|
const val = parseFloat(line[m]) || 0;
|
||||||
|
return Math.round(val * multiplier * 100) / 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
|
||||||
|
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
ON CONFLICT (budget_plan_id, account_id, fund_type)
|
||||||
|
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
|
||||||
|
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
|
||||||
|
is_manually_adjusted=false`,
|
||||||
|
[planId, line.account_id, line.fund_type, ...inflated],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLines(planId: string, lines: any[]) {
|
||||||
|
for (const line of lines) {
|
||||||
|
const monthValues = monthCols.map((m) => {
|
||||||
|
const key = m === 'dec_amt' ? 'dec' : m;
|
||||||
|
return line[key] ?? line[m] ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
|
||||||
|
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true)
|
||||||
|
ON CONFLICT (budget_plan_id, account_id, fund_type)
|
||||||
|
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
|
||||||
|
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
|
||||||
|
is_manually_adjusted=true`,
|
||||||
|
[planId, line.accountId, line.fundType, ...monthValues],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { updated: lines.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInflation(fiscalYear: number, inflationRate: number) {
|
||||||
|
const plans = await this.tenant.query(
|
||||||
|
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||||
|
);
|
||||||
|
if (!plans.length) throw new NotFoundException('Budget plan not found');
|
||||||
|
|
||||||
|
const plan = plans[0];
|
||||||
|
if (plan.status === 'ratified') {
|
||||||
|
throw new BadRequestException('Cannot modify inflation on a ratified budget');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.tenant.query(
|
||||||
|
'UPDATE budget_plans SET inflation_rate = $1, updated_at = NOW() WHERE fiscal_year = $2',
|
||||||
|
[inflationRate, fiscalYear],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-generate only non-manually-adjusted lines
|
||||||
|
await this.generateLines(plan.id, plan.base_year, inflationRate, fiscalYear);
|
||||||
|
|
||||||
|
return this.getPlan(fiscalYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
async advanceStatus(fiscalYear: number, newStatus: string, userId: string) {
|
||||||
|
const plans = await this.tenant.query(
|
||||||
|
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||||
|
);
|
||||||
|
if (!plans.length) throw new NotFoundException('Budget plan not found');
|
||||||
|
|
||||||
|
const plan = plans[0];
|
||||||
|
const validTransitions: Record<string, string[]> = {
|
||||||
|
planning: ['approved'],
|
||||||
|
approved: ['planning', 'ratified'],
|
||||||
|
ratified: ['approved'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!validTransitions[plan.status]?.includes(newStatus)) {
|
||||||
|
throw new BadRequestException(`Cannot transition from ${plan.status} to ${newStatus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If reverting from ratified, remove official budget
|
||||||
|
if (plan.status === 'ratified' && newStatus === 'approved') {
|
||||||
|
await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = ['status = $1', 'updated_at = NOW()'];
|
||||||
|
const params: any[] = [newStatus];
|
||||||
|
|
||||||
|
if (newStatus === 'approved') {
|
||||||
|
updates.push(`approved_by = $${params.length + 1}`, `approved_at = NOW()`);
|
||||||
|
params.push(userId);
|
||||||
|
} else if (newStatus === 'ratified') {
|
||||||
|
updates.push(`ratified_by = $${params.length + 1}`, `ratified_at = NOW()`);
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(fiscalYear);
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE budget_plans SET ${updates.join(', ')} WHERE fiscal_year = $${params.length}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If ratifying, copy to official budgets
|
||||||
|
if (newStatus === 'ratified') {
|
||||||
|
await this.ratifyToOfficial(plan.id, fiscalYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPlan(fiscalYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ratifyToOfficial(planId: string, fiscalYear: number) {
|
||||||
|
// Clear existing official budgets for this year
|
||||||
|
await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]);
|
||||||
|
|
||||||
|
// Copy plan lines to official budgets
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO budgets (fiscal_year, account_id, fund_type,
|
||||||
|
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes)
|
||||||
|
SELECT $1, bpl.account_id, bpl.fund_type,
|
||||||
|
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun,
|
||||||
|
bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt, bpl.notes
|
||||||
|
FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = $2`,
|
||||||
|
[fiscalYear, planId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async importLines(fiscalYear: number, lines: any[], userId: string) {
|
||||||
|
// Ensure plan exists (create if needed)
|
||||||
|
let plans = await this.tenant.query(
|
||||||
|
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||||
|
);
|
||||||
|
if (!plans.length) {
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by)
|
||||||
|
VALUES ($1, $1, 0, $2) RETURNING *`,
|
||||||
|
[fiscalYear, userId],
|
||||||
|
);
|
||||||
|
plans = await this.tenant.query(
|
||||||
|
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const plan = plans[0];
|
||||||
|
const errors: string[] = [];
|
||||||
|
const created: string[] = [];
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const accountNumber = String(line.accountNumber || line.account_number || '').trim();
|
||||||
|
const accountName = String(line.accountName || line.account_name || '').trim();
|
||||||
|
if (!accountNumber) {
|
||||||
|
errors.push(`Row ${i + 1}: missing account_number`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accounts = await this.tenant.query(
|
||||||
|
`SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
||||||
|
[accountNumber],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-create account if not found
|
||||||
|
if ((!accounts || accounts.length === 0) && accountName) {
|
||||||
|
const accountType = this.inferAccountType(accountNumber, accountName);
|
||||||
|
const fundType = this.inferFundType(accountNumber, accountName);
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO accounts (account_number, name, account_type, fund_type, is_system)
|
||||||
|
VALUES ($1, $2, $3, $4, false)`,
|
||||||
|
[accountNumber, accountName, accountType, fundType],
|
||||||
|
);
|
||||||
|
accounts = await this.tenant.query(
|
||||||
|
`SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
||||||
|
[accountNumber],
|
||||||
|
);
|
||||||
|
created.push(`${accountNumber} - ${accountName} (${accountType}/${fundType})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accounts || accounts.length === 0) {
|
||||||
|
errors.push(`Row ${i + 1}: account "${accountNumber}" not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accounts[0];
|
||||||
|
const fundType = line.fund_type || account.fund_type || 'operating';
|
||||||
|
const monthValues = monthCols.map((m) => {
|
||||||
|
const key = m === 'dec_amt' ? 'dec' : m;
|
||||||
|
return this.parseCurrency(line[key] ?? line[m] ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type,
|
||||||
|
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true)
|
||||||
|
ON CONFLICT (budget_plan_id, account_id, fund_type)
|
||||||
|
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9,
|
||||||
|
jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15,
|
||||||
|
is_manually_adjusted=true`,
|
||||||
|
[plan.id, account.id, fundType, ...monthValues],
|
||||||
|
);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported, errors, created, plan: await this.getPlan(fiscalYear) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplate(fiscalYear: number): Promise<string> {
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`SELECT a.account_number, a.name as account_name,
|
||||||
|
COALESCE(b.jan, 0) as jan, COALESCE(b.feb, 0) as feb,
|
||||||
|
COALESCE(b.mar, 0) as mar, COALESCE(b.apr, 0) as apr,
|
||||||
|
COALESCE(b.may, 0) as may, COALESCE(b.jun, 0) as jun,
|
||||||
|
COALESCE(b.jul, 0) as jul, COALESCE(b.aug, 0) as aug,
|
||||||
|
COALESCE(b.sep, 0) as sep, COALESCE(b.oct, 0) as oct,
|
||||||
|
COALESCE(b.nov, 0) as nov, COALESCE(b.dec_amt, 0) as dec
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||||
|
WHERE a.is_active = true
|
||||||
|
AND a.account_type IN ('income', 'expense')
|
||||||
|
ORDER BY a.account_number`,
|
||||||
|
[fiscalYear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = 'account_number,account_name,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec';
|
||||||
|
const csvLines = rows.map((r: any) => {
|
||||||
|
const name = String(r.account_name).includes(',') ? `"${r.account_name}"` : r.account_name;
|
||||||
|
return [r.account_number, name, r.jan, r.feb, r.mar, r.apr, r.may, r.jun, r.jul, r.aug, r.sep, r.oct, r.nov, r.dec].join(',');
|
||||||
|
});
|
||||||
|
return [header, ...csvLines].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCurrency(val: string | number | undefined | null): number {
|
||||||
|
if (val === undefined || val === null) return 0;
|
||||||
|
if (typeof val === 'number') return val;
|
||||||
|
let s = String(val).trim();
|
||||||
|
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
|
||||||
|
const isNegative = s.includes('(') && s.includes(')');
|
||||||
|
s = s.replace(/[$,\s()]/g, '');
|
||||||
|
if (!s || s === '-') return 0;
|
||||||
|
const num = parseFloat(s);
|
||||||
|
if (isNaN(num)) return 0;
|
||||||
|
return isNegative ? -num : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferAccountType(accountNumber: string, accountName: string): string {
|
||||||
|
const prefix = parseInt(accountNumber.split('-')[0].trim(), 10);
|
||||||
|
if (isNaN(prefix)) return 'expense';
|
||||||
|
const nameUpper = (accountName || '').toUpperCase();
|
||||||
|
if (prefix >= 3000 && prefix < 4000) return 'income';
|
||||||
|
if (nameUpper.includes('INCOME') || nameUpper.includes('REVENUE') || nameUpper.includes('ASSESSMENT')) return 'income';
|
||||||
|
return 'expense';
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferFundType(accountNumber: string, accountName: string): string {
|
||||||
|
const prefix = parseInt(accountNumber.split('-')[0].trim(), 10);
|
||||||
|
const nameUpper = (accountName || '').toUpperCase();
|
||||||
|
if (nameUpper.includes('RESERVE')) return 'reserve';
|
||||||
|
if (prefix >= 7000 && prefix < 8000) return 'reserve';
|
||||||
|
return 'operating';
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlan(fiscalYear: number) {
|
||||||
|
const plans = await this.tenant.query(
|
||||||
|
'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear],
|
||||||
|
);
|
||||||
|
if (!plans.length) throw new NotFoundException('Budget plan not found');
|
||||||
|
|
||||||
|
if (plans[0].status !== 'planning') {
|
||||||
|
throw new BadRequestException('Can only delete plans in planning status');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.tenant.query('DELETE FROM budget_plans WHERE fiscal_year = $1', [fiscalYear]);
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { BudgetsService } from './budgets.service';
|
import { BudgetsService } from './budgets.service';
|
||||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Post(':year/import')
|
@Post(':year/import')
|
||||||
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
||||||
|
@RequireCapability('financials.budgets.edit')
|
||||||
importBudget(
|
importBudget(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Body() lines: any[],
|
@Body() lines: any[],
|
||||||
@@ -23,6 +25,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year/template')
|
@Get(':year/template')
|
||||||
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
async getTemplate(
|
async getTemplate(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@@ -37,6 +40,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year/vs-actual')
|
@Get(':year/vs-actual')
|
||||||
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
budgetVsActual(
|
budgetVsActual(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Query('month') month?: string,
|
@Query('month') month?: string,
|
||||||
@@ -46,12 +50,14 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year')
|
@Get(':year')
|
||||||
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
findByYear(@Param('year', ParseIntPipe) year: number) {
|
findByYear(@Param('year', ParseIntPipe) year: number) {
|
||||||
return this.budgetsService.findByYear(year);
|
return this.budgetsService.findByYear(year);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':year')
|
@Put(':year')
|
||||||
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.edit')
|
||||||
upsert(
|
upsert(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Body() budgets: UpsertBudgetDto[],
|
@Body() budgets: UpsertBudgetDto[],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { CapitalProjectsService } from './capital-projects.service';
|
import { CapitalProjectsService } from './capital-projects.service';
|
||||||
|
|
||||||
@ApiTags('capital-projects')
|
@ApiTags('capital-projects')
|
||||||
@@ -11,14 +12,18 @@ export class CapitalProjectsController {
|
|||||||
constructor(private service: CapitalProjectsService) {}
|
constructor(private service: CapitalProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
9
backend/src/modules/email/email.module.ts
Normal file
9
backend/src/modules/email/email.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { EmailService } from './email.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [EmailService],
|
||||||
|
exports: [EmailService],
|
||||||
|
})
|
||||||
|
export class EmailModule {}
|
||||||
348
backend/src/modules/email/email.service.ts
Normal file
348
backend/src/modules/email/email.service.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailService {
|
||||||
|
private readonly logger = new Logger(EmailService.name);
|
||||||
|
private resend: Resend | null = null;
|
||||||
|
private fromAddress: string;
|
||||||
|
private replyToAddress: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
const apiKey = this.configService.get<string>('RESEND_API_KEY');
|
||||||
|
if (apiKey && !apiKey.includes('placeholder')) {
|
||||||
|
this.resend = new Resend(apiKey);
|
||||||
|
this.logger.log('Resend email service initialized');
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
|
||||||
|
}
|
||||||
|
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
|
||||||
|
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
||||||
|
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
|
||||||
|
const html = this.buildTemplate({
|
||||||
|
preheader: 'Your HOA LedgerIQ account is ready to activate.',
|
||||||
|
heading: 'Welcome to HOA LedgerIQ!',
|
||||||
|
body: `
|
||||||
|
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
|
||||||
|
<p>Click the button below to set your password and activate your account:</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Activate My Account',
|
||||||
|
ctaUrl: activationUrl,
|
||||||
|
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
|
||||||
|
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
|
||||||
|
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
|
||||||
|
const html = this.buildTemplate({
|
||||||
|
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
|
||||||
|
heading: `You're all set!`,
|
||||||
|
body: `
|
||||||
|
<p>Your account for <strong>${this.esc(businessName)}</strong> is now active.</p>
|
||||||
|
<p>Log in to start managing your HOA's finances, assessments, and investments — all in one place.</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Go to Dashboard',
|
||||||
|
ctaUrl: `${appUrl}/dashboard`,
|
||||||
|
footer: 'If you have any questions, just reply to this email and we\'ll help you get started.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'welcome', { businessName });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
|
||||||
|
const subject = `Action required: Payment failed for ${businessName}`;
|
||||||
|
const html = this.buildTemplate({
|
||||||
|
preheader: 'We were unable to process your payment.',
|
||||||
|
heading: 'Payment Failed',
|
||||||
|
body: `
|
||||||
|
<p>We were unable to process the latest payment for <strong>${this.esc(businessName)}</strong>.</p>
|
||||||
|
<p>Please update your payment method to avoid any interruption to your service.</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Update Payment Method',
|
||||||
|
ctaUrl: `${this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`,
|
||||||
|
footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'payment_failed', { businessName });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
|
||||||
|
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
|
||||||
|
const html = this.buildTemplate({
|
||||||
|
preheader: `Join ${orgName} on HOA LedgerIQ.`,
|
||||||
|
heading: 'You\'re Invited!',
|
||||||
|
body: `
|
||||||
|
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
|
||||||
|
<p>Click below to accept the invitation and set up your account:</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Accept Invitation',
|
||||||
|
ctaUrl: inviteUrl,
|
||||||
|
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
preheader: 'Password reset requested for your HOA LedgerIQ account.',
|
||||||
|
heading: 'Password Reset',
|
||||||
|
body: `
|
||||||
|
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Reset Password',
|
||||||
|
ctaUrl: resetUrl,
|
||||||
|
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'password_reset', { resetUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core send logic ────────────────────────────────────────
|
||||||
|
|
||||||
|
private async send(
|
||||||
|
toEmail: string,
|
||||||
|
subject: string,
|
||||||
|
html: string,
|
||||||
|
template: string,
|
||||||
|
metadata: Record<string, any>,
|
||||||
|
): Promise<void> {
|
||||||
|
// Always log to the database
|
||||||
|
await this.log(toEmail, subject, html, template, metadata);
|
||||||
|
|
||||||
|
if (!this.resend) {
|
||||||
|
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
|
||||||
|
this.logger.log(` Subject: ${subject}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromAddress,
|
||||||
|
to: [toEmail],
|
||||||
|
replyTo: this.replyToAddress || undefined,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'failed', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Database logging ───────────────────────────────────────
|
||||||
|
|
||||||
|
private async log(
|
||||||
|
toEmail: string,
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
template: string,
|
||||||
|
metadata: Record<string, any>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[toEmail, subject, body, template, JSON.stringify(metadata)],
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to log email: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.email_log
|
||||||
|
SET metadata = metadata || $1::jsonb
|
||||||
|
WHERE to_email = $2 AND template = $3
|
||||||
|
AND created_at = (
|
||||||
|
SELECT MAX(created_at) FROM shared.email_log
|
||||||
|
WHERE to_email = $2 AND template = $3
|
||||||
|
)`,
|
||||||
|
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best effort — don't block the flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTML email template ────────────────────────────────────
|
||||||
|
|
||||||
|
private esc(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTemplate(opts: {
|
||||||
|
preheader: string;
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
ctaText: string;
|
||||||
|
ctaUrl: string;
|
||||||
|
footer: string;
|
||||||
|
}): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${this.esc(opts.heading)}</title>
|
||||||
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<!-- Preheader (hidden preview text) -->
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;">${this.esc(opts.preheader)}</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;padding:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Logo bar -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:24px 0 16px;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#1a73e8;letter-spacing:-0.5px;">
|
||||||
|
HOA LedgerIQ
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||||
|
style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 32px;">
|
||||||
|
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#1a1a2e;">
|
||||||
|
${this.esc(opts.heading)}
|
||||||
|
</h1>
|
||||||
|
<div style="font-size:15px;line-height:1.6;color:#4a4a68;">
|
||||||
|
${opts.body}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color:#1a73e8;border-radius:6px;">
|
||||||
|
<a href="${opts.ctaUrl}"
|
||||||
|
target="_blank"
|
||||||
|
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
||||||
|
${this.esc(opts.ctaText)}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Fallback URL -->
|
||||||
|
<p style="font-size:12px;color:#999;word-break:break-all;margin-top:16px;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||||
|
<a href="${opts.ctaUrl}" style="color:#1a73e8;">${opts.ctaUrl}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 32px;text-align:center;">
|
||||||
|
<p style="font-size:12px;color:#999;line-height:1.5;margin:0;">
|
||||||
|
${this.esc(opts.footer)}
|
||||||
|
</p>
|
||||||
|
<p style="font-size:12px;color:#bbb;margin:12px 0 0;">
|
||||||
|
© ${new Date().getFullYear()} HOA LedgerIQ — Smart Financial Management for HOAs
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
import { Controller, Get, Post, UseGuards, Req, Logger } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
@@ -9,24 +9,68 @@ import { HealthScoresService } from './health-scores.service';
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class HealthScoresController {
|
export class HealthScoresController {
|
||||||
|
private readonly logger = new Logger(HealthScoresController.name);
|
||||||
|
|
||||||
constructor(private service: HealthScoresService) {}
|
constructor(private service: HealthScoresService) {}
|
||||||
|
|
||||||
@Get('latest')
|
@Get('latest')
|
||||||
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||||
getLatest(@Req() req: any) {
|
getLatest(@Req() req: any) {
|
||||||
const schema = req.user?.orgSchema;
|
const schema = req.tenantSchema;
|
||||||
return this.service.getLatestScores(schema);
|
return this.service.getLatestScores(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('calculate')
|
@Post('calculate')
|
||||||
@ApiOperation({ summary: 'Trigger health score recalculation for current tenant' })
|
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculate(@Req() req: any) {
|
async calculate(@Req() req: any) {
|
||||||
const schema = req.user?.orgSchema;
|
const schema = req.tenantSchema;
|
||||||
const [operating, reserve] = await Promise.all([
|
|
||||||
|
// Fire-and-forget — background processing saves results to DB
|
||||||
|
Promise.all([
|
||||||
this.service.calculateScore(schema, 'operating'),
|
this.service.calculateScore(schema, 'operating'),
|
||||||
this.service.calculateScore(schema, 'reserve'),
|
this.service.calculateScore(schema, 'reserve'),
|
||||||
]);
|
]).catch((err) => {
|
||||||
return { operating, reserve };
|
this.logger.error(`Background health score calculation failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'Health score calculations started. Results will appear when ready.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate/operating')
|
||||||
|
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculateOperating(@Req() req: any) {
|
||||||
|
const schema = req.tenantSchema;
|
||||||
|
|
||||||
|
// Fire-and-forget
|
||||||
|
this.service.calculateScore(schema, 'operating').catch((err) => {
|
||||||
|
this.logger.error(`Background operating score failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'Operating fund health score calculation started.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate/reserve')
|
||||||
|
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculateReserve(@Req() req: any) {
|
||||||
|
const schema = req.tenantSchema;
|
||||||
|
|
||||||
|
// Fire-and-forget
|
||||||
|
this.service.calculateScore(schema, 'reserve').catch((err) => {
|
||||||
|
this.logger.error(`Background reserve score failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'Reserve fund health score calculation started.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ import { HealthScoresScheduler } from './health-scores.scheduler';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthScoresController],
|
controllers: [HealthScoresController],
|
||||||
providers: [HealthScoresService, HealthScoresScheduler],
|
providers: [HealthScoresService, HealthScoresScheduler],
|
||||||
|
exports: [HealthScoresService],
|
||||||
})
|
})
|
||||||
export class HealthScoresModule {}
|
export class HealthScoresModule {}
|
||||||
|
|||||||
@@ -47,23 +47,49 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
// ── Public API ──
|
// ── Public API ──
|
||||||
|
|
||||||
async getLatestScores(schema: string): Promise<{ operating: HealthScore | null; reserve: HealthScore | null }> {
|
async getLatestScores(schema: string): Promise<{
|
||||||
|
operating: HealthScore | null;
|
||||||
|
reserve: HealthScore | null;
|
||||||
|
operating_last_failed: boolean;
|
||||||
|
reserve_last_failed: boolean;
|
||||||
|
}> {
|
||||||
const qr = this.dataSource.createQueryRunner();
|
const qr = this.dataSource.createQueryRunner();
|
||||||
try {
|
try {
|
||||||
await qr.connect();
|
await qr.connect();
|
||||||
await qr.query(`SET search_path TO "${schema}"`);
|
await qr.query(`SET search_path TO "${schema}"`);
|
||||||
|
|
||||||
const operating = await qr.query(
|
// For each score type, return the latest *successful* score for display,
|
||||||
`SELECT * FROM health_scores WHERE score_type = 'operating' ORDER BY calculated_at DESC LIMIT 1`,
|
// and flag whether the most recent attempt (any status) was an error.
|
||||||
);
|
const result = { operating: null as HealthScore | null, reserve: null as HealthScore | null, operating_last_failed: false, reserve_last_failed: false };
|
||||||
const reserve = await qr.query(
|
|
||||||
`SELECT * FROM health_scores WHERE score_type = 'reserve' ORDER BY calculated_at DESC LIMIT 1`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
for (const scoreType of ['operating', 'reserve'] as const) {
|
||||||
operating: operating[0] || null,
|
// Most recent row (any status)
|
||||||
reserve: reserve[0] || null,
|
const latest = await qr.query(
|
||||||
};
|
`SELECT * FROM health_scores WHERE score_type = $1 ORDER BY calculated_at DESC LIMIT 1`,
|
||||||
|
[scoreType],
|
||||||
|
);
|
||||||
|
const latestRow = latest[0] || null;
|
||||||
|
|
||||||
|
if (!latestRow) {
|
||||||
|
// No scores at all
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestRow.status === 'error') {
|
||||||
|
// Most recent attempt failed — return the latest *complete* score instead
|
||||||
|
const lastGood = await qr.query(
|
||||||
|
`SELECT * FROM health_scores WHERE score_type = $1 AND status = 'complete' ORDER BY calculated_at DESC LIMIT 1`,
|
||||||
|
[scoreType],
|
||||||
|
);
|
||||||
|
result[scoreType] = lastGood[0] || latestRow; // fall back to error row if no good score exists
|
||||||
|
result[`${scoreType}_last_failed`] = true;
|
||||||
|
} else {
|
||||||
|
result[scoreType] = latestRow;
|
||||||
|
result[`${scoreType}_last_failed`] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
await qr.release();
|
await qr.release();
|
||||||
}
|
}
|
||||||
@@ -154,7 +180,7 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
// ── Data Readiness Checks ──
|
// ── Data Readiness Checks ──
|
||||||
|
|
||||||
private async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
|
async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
if (scoreType === 'operating') {
|
if (scoreType === 'operating') {
|
||||||
@@ -194,12 +220,12 @@ export class HealthScoresService {
|
|||||||
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
|
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should have capital projects (warn but don't block)
|
// Should have reserve-funded projects with estimated costs (warn but don't block)
|
||||||
const projects = await qr.query(
|
const projects = await qr.query(
|
||||||
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true`,
|
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true AND fund_source = 'reserve'`,
|
||||||
);
|
);
|
||||||
if (parseInt(projects[0].cnt) === 0) {
|
if (parseInt(projects[0].cnt) === 0) {
|
||||||
missing.push('No capital projects found. Add planned capital projects for a more accurate reserve health assessment.');
|
missing.push('No reserve-funded projects found. Add projects with estimated costs for an accurate funded-ratio calculation.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +249,10 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
// ── Data Gathering ──
|
// ── Data Gathering ──
|
||||||
|
|
||||||
private async gatherOperatingData(qr: any) {
|
async gatherOperatingData(qr: any) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const [accounts, budgets, assessments, cashFlow, recentTransactions] = await Promise.all([
|
const [accounts, budgets, assessments, cashFlow, recentTransactions, actualsMonths] = await Promise.all([
|
||||||
// Operating accounts with balances
|
// Operating accounts with balances
|
||||||
qr.query(`
|
qr.query(`
|
||||||
SELECT a.name, a.account_number, a.account_type, a.fund_type,
|
SELECT a.name, a.account_number, a.account_type, a.fund_type,
|
||||||
@@ -285,21 +311,54 @@ export class HealthScoresService {
|
|||||||
FROM invoices
|
FROM invoices
|
||||||
WHERE status IN ('sent', 'overdue') AND due_date < CURRENT_DATE
|
WHERE status IN ('sent', 'overdue') AND due_date < CURRENT_DATE
|
||||||
`),
|
`),
|
||||||
|
// Detect which months have posted actuals (expense or income JEs)
|
||||||
|
qr.query(`
|
||||||
|
SELECT DISTINCT EXTRACT(MONTH FROM je.entry_date)::int as month_num
|
||||||
|
FROM journal_entries je
|
||||||
|
JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||||
|
JOIN accounts a ON a.id = jel.account_id
|
||||||
|
WHERE je.entry_date >= $1
|
||||||
|
AND je.entry_date < $2
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND a.fund_type = 'operating'
|
||||||
|
AND a.account_type IN ('income', 'expense')
|
||||||
|
ORDER BY month_num
|
||||||
|
`, [`${year}-01-01`, `${year + 1}-01-01`]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate month-by-month budget actuals progress
|
// Calculate month-by-month budget actuals progress
|
||||||
const currentMonth = new Date().getMonth(); // 0-indexed
|
const currentMonth = new Date().getMonth(); // 0-indexed
|
||||||
|
const dayOfMonth = new Date().getDate();
|
||||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||||
|
const monthLabelsForBudget = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
// Determine which months have posted actuals
|
||||||
|
const monthsWithActuals: number[] = actualsMonths.map((r: any) => parseInt(r.month_num)); // 1-indexed
|
||||||
|
const lastActualsMonth0 = monthsWithActuals.length > 0
|
||||||
|
? Math.max(...monthsWithActuals) - 1 // convert to 0-indexed
|
||||||
|
: -1; // no actuals posted at all
|
||||||
|
|
||||||
|
// YTD budget = sum through last month with actuals only (NOT current incomplete month)
|
||||||
let budgetedIncomeYTD = 0;
|
let budgetedIncomeYTD = 0;
|
||||||
let budgetedExpenseYTD = 0;
|
let budgetedExpenseYTD = 0;
|
||||||
for (const b of budgets) {
|
for (const b of budgets) {
|
||||||
for (let m = 0; m <= currentMonth; m++) {
|
for (let m = 0; m <= lastActualsMonth0; m++) {
|
||||||
const amt = parseFloat(b[monthNames[m]]) || 0;
|
const amt = parseFloat(b[monthNames[m]]) || 0;
|
||||||
if (b.account_type === 'income') budgetedIncomeYTD += amt;
|
if (b.account_type === 'income') budgetedIncomeYTD += amt;
|
||||||
else if (b.account_type === 'expense') budgetedExpenseYTD += amt;
|
else if (b.account_type === 'expense') budgetedExpenseYTD += amt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Current month budget (shown separately, not included in YTD comparison)
|
||||||
|
let currentMonthBudgetIncome = 0;
|
||||||
|
let currentMonthBudgetExpense = 0;
|
||||||
|
for (const b of budgets) {
|
||||||
|
const amt = parseFloat(b[monthNames[currentMonth]]) || 0;
|
||||||
|
if (b.account_type === 'income') currentMonthBudgetIncome += amt;
|
||||||
|
else if (b.account_type === 'expense') currentMonthBudgetExpense += amt;
|
||||||
|
}
|
||||||
|
const currentMonthHasActuals = monthsWithActuals.includes(currentMonth + 1);
|
||||||
|
|
||||||
const operatingCash = accounts
|
const operatingCash = accounts
|
||||||
.filter((a: any) => a.account_type === 'asset')
|
.filter((a: any) => a.account_type === 'asset')
|
||||||
.reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
.reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
||||||
@@ -433,11 +492,27 @@ export class HealthScoresService {
|
|||||||
ytdIncome,
|
ytdIncome,
|
||||||
ytdExpense,
|
ytdExpense,
|
||||||
monthlyAssessmentIncome,
|
monthlyAssessmentIncome,
|
||||||
|
totalAnnualAssessmentIncome: assessments.reduce((sum: number, ag: any) => {
|
||||||
|
const regular = parseFloat(ag.regular_assessment) || 0;
|
||||||
|
const units = parseInt(ag.unit_count) || 0;
|
||||||
|
const total = regular * units;
|
||||||
|
const freq = ag.frequency || 'monthly';
|
||||||
|
if (freq === 'monthly') return sum + total * 12;
|
||||||
|
if (freq === 'quarterly') return sum + total * 4;
|
||||||
|
return sum + total; // annual
|
||||||
|
}, 0),
|
||||||
delinquentCount: parseInt(recentTransactions[0]?.count || '0'),
|
delinquentCount: parseInt(recentTransactions[0]?.count || '0'),
|
||||||
delinquentAmount: parseFloat(recentTransactions[0]?.total_overdue || '0'),
|
delinquentAmount: parseFloat(recentTransactions[0]?.total_overdue || '0'),
|
||||||
monthsOfExpenses: budgetedExpenseAnnual > 0 ? (operatingCash / (budgetedExpenseAnnual / 12)) : 0,
|
monthsOfExpenses: budgetedExpenseAnnual > 0 ? (operatingCash / (budgetedExpenseAnnual / 12)) : 0,
|
||||||
year,
|
year,
|
||||||
currentMonth: currentMonth + 1,
|
currentMonth: currentMonth + 1,
|
||||||
|
dayOfMonth,
|
||||||
|
monthsWithActuals,
|
||||||
|
lastActualsMonthLabel: lastActualsMonth0 >= 0 ? monthLabelsForBudget[lastActualsMonth0] : null,
|
||||||
|
currentMonthLabel: monthLabelsForBudget[currentMonth],
|
||||||
|
currentMonthBudgetIncome,
|
||||||
|
currentMonthBudgetExpense,
|
||||||
|
currentMonthHasActuals,
|
||||||
forecast,
|
forecast,
|
||||||
lowestCash: Math.round(lowestCash * 100) / 100,
|
lowestCash: Math.round(lowestCash * 100) / 100,
|
||||||
lowestCashMonth,
|
lowestCashMonth,
|
||||||
@@ -445,7 +520,7 @@ export class HealthScoresService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async gatherReserveData(qr: any) {
|
async gatherReserveData(qr: any) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const currentMonth = new Date().getMonth(); // 0-indexed
|
const currentMonth = new Date().getMonth(); // 0-indexed
|
||||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||||
@@ -483,10 +558,12 @@ export class HealthScoresService {
|
|||||||
FROM reserve_components
|
FROM reserve_components
|
||||||
ORDER BY remaining_life_years ASC NULLS LAST
|
ORDER BY remaining_life_years ASC NULLS LAST
|
||||||
`),
|
`),
|
||||||
// Capital projects
|
// Capital projects (include component-level fields for funded ratio when reserve_components is empty)
|
||||||
qr.query(`
|
qr.query(`
|
||||||
SELECT name, estimated_cost, target_year, target_month, fund_source,
|
SELECT name, estimated_cost, actual_cost, target_year, target_month, fund_source,
|
||||||
status, priority, current_fund_balance, funded_percentage
|
status, priority, current_fund_balance, funded_percentage,
|
||||||
|
category, useful_life_years, remaining_life_years, condition_rating,
|
||||||
|
annual_contribution
|
||||||
FROM projects
|
FROM projects
|
||||||
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
||||||
ORDER BY target_year, target_month NULLS LAST
|
ORDER BY target_year, target_month NULLS LAST
|
||||||
@@ -521,11 +598,19 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
const totalReserveFund = reserveCash + totalInvestments;
|
const totalReserveFund = reserveCash + totalInvestments;
|
||||||
|
|
||||||
const totalReplacementCost = reserveComponents
|
// Use reserve_components for funded ratio when available; fall back to
|
||||||
.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0);
|
// reserve-funded projects (which carry the same estimated_cost / lifecycle
|
||||||
|
// fields that users actually populate on the Projects page).
|
||||||
|
const reserveProjects = projects.filter((p: any) => p.fund_source === 'reserve');
|
||||||
|
const useComponentsTable = reserveComponents.length > 0;
|
||||||
|
|
||||||
const totalComponentFunded = reserveComponents
|
const totalReplacementCost = useComponentsTable
|
||||||
.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0);
|
? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0)
|
||||||
|
: reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||||
|
|
||||||
|
const totalComponentFunded = useComponentsTable
|
||||||
|
? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0)
|
||||||
|
: reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.current_fund_balance || '0'), 0);
|
||||||
|
|
||||||
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
|
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
|
||||||
|
|
||||||
@@ -540,10 +625,16 @@ export class HealthScoresService {
|
|||||||
.filter((b: any) => b.account_type === 'expense')
|
.filter((b: any) => b.account_type === 'expense')
|
||||||
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
||||||
|
|
||||||
// Components needing replacement within 5 years
|
// Projects due within 5 years — based on planned date (target_year/target_month),
|
||||||
const urgentComponents = reserveComponents.filter(
|
// NOT remaining_life_years. The planned date is the board's decision on when to act;
|
||||||
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
// 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 ──
|
// ── Build 12-month forward reserve cash flow projection ──
|
||||||
|
|
||||||
@@ -674,6 +765,7 @@ export class HealthScoresService {
|
|||||||
accounts,
|
accounts,
|
||||||
investments,
|
investments,
|
||||||
reserveComponents,
|
reserveComponents,
|
||||||
|
reserveProjects,
|
||||||
projects,
|
projects,
|
||||||
budgets,
|
budgets,
|
||||||
assessments,
|
assessments,
|
||||||
@@ -683,7 +775,7 @@ export class HealthScoresService {
|
|||||||
totalProjectCost,
|
totalProjectCost,
|
||||||
annualReserveContribution,
|
annualReserveContribution,
|
||||||
annualReserveExpenses,
|
annualReserveExpenses,
|
||||||
urgentComponents,
|
urgentProjects,
|
||||||
monthlySpecialAssessmentIncome,
|
monthlySpecialAssessmentIncome,
|
||||||
year,
|
year,
|
||||||
forecast,
|
forecast,
|
||||||
@@ -695,7 +787,7 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
// ── AI Prompt Construction ──
|
// ── 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 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.
|
const systemPrompt = `You are an HOA financial health analyst. You evaluate the operating fund health of homeowners associations on a scale of 0-100.
|
||||||
@@ -715,6 +807,14 @@ KEY FACTORS TO EVALUATE:
|
|||||||
4. Income-to-expense ratio
|
4. Income-to-expense ratio
|
||||||
5. Emergency buffer adequacy
|
5. Emergency buffer adequacy
|
||||||
6. CRITICAL — Projected cash flow: Use the 12-MONTH CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from assessments and budgeted sources), expenses (from budget), and project costs. Check whether cash will go negative or dangerously low in any future month. If projected income arrives before projected expenses, the position may be adequate even if current cash seems low. Conversely, if a large expense precedes income in a given month, flag the timing risk.
|
6. CRITICAL — Projected cash flow: Use the 12-MONTH CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from assessments and budgeted sources), expenses (from budget), and project costs. Check whether cash will go negative or dangerously low in any future month. If projected income arrives before projected expenses, the position may be adequate even if current cash seems low. Conversely, if a large expense precedes income in a given month, flag the timing risk.
|
||||||
|
7. BUDGET TIMING: YTD budget comparisons only include months where actual accounting entries have been posted. Do NOT penalize the HOA for a budget variance in the current month if actuals have not yet been submitted — this is normal operational procedure. Actuals are posted at month-end. The current month's budget is shown separately for context only, not for variance analysis.
|
||||||
|
|
||||||
|
CASH RUNWAY CLASSIFICATION (strict — use these rules for the Cash Reserves factor):
|
||||||
|
- <2 months of expenses: impact = "negative"
|
||||||
|
- 2-3 months of expenses: impact = "neutral"
|
||||||
|
- 3-6 months of expenses: impact = "positive"
|
||||||
|
- 6+ months of expenses: impact = "strongly positive" (contributes to Excellent score)
|
||||||
|
Do NOT rate cash runway as positive based on projected future inflows — evaluate the CURRENT cash-on-hand position for this factor. Future inflows should be evaluated separately under the Projected Cash Flow factor.
|
||||||
|
|
||||||
RESPONSE FORMAT:
|
RESPONSE FORMAT:
|
||||||
Respond with ONLY valid JSON (no markdown, no code fences):
|
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||||
@@ -742,14 +842,30 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
.join('\n') || 'No budget line items.';
|
.join('\n') || 'No budget line items.';
|
||||||
|
|
||||||
const assessmentLines = data.assessments
|
const assessmentLines = data.assessments
|
||||||
.map((a: any) => `- ${a.name}: $${parseFloat(a.regular_assessment || '0').toFixed(2)}/unit × ${a.unit_count} units (${a.frequency})`)
|
.map((a: any) => {
|
||||||
|
const regular = parseFloat(a.regular_assessment || '0');
|
||||||
|
const units = parseInt(a.unit_count || '0');
|
||||||
|
const total = regular * units;
|
||||||
|
return `- ${a.name}: $${regular.toFixed(2)}/unit × ${units} units (${a.frequency}) = $${total.toFixed(2)} total/period`;
|
||||||
|
})
|
||||||
.join('\n') || 'No assessment groups.';
|
.join('\n') || 'No assessment groups.';
|
||||||
|
|
||||||
|
const totalAnnualAssessmentIncome = data.assessments.reduce((sum: number, a: any) => {
|
||||||
|
const regular = parseFloat(a.regular_assessment || '0');
|
||||||
|
const units = parseInt(a.unit_count || '0');
|
||||||
|
const total = regular * units;
|
||||||
|
const freq = a.frequency || 'monthly';
|
||||||
|
if (freq === 'monthly') return sum + total * 12;
|
||||||
|
if (freq === 'quarterly') return sum + total * 4;
|
||||||
|
return sum + total; // annual
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const userPrompt = `Evaluate this HOA's operating fund health.
|
const userPrompt = `Evaluate this HOA's operating fund health.
|
||||||
|
|
||||||
TODAY: ${today}
|
TODAY: ${today}
|
||||||
FISCAL YEAR: ${data.year}
|
FISCAL YEAR: ${data.year}
|
||||||
CURRENT MONTH: ${data.currentMonth} of 12
|
CURRENT MONTH: ${data.currentMonthLabel} (day ${data.dayOfMonth}), month ${data.currentMonth} of 12
|
||||||
|
Months with posted actuals: ${data.monthsWithActuals.length > 0 ? data.monthsWithActuals.map((m: number) => ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][m - 1]).join(', ') : 'None yet'}
|
||||||
|
|
||||||
=== OPERATING FUND ACCOUNTS ===
|
=== OPERATING FUND ACCOUNTS ===
|
||||||
${accountLines}
|
${accountLines}
|
||||||
@@ -763,20 +879,28 @@ Budgeted Annual Income: $${data.budgetedIncomeAnnual.toFixed(2)}
|
|||||||
Budgeted Annual Expenses: $${data.budgetedExpenseAnnual.toFixed(2)}
|
Budgeted Annual Expenses: $${data.budgetedExpenseAnnual.toFixed(2)}
|
||||||
Monthly Expense Run Rate: $${(data.budgetedExpenseAnnual / 12).toFixed(2)}
|
Monthly Expense Run Rate: $${(data.budgetedExpenseAnnual / 12).toFixed(2)}
|
||||||
|
|
||||||
=== BUDGET VS ACTUAL (YTD through month ${data.currentMonth}) ===
|
=== BUDGET VS ACTUAL (YTD through ${data.lastActualsMonthLabel || 'N/A — no actuals posted yet'}) ===
|
||||||
|
Note: This comparison only covers months with posted accounting entries. ${data.lastActualsMonthLabel ? `Actuals have been posted through ${data.lastActualsMonthLabel}.` : 'No monthly actuals have been posted yet for this fiscal year.'} Budget figures are used for forecasting until actuals are submitted at month-end.
|
||||||
|
|
||||||
Budgeted Income YTD: $${data.budgetedIncomeYTD.toFixed(2)}
|
Budgeted Income YTD: $${data.budgetedIncomeYTD.toFixed(2)}
|
||||||
Actual Income YTD: $${data.ytdIncome.toFixed(2)}
|
Actual Income YTD: $${data.ytdIncome.toFixed(2)}
|
||||||
Income Variance: $${(data.ytdIncome - data.budgetedIncomeYTD).toFixed(2)} (${data.budgetedIncomeYTD > 0 ? ((data.ytdIncome / data.budgetedIncomeYTD) * 100).toFixed(1) : 0}% of budget)
|
Income Variance: $${(data.ytdIncome - data.budgetedIncomeYTD).toFixed(2)}${data.budgetedIncomeYTD > 0 ? ` (${((data.ytdIncome / data.budgetedIncomeYTD) * 100).toFixed(1)}% of budget)` : ''}
|
||||||
|
|
||||||
Budgeted Expenses YTD: $${data.budgetedExpenseYTD.toFixed(2)}
|
Budgeted Expenses YTD: $${data.budgetedExpenseYTD.toFixed(2)}
|
||||||
Actual Expenses YTD: $${data.ytdExpense.toFixed(2)}
|
Actual Expenses YTD: $${data.ytdExpense.toFixed(2)}
|
||||||
Expense Variance: $${(data.ytdExpense - data.budgetedExpenseYTD).toFixed(2)} (${data.budgetedExpenseYTD > 0 ? ((data.ytdExpense / data.budgetedExpenseYTD) * 100).toFixed(1) : 0}% of budget)
|
Expense Variance: $${(data.ytdExpense - data.budgetedExpenseYTD).toFixed(2)}${data.budgetedExpenseYTD > 0 ? ` (${((data.ytdExpense / data.budgetedExpenseYTD) * 100).toFixed(1)}% of budget)` : ''}
|
||||||
|
|
||||||
|
=== CURRENT MONTH (${data.currentMonthLabel}, ${data.dayOfMonth} days elapsed) ===
|
||||||
|
Budgeted Income this month: $${data.currentMonthBudgetIncome.toFixed(2)}
|
||||||
|
Budgeted Expenses this month: $${data.currentMonthBudgetExpense.toFixed(2)}
|
||||||
|
Actuals posted this month: ${data.currentMonthHasActuals ? 'Yes' : 'No — actuals are typically posted at month-end'}
|
||||||
|
|
||||||
=== CASH RUNWAY ===
|
=== CASH RUNWAY ===
|
||||||
Months of Operating Expenses Covered: ${data.monthsOfExpenses.toFixed(1)} months
|
Months of Operating Expenses Covered: ${data.monthsOfExpenses.toFixed(1)} months
|
||||||
|
|
||||||
=== ASSESSMENT INCOME ===
|
=== ASSESSMENT INCOME ===
|
||||||
${assessmentLines}
|
${assessmentLines}
|
||||||
|
Total Annual Assessment Income: $${data.totalAnnualAssessmentIncome.toFixed(2)}
|
||||||
Monthly Assessment Income: $${data.monthlyAssessmentIncome.toFixed(2)}
|
Monthly Assessment Income: $${data.monthlyAssessmentIncome.toFixed(2)}
|
||||||
|
|
||||||
=== DELINQUENCY ===
|
=== DELINQUENCY ===
|
||||||
@@ -803,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 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.
|
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.
|
||||||
@@ -818,12 +942,13 @@ SCORING GUIDELINES:
|
|||||||
|
|
||||||
KEY FACTORS TO EVALUATE:
|
KEY FACTORS TO EVALUATE:
|
||||||
1. Percent funded (total reserve assets vs total replacement costs)
|
1. Percent funded (total reserve assets vs total replacement costs)
|
||||||
2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?)
|
2. Annual contribution adequacy (is annual contribution enough to keep pace with planned projects?)
|
||||||
3. Component urgency (components due within 5 years and their funding status)
|
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?)
|
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.?)
|
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.
|
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:
|
RESPONSE FORMAT:
|
||||||
Respond with ONLY valid JSON (no markdown, no code fences):
|
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||||
@@ -852,13 +977,17 @@ 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'}`,
|
`- ${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');
|
).join('\n');
|
||||||
|
|
||||||
const componentLines = data.reserveComponents.length === 0
|
// Build component lines from reserve_components if available, otherwise from reserve-funded projects.
|
||||||
? 'No reserve components tracked.'
|
// Use planned date (target_year/target_month) as the authoritative timeline, not remaining_life_years.
|
||||||
: data.reserveComponents.map((c: any) => {
|
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
|
||||||
const cost = parseFloat(c.replacement_cost || '0');
|
const componentLines = componentSource.length === 0
|
||||||
|
? 'No reserve components or reserve projects tracked.'
|
||||||
|
: componentSource.map((c: any) => {
|
||||||
|
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(c.current_fund_balance || '0');
|
||||||
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
||||||
return `- ${c.name} [${c.category}] | 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');
|
}).join('\n');
|
||||||
|
|
||||||
const projectLines = data.projects.length === 0
|
const projectLines = data.projects.length === 0
|
||||||
@@ -871,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`)
|
.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.';
|
.join('\n') || 'No reserve budget line items.';
|
||||||
|
|
||||||
const urgentLines = data.urgentComponents.length === 0
|
const urgentLines = data.urgentProjects.length === 0
|
||||||
? 'None — no components due within 5 years.'
|
? 'None — no reserve projects planned within 5 years.'
|
||||||
: data.urgentComponents.map((c: any) => {
|
: data.urgentProjects.map((p: any) => {
|
||||||
const cost = parseFloat(c.replacement_cost || '0');
|
const cost = parseFloat(p.estimated_cost || '0');
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(p.current_fund_balance || '0');
|
||||||
const gap = cost - funded;
|
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');
|
}).join('\n');
|
||||||
|
|
||||||
const userPrompt = `Evaluate this HOA's reserve fund health.
|
const userPrompt = `Evaluate this HOA's reserve fund health.
|
||||||
@@ -890,8 +1020,8 @@ Reserve Cash (bank accounts): $${data.reserveCash.toFixed(2)}
|
|||||||
Reserve Investments: $${data.totalInvestments.toFixed(2)}
|
Reserve Investments: $${data.totalInvestments.toFixed(2)}
|
||||||
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
|
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
|
||||||
|
|
||||||
Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)}
|
Total Replacement Cost (all components): ${data.totalReplacementCost > 0 ? '$' + data.totalReplacementCost.toFixed(2) : '$0.00 (no reserve components entered — funded ratio cannot be calculated)'}
|
||||||
Percent Funded: ${data.percentFunded.toFixed(1)}%
|
Percent Funded: ${data.totalReplacementCost > 0 ? data.percentFunded.toFixed(1) + '%' : 'N/A — no reserve components with replacement costs have been entered. Do NOT report a 0% funded ratio; instead note that funded ratio is unavailable due to missing component data.'}
|
||||||
|
|
||||||
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
|
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
|
||||||
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
|
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
|
||||||
@@ -903,10 +1033,10 @@ ${accountLines}
|
|||||||
=== RESERVE INVESTMENTS ===
|
=== RESERVE INVESTMENTS ===
|
||||||
${investmentLines}
|
${investmentLines}
|
||||||
|
|
||||||
=== RESERVE COMPONENTS (ordered by urgency) ===
|
=== RESERVE COMPONENTS (ordered by planned date) ===
|
||||||
${componentLines}
|
${componentLines}
|
||||||
|
|
||||||
=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) ===
|
=== PROJECTS PLANNED WITHIN 5 YEARS (by planned date) ===
|
||||||
${urgentLines}
|
${urgentLines}
|
||||||
|
|
||||||
=== CAPITAL PROJECTS ===
|
=== CAPITAL PROJECTS ===
|
||||||
@@ -918,11 +1048,26 @@ ${budgetLines}
|
|||||||
|
|
||||||
=== SPECIAL ASSESSMENT INCOME (Reserve Fund) ===
|
=== SPECIAL ASSESSMENT INCOME (Reserve Fund) ===
|
||||||
${data.assessments.length === 0 ? 'No special assessments configured.' :
|
${data.assessments.length === 0 ? 'No special assessments configured.' :
|
||||||
data.assessments.map((a: any) => {
|
(() => {
|
||||||
|
const lines = data.assessments.map((a: any) => {
|
||||||
const special = parseFloat(a.special_assessment || '0');
|
const special = parseFloat(a.special_assessment || '0');
|
||||||
if (special === 0) return null;
|
if (special === 0) return null;
|
||||||
return `- ${a.name}: $${special.toFixed(2)}/unit × ${a.unit_count} units (${a.frequency}) = $${(special * parseInt(a.unit_count || '0')).toFixed(2)}/period → Reserve Fund`;
|
const units = parseInt(a.unit_count || '0');
|
||||||
}).filter(Boolean).join('\n') || 'No special assessments currently being collected.'}
|
const totalPerPeriod = special * units;
|
||||||
|
return `- ${a.name}: $${special.toFixed(2)}/unit × ${units} units (${a.frequency}) = $${totalPerPeriod.toFixed(2)}/period → Reserve Fund`;
|
||||||
|
}).filter(Boolean);
|
||||||
|
if (lines.length === 0) return 'No special assessments currently being collected.';
|
||||||
|
const totalAnnual = data.assessments.reduce((sum: number, a: any) => {
|
||||||
|
const special = parseFloat(a.special_assessment || '0');
|
||||||
|
const units = parseInt(a.unit_count || '0');
|
||||||
|
const total = special * units;
|
||||||
|
const freq = a.frequency || 'monthly';
|
||||||
|
if (freq === 'monthly') return sum + total * 12;
|
||||||
|
if (freq === 'quarterly') return sum + total * 4;
|
||||||
|
return sum + total;
|
||||||
|
}, 0);
|
||||||
|
return lines.join('\n') + '\nTotal Annual Special Assessment Income to Reserves: $' + totalAnnual.toFixed(2);
|
||||||
|
})()}
|
||||||
|
|
||||||
=== 12-MONTH PROJECTED CASH FLOW (Reserve Fund) ===
|
=== 12-MONTH PROJECTED CASH FLOW (Reserve Fund) ===
|
||||||
Starting Reserve Cash: $${data.reserveCash.toFixed(2)}
|
Starting Reserve Cash: $${data.reserveCash.toFixed(2)}
|
||||||
@@ -967,7 +1112,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
|
|||||||
const requestBody = {
|
const requestBody = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
temperature: 0.3,
|
temperature: 0.1,
|
||||||
max_tokens: 2048,
|
max_tokens: 2048,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -993,7 +1138,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
timeout: 120000,
|
timeout: 600000, // 10 minute timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -1007,7 +1152,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
|
|||||||
req.on('error', (err) => reject(err));
|
req.on('error', (err) => reject(err));
|
||||||
req.on('timeout', () => {
|
req.on('timeout', () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error('Request timed out after 120s'));
|
reject(new Error('Request timed out after 600s'));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyString);
|
req.write(bodyString);
|
||||||
|
|||||||
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
27
backend/src/modules/ideas/ideas.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/ideas/ideas.module.ts
Normal file
14
backend/src/modules/ideas/ideas.module.ts
Normal 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 {}
|
||||||
89
backend/src/modules/ideas/ideas.service.ts
Normal file
89
backend/src/modules/ideas/ideas.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
|||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvestmentPlanningService } from './investment-planning.service';
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
|
|
||||||
@ApiTags('investment-planning')
|
@ApiTags('investment-planning')
|
||||||
@@ -13,32 +14,37 @@ export class InvestmentPlanningController {
|
|||||||
|
|
||||||
@Get('snapshot')
|
@Get('snapshot')
|
||||||
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getSnapshot() {
|
getSnapshot() {
|
||||||
return this.service.getFinancialSnapshot();
|
return this.service.getFinancialSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('cd-rates')
|
@Get('cd-rates')
|
||||||
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getCdRates() {
|
getCdRates() {
|
||||||
return this.service.getCdRates();
|
return this.service.getCdRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-rates')
|
@Get('market-rates')
|
||||||
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getMarketRates() {
|
getMarketRates() {
|
||||||
return this.service.getMarketRates();
|
return this.service.getMarketRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('saved-recommendation')
|
@Get('saved-recommendation')
|
||||||
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getSavedRecommendation() {
|
getSavedRecommendation() {
|
||||||
return this.service.getSavedRecommendation();
|
return this.service.getSavedRecommendation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
getRecommendations(@Req() req: any) {
|
@RequireCapability('planning.investments.edit')
|
||||||
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
triggerRecommendations(@Req() req: any) {
|
||||||
|
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ import { InvestmentPlanningService } from './investment-planning.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [InvestmentPlanningController],
|
controllers: [InvestmentPlanningController],
|
||||||
providers: [InvestmentPlanningService],
|
providers: [InvestmentPlanningService],
|
||||||
|
exports: [InvestmentPlanningService],
|
||||||
})
|
})
|
||||||
export class InvestmentPlanningModule {}
|
export class InvestmentPlanningModule {}
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ export interface MarketRate {
|
|||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecommendationComponent {
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
term_months: number;
|
||||||
|
rate: number;
|
||||||
|
bank_name?: string;
|
||||||
|
investment_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Recommendation {
|
export interface Recommendation {
|
||||||
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
||||||
priority: 'high' | 'medium' | 'low';
|
priority: 'high' | 'medium' | 'low';
|
||||||
@@ -50,6 +59,7 @@ export interface Recommendation {
|
|||||||
suggested_rate?: number;
|
suggested_rate?: number;
|
||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
|
components?: RecommendationComponent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIResponse {
|
export interface AIResponse {
|
||||||
@@ -65,6 +75,9 @@ export interface SavedRecommendation {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
response_time_ms: number;
|
response_time_ms: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
status: 'processing' | 'complete' | 'error';
|
||||||
|
last_failed: boolean;
|
||||||
|
error_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -196,14 +209,33 @@ export class InvestmentPlanningService {
|
|||||||
return rates.cd;
|
return rates.cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the status/error_message columns exist (for tenants created before this migration).
|
||||||
|
*/
|
||||||
|
private async ensureStatusColumn(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.tenant.query(
|
||||||
|
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'complete'`,
|
||||||
|
);
|
||||||
|
await this.tenant.query(
|
||||||
|
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS error_message TEXT`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore — column may already exist or table may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the latest saved AI recommendation for this tenant.
|
* Get the latest saved AI recommendation for this tenant.
|
||||||
|
* Returns status and last_failed flag for UI state management.
|
||||||
*/
|
*/
|
||||||
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
||||||
try {
|
try {
|
||||||
|
await this.ensureStatusColumn();
|
||||||
|
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
||||||
response_time_ms, created_at
|
response_time_ms, status, error_message, created_at
|
||||||
FROM ai_recommendations
|
FROM ai_recommendations
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
@@ -212,6 +244,64 @@ export class InvestmentPlanningService {
|
|||||||
if (!rows || rows.length === 0) return null;
|
if (!rows || rows.length === 0) return null;
|
||||||
|
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
|
const status = row.status || 'complete';
|
||||||
|
|
||||||
|
// If still processing, return processing status
|
||||||
|
if (status === 'processing') {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
recommendations: [],
|
||||||
|
overall_assessment: '',
|
||||||
|
risk_notes: [],
|
||||||
|
response_time_ms: 0,
|
||||||
|
created_at: row.created_at,
|
||||||
|
status: 'processing',
|
||||||
|
last_failed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If latest attempt failed, return the last successful result with last_failed flag
|
||||||
|
if (status === 'error') {
|
||||||
|
const lastGood = await this.tenant.query(
|
||||||
|
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
||||||
|
response_time_ms, created_at
|
||||||
|
FROM ai_recommendations
|
||||||
|
WHERE status = 'complete'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastGood?.length) {
|
||||||
|
const goodRow = lastGood[0];
|
||||||
|
const recData = goodRow.recommendations_json || {};
|
||||||
|
return {
|
||||||
|
id: goodRow.id,
|
||||||
|
recommendations: recData.recommendations || [],
|
||||||
|
overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '',
|
||||||
|
risk_notes: goodRow.risk_notes || recData.risk_notes || [],
|
||||||
|
response_time_ms: goodRow.response_time_ms || 0,
|
||||||
|
created_at: goodRow.created_at,
|
||||||
|
status: 'complete',
|
||||||
|
last_failed: true,
|
||||||
|
error_message: row.error_message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No previous good result — return error state
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
recommendations: [],
|
||||||
|
overall_assessment: row.error_message || 'AI analysis failed. Please try again.',
|
||||||
|
risk_notes: [],
|
||||||
|
response_time_ms: 0,
|
||||||
|
created_at: row.created_at,
|
||||||
|
status: 'error',
|
||||||
|
last_failed: true,
|
||||||
|
error_message: row.error_message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete — return the data normally
|
||||||
const recData = row.recommendations_json || {};
|
const recData = row.recommendations_json || {};
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -220,6 +310,8 @@ export class InvestmentPlanningService {
|
|||||||
risk_notes: row.risk_notes || recData.risk_notes || [],
|
risk_notes: row.risk_notes || recData.risk_notes || [],
|
||||||
response_time_ms: row.response_time_ms || 0,
|
response_time_ms: row.response_time_ms || 0,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
|
status: 'complete',
|
||||||
|
last_failed: false,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Table might not exist yet (pre-migration tenants)
|
// Table might not exist yet (pre-migration tenants)
|
||||||
@@ -228,15 +320,153 @@ export class InvestmentPlanningService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a 'processing' placeholder record and return its ID.
|
||||||
|
*/
|
||||||
|
private async saveProcessingRecord(userId?: string): Promise<string> {
|
||||||
|
await this.ensureStatusColumn();
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`INSERT INTO ai_recommendations
|
||||||
|
(recommendations_json, overall_assessment, risk_notes, requested_by, status)
|
||||||
|
VALUES ('{}', '', '[]', $1, 'processing')
|
||||||
|
RETURNING id`,
|
||||||
|
[userId || null],
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a processing record with completed results.
|
||||||
|
*/
|
||||||
|
private async updateRecommendationComplete(
|
||||||
|
jobId: string,
|
||||||
|
aiResponse: AIResponse,
|
||||||
|
userId: string | undefined,
|
||||||
|
elapsed: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE ai_recommendations
|
||||||
|
SET recommendations_json = $1,
|
||||||
|
overall_assessment = $2,
|
||||||
|
risk_notes = $3,
|
||||||
|
response_time_ms = $4,
|
||||||
|
status = 'complete'
|
||||||
|
WHERE id = $5`,
|
||||||
|
[
|
||||||
|
JSON.stringify(aiResponse),
|
||||||
|
aiResponse.overall_assessment || '',
|
||||||
|
JSON.stringify(aiResponse.risk_notes || []),
|
||||||
|
elapsed,
|
||||||
|
jobId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Could not update recommendation ${jobId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a processing record with error status.
|
||||||
|
*/
|
||||||
|
private async updateRecommendationError(jobId: string, errorMessage: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE ai_recommendations
|
||||||
|
SET status = 'error',
|
||||||
|
error_message = $1
|
||||||
|
WHERE id = $2`,
|
||||||
|
[errorMessage, jobId],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Could not update recommendation error ${jobId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger AI recommendations asynchronously.
|
||||||
|
* Saves a 'processing' record, starts the AI work in the background, and returns immediately.
|
||||||
|
* The TenantService instance remains alive via closure reference for the duration of the background work.
|
||||||
|
*/
|
||||||
|
async triggerAIRecommendations(userId?: string, orgId?: string): Promise<{ status: string; message: string }> {
|
||||||
|
const jobId = await this.saveProcessingRecord(userId);
|
||||||
|
this.logger.log(`AI recommendation triggered (job ${jobId}), starting background processing...`);
|
||||||
|
|
||||||
|
// Fire-and-forget — the Promise keeps this service instance (and TenantService) alive
|
||||||
|
this.runBackgroundRecommendations(jobId, userId, orgId).catch((err) => {
|
||||||
|
this.logger.error(`Background AI recommendation failed (job ${jobId}): ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'AI analysis has been started. You can navigate away safely — results will appear when ready.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full AI recommendation pipeline in the background.
|
||||||
|
*/
|
||||||
|
private async runBackgroundRecommendations(jobId: string, userId?: string, orgId?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const [snapshot, allRates, monthlyForecast] = await Promise.all([
|
||||||
|
this.getFinancialSnapshot(),
|
||||||
|
this.getMarketRates(),
|
||||||
|
this.getMonthlyForecast(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.debug('background_snapshot_summary', {
|
||||||
|
job_id: jobId,
|
||||||
|
operating_cash: snapshot.summary.operating_cash,
|
||||||
|
reserve_cash: snapshot.summary.reserve_cash,
|
||||||
|
total_all: snapshot.summary.total_all,
|
||||||
|
investment_accounts: snapshot.investment_accounts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
|
||||||
|
const aiResponse = await this.callAI(messages);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.debug('background_final_response', {
|
||||||
|
job_id: jobId,
|
||||||
|
recommendation_count: aiResponse.recommendations.length,
|
||||||
|
has_assessment: !!aiResponse.overall_assessment,
|
||||||
|
elapsed_ms: elapsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the AI returned a graceful error (empty recommendations with error message)
|
||||||
|
const isGracefulError = aiResponse.recommendations.length === 0 &&
|
||||||
|
(aiResponse.overall_assessment?.includes('Unable to generate') ||
|
||||||
|
aiResponse.overall_assessment?.includes('invalid response'));
|
||||||
|
|
||||||
|
if (isGracefulError) {
|
||||||
|
await this.updateRecommendationError(jobId, aiResponse.overall_assessment);
|
||||||
|
} else {
|
||||||
|
await this.updateRecommendationComplete(jobId, aiResponse, userId, elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log AI usage (fire-and-forget)
|
||||||
|
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
|
||||||
|
|
||||||
|
this.logger.log(`Background AI recommendation completed (job ${jobId}) in ${elapsed}ms`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Background AI recommendation error (job ${jobId}): ${err.message}`);
|
||||||
|
await this.updateRecommendationError(jobId, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save AI recommendation result to tenant schema.
|
* Save AI recommendation result to tenant schema.
|
||||||
|
* @deprecated Use triggerAIRecommendations() for async flow instead
|
||||||
*/
|
*/
|
||||||
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
|
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
await this.ensureStatusColumn();
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO ai_recommendations
|
`INSERT INTO ai_recommendations
|
||||||
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
|
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5, 'complete')`,
|
||||||
[
|
[
|
||||||
JSON.stringify(aiResponse),
|
JSON.stringify(aiResponse),
|
||||||
aiResponse.overall_assessment || '',
|
aiResponse.overall_assessment || '',
|
||||||
@@ -647,7 +877,7 @@ export class InvestmentPlanningService {
|
|||||||
|
|
||||||
// ── Private: AI Prompt Construction ──
|
// ── Private: AI Prompt Construction ──
|
||||||
|
|
||||||
private buildPromptMessages(
|
buildPromptMessages(
|
||||||
snapshot: any,
|
snapshot: any,
|
||||||
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
|
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
|
||||||
monthlyForecast: any,
|
monthlyForecast: any,
|
||||||
@@ -684,13 +914,28 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
|
|||||||
"suggested_term": "12 months",
|
"suggested_term": "12 months",
|
||||||
"suggested_rate": 4.50,
|
"suggested_rate": 4.50,
|
||||||
"bank_name": "Bank name from market rates (if applicable)",
|
"bank_name": "Bank name from market rates (if applicable)",
|
||||||
"rationale": "Financial reasoning for why this makes sense"
|
"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",
|
"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"]
|
"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.`;
|
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
|
||||||
|
|
||||||
// Build the data context for the user prompt
|
// Build the data context for the user prompt
|
||||||
@@ -814,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: AI API Call ──
|
||||||
|
|
||||||
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
||||||
@@ -873,7 +1397,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
timeout: 180000, // 3 minute timeout
|
timeout: 600000, // 10 minute timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -887,7 +1411,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
req.on('error', (err) => reject(err));
|
req.on('error', (err) => reject(err));
|
||||||
req.on('timeout', () => {
|
req.on('timeout', () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error(`Request timed out after 180s`));
|
reject(new Error(`Request timed out after 600s`));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyString);
|
req.write(bodyString);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvestmentsService } from './investments.service';
|
import { InvestmentsService } from './investments.service';
|
||||||
|
|
||||||
@ApiTags('investments')
|
@ApiTags('investments')
|
||||||
@@ -11,14 +12,18 @@ export class InvestmentsController {
|
|||||||
constructor(private service: InvestmentsService) {}
|
constructor(private service: InvestmentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.investments.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.investments.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvoicesService } from './invoices.service';
|
import { InvoicesService } from './invoices.service';
|
||||||
|
|
||||||
@ApiTags('invoices')
|
@ApiTags('invoices')
|
||||||
@@ -11,17 +12,27 @@ export class InvoicesController {
|
|||||||
constructor(private invoicesService: InvoicesService) {}
|
constructor(private invoicesService: InvoicesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll() { return this.invoicesService.findAll(); }
|
findAll() { return this.invoicesService.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
||||||
|
|
||||||
|
@Post('generate-preview')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
|
generatePreview(@Body() dto: { month: number; year: number }) {
|
||||||
|
return this.invoicesService.generatePreview(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('generate-bulk')
|
@Post('generate-bulk')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
||||||
return this.invoicesService.generateBulk(dto, req.user.sub);
|
return this.invoicesService.generateBulk(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('apply-late-fees')
|
@Post('apply-late-fees')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
||||||
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,135 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { TenantService } from '../../database/tenant.service';
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'', 'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTH_ABBREV = [
|
||||||
|
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InvoicesService {
|
export class InvoicesService {
|
||||||
constructor(private tenant: TenantService) {}
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.tenant.query(`
|
return this.tenant.query(`
|
||||||
SELECT i.*, u.unit_number,
|
SELECT i.*, u.unit_number, u.owner_name, ag.name as assessment_group_name, ag.frequency,
|
||||||
(i.amount - i.amount_paid) as balance_due
|
(i.amount - i.amount_paid) as balance_due
|
||||||
FROM invoices i
|
FROM invoices i
|
||||||
JOIN units u ON u.id = i.unit_id
|
JOIN units u ON u.id = i.unit_id
|
||||||
|
LEFT JOIN assessment_groups ag ON ag.id = i.assessment_group_id
|
||||||
ORDER BY i.invoice_date DESC, i.invoice_number DESC
|
ORDER BY i.invoice_date DESC, i.invoice_number DESC
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const rows = await this.tenant.query(`
|
const rows = await this.tenant.query(`
|
||||||
SELECT i.*, u.unit_number FROM invoices i
|
SELECT i.*, u.unit_number, u.owner_name FROM invoices i
|
||||||
JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]);
|
JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]);
|
||||||
if (!rows.length) throw new NotFoundException('Invoice not found');
|
if (!rows.length) throw new NotFoundException('Invoice not found');
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateBulk(dto: { month: number; year: number }, userId: string) {
|
/**
|
||||||
const units = await this.tenant.query(
|
* Calculate billing period based on frequency and the billing month.
|
||||||
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 0`,
|
*/
|
||||||
|
private calculatePeriod(frequency: string, month: number, year: number): { start: string; end: string; description: string } {
|
||||||
|
switch (frequency) {
|
||||||
|
case 'quarterly': {
|
||||||
|
// Period covers 3 months starting from the billing month
|
||||||
|
const startDate = new Date(year, month - 1, 1);
|
||||||
|
const endDate = new Date(year, month + 2, 0); // last day of month+2
|
||||||
|
const endMonth = month + 2 > 12 ? month + 2 - 12 : month + 2;
|
||||||
|
const quarter = Math.ceil(month / 3);
|
||||||
|
return {
|
||||||
|
start: startDate.toISOString().split('T')[0],
|
||||||
|
end: endDate.toISOString().split('T')[0],
|
||||||
|
description: `Q${quarter} ${year} Assessment (${MONTH_ABBREV[month]}-${MONTH_ABBREV[endMonth]})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'annual': {
|
||||||
|
const startDate = new Date(year, 0, 1);
|
||||||
|
const endDate = new Date(year, 11, 31);
|
||||||
|
return {
|
||||||
|
start: startDate.toISOString().split('T')[0],
|
||||||
|
end: endDate.toISOString().split('T')[0],
|
||||||
|
description: `Annual Assessment ${year}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: { // monthly
|
||||||
|
const startDate = new Date(year, month - 1, 1);
|
||||||
|
const endDate = new Date(year, month, 0); // last day of month
|
||||||
|
return {
|
||||||
|
start: startDate.toISOString().split('T')[0],
|
||||||
|
end: endDate.toISOString().split('T')[0],
|
||||||
|
description: `Monthly Assessment - ${MONTH_NAMES[month]} ${year}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview which groups/units will be billed for a given month/year.
|
||||||
|
*/
|
||||||
|
async generatePreview(dto: { month: number; year: number }) {
|
||||||
|
const allGroups = await this.tenant.query(
|
||||||
|
`SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as active_units
|
||||||
|
FROM assessment_groups ag WHERE ag.is_active = true ORDER BY ag.name`,
|
||||||
);
|
);
|
||||||
if (!units.length) throw new BadRequestException('No active units with assessments found');
|
|
||||||
|
const groups = allGroups.map((g: any) => {
|
||||||
|
const dueMonths: number[] = g.due_months || [1,2,3,4,5,6,7,8,9,10,11,12];
|
||||||
|
const isBillingMonth = dueMonths.includes(dto.month);
|
||||||
|
const activeUnits = parseInt(g.active_units || '0');
|
||||||
|
const totalAmount = isBillingMonth
|
||||||
|
? (parseFloat(g.regular_assessment) + parseFloat(g.special_assessment || '0')) * activeUnits
|
||||||
|
: 0;
|
||||||
|
const period = this.calculatePeriod(g.frequency || 'monthly', dto.month, dto.year);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
frequency: g.frequency || 'monthly',
|
||||||
|
due_months: dueMonths,
|
||||||
|
active_units: activeUnits,
|
||||||
|
regular_assessment: g.regular_assessment,
|
||||||
|
special_assessment: g.special_assessment,
|
||||||
|
is_billing_month: isBillingMonth,
|
||||||
|
total_amount: totalAmount,
|
||||||
|
period_description: period.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const billableGroups = groups.filter((g: any) => g.is_billing_month && g.active_units > 0);
|
||||||
|
const totalInvoices = billableGroups.reduce((sum: number, g: any) => sum + g.active_units, 0);
|
||||||
|
const totalAmount = billableGroups.reduce((sum: number, g: any) => sum + g.total_amount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
month: dto.month,
|
||||||
|
year: dto.year,
|
||||||
|
month_name: MONTH_NAMES[dto.month],
|
||||||
|
groups,
|
||||||
|
summary: { total_groups_billing: billableGroups.length, total_invoices: totalInvoices, total_amount: totalAmount },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate invoices for all assessment groups where the given month is a billing month.
|
||||||
|
*/
|
||||||
|
async generateBulk(dto: { month: number; year: number }, userId: string) {
|
||||||
|
// Get assessment groups where this month is a billing month
|
||||||
|
const groups = await this.tenant.query(
|
||||||
|
`SELECT * FROM assessment_groups WHERE is_active = true AND $1 = ANY(due_months)`,
|
||||||
|
[dto.month],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!groups.length) {
|
||||||
|
throw new BadRequestException(`No assessment groups have billing scheduled for ${MONTH_NAMES[dto.month]}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Get or create fiscal period
|
// Get or create fiscal period
|
||||||
let fp = await this.tenant.query(
|
let fp = await this.tenant.query(
|
||||||
@@ -41,9 +143,32 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
const fiscalPeriodId = fp[0].id;
|
const fiscalPeriodId = fp[0].id;
|
||||||
|
|
||||||
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
|
// Look up GL accounts once
|
||||||
const dueDate = new Date(dto.year, dto.month - 1, 15);
|
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
|
||||||
|
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
|
||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
|
const groupResults: any[] = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
// Get active units in this assessment group
|
||||||
|
const units = await this.tenant.query(
|
||||||
|
`SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`,
|
||||||
|
[group.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!units.length) continue;
|
||||||
|
|
||||||
|
const frequency = group.frequency || 'monthly';
|
||||||
|
const period = this.calculatePeriod(frequency, dto.month, dto.year);
|
||||||
|
const dueDay = Math.min(group.due_day || 1, 28);
|
||||||
|
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
|
||||||
|
const dueDate = new Date(dto.year, dto.month - 1, dueDay);
|
||||||
|
|
||||||
|
// Use the group's assessment amount (full period amount, not monthly equivalent)
|
||||||
|
const assessmentAmount = parseFloat(group.regular_assessment) + parseFloat(group.special_assessment || '0');
|
||||||
|
|
||||||
|
let groupCreated = 0;
|
||||||
|
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
|
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
|
||||||
@@ -54,19 +179,24 @@ export class InvoicesService {
|
|||||||
);
|
);
|
||||||
if (existing.length) continue;
|
if (existing.length) continue;
|
||||||
|
|
||||||
// Create the invoice
|
// Use unit-level override if set, otherwise use group amount
|
||||||
|
const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0
|
||||||
|
? (frequency === 'monthly'
|
||||||
|
? parseFloat(unit.monthly_assessment)
|
||||||
|
: frequency === 'quarterly'
|
||||||
|
? parseFloat(unit.monthly_assessment) * 3
|
||||||
|
: parseFloat(unit.monthly_assessment) * 12)
|
||||||
|
: assessmentAmount;
|
||||||
|
|
||||||
|
// Create the invoice with status 'pending' (no email sending capability)
|
||||||
const inv = await this.tenant.query(
|
const inv = await this.tenant.query(
|
||||||
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
|
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status, period_start, period_end, assessment_group_id)
|
||||||
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
|
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) RETURNING id`,
|
||||||
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
|
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
|
||||||
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
|
period.description, unitAmount, period.start, period.end, group.id],
|
||||||
unit.monthly_assessment],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create journal entry: DR Accounts Receivable, CR Assessment Income
|
// Create journal entry: DR Accounts Receivable, CR Assessment Income
|
||||||
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
|
|
||||||
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
|
|
||||||
|
|
||||||
if (arAccount.length && incomeAccount.length) {
|
if (arAccount.length && incomeAccount.length) {
|
||||||
const je = await this.tenant.query(
|
const je = await this.tenant.query(
|
||||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
|
||||||
@@ -75,16 +205,25 @@ export class InvoicesService {
|
|||||||
);
|
);
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
|
||||||
[je[0].id, arAccount[0].id, unit.monthly_assessment, incomeAccount[0].id],
|
[je[0].id, arAccount[0].id, unitAmount, incomeAccount[0].id],
|
||||||
);
|
);
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
|
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
created++;
|
created++;
|
||||||
|
groupCreated++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, month: dto.month, year: dto.year };
|
groupResults.push({
|
||||||
|
group_name: group.name,
|
||||||
|
frequency,
|
||||||
|
period: period.description,
|
||||||
|
invoices_created: groupCreated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, month: dto.month, year: dto.year, groups: groupResults };
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
|
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
|
||||||
@@ -95,7 +234,7 @@ export class InvoicesService {
|
|||||||
const overdue = await this.tenant.query(`
|
const overdue = await this.tenant.query(`
|
||||||
SELECT i.*, u.unit_number FROM invoices i
|
SELECT i.*, u.unit_number FROM invoices i
|
||||||
JOIN units u ON u.id = i.unit_id
|
JOIN units u ON u.id = i.unit_id
|
||||||
WHERE i.status IN ('sent', 'partial') AND i.due_date < $1
|
WHERE i.status IN ('pending', 'partial') AND i.due_date < $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id
|
SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id
|
||||||
AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%'
|
AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%'
|
||||||
@@ -109,7 +248,7 @@ export class InvoicesService {
|
|||||||
const lfNum = `LF-${inv.invoice_number}`;
|
const lfNum = `LF-${inv.invoice_number}`;
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
|
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
|
||||||
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'sent')`,
|
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'pending')`,
|
||||||
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
|
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
|
||||||
);
|
);
|
||||||
applied++;
|
applied++;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { JournalEntriesService } from './journal-entries.service';
|
import { JournalEntriesService } from './journal-entries.service';
|
||||||
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
||||||
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
||||||
@@ -16,6 +17,7 @@ export class JournalEntriesController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List journal entries' })
|
@ApiOperation({ summary: 'List journal entries' })
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll(
|
findAll(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -27,24 +29,28 @@ export class JournalEntriesController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get journal entry by ID' })
|
@ApiOperation({ summary: 'Get journal entry by ID' })
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.jeService.findOne(id);
|
return this.jeService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a journal entry' })
|
@ApiOperation({ summary: 'Create a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
||||||
return this.jeService.create(dto, req.user.sub);
|
return this.jeService.create(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/post')
|
@Post(':id/post')
|
||||||
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
post(@Param('id') id: string, @Request() req: any) {
|
post(@Param('id') id: string, @Request() req: any) {
|
||||||
return this.jeService.post(id, req.user.sub);
|
return this.jeService.post(id, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/void')
|
@Post(':id/void')
|
||||||
@ApiOperation({ summary: 'Void a journal entry' })
|
@ApiOperation({ summary: 'Void a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
||||||
return this.jeService.void(id, req.user.sub, dto.reason);
|
return this.jeService.void(id, req.user.sub, dto.reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export class JournalEntriesService {
|
|||||||
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT je.*,
|
SELECT je.*,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
||||||
|
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
|
||||||
|
ELSE COALESCE(SUM(jel.debit), 0)
|
||||||
|
END as total_debit,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
||||||
|
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
|
||||||
|
ELSE COALESCE(SUM(jel.credit), 0)
|
||||||
|
END as total_credit,
|
||||||
json_agg(json_build_object(
|
json_agg(json_build_object(
|
||||||
'id', jel.id, 'account_id', jel.account_id,
|
'id', jel.id, 'account_id', jel.account_id,
|
||||||
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { MonthlyActualsService } from './monthly-actuals.service';
|
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||||
|
|
||||||
@ApiTags('monthly-actuals')
|
@ApiTags('monthly-actuals')
|
||||||
@@ -12,12 +13,14 @@ export class MonthlyActualsController {
|
|||||||
|
|
||||||
@Get(':year/:month')
|
@Get(':year/:month')
|
||||||
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
||||||
|
@RequireCapability('financials.actuals.view')
|
||||||
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
||||||
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':year/:month')
|
@Post(':year/:month')
|
||||||
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
||||||
|
@RequireCapability('financials.actuals.edit')
|
||||||
async save(
|
async save(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Param('month') month: string,
|
@Param('month') month: string,
|
||||||
|
|||||||
31
backend/src/modules/onboarding/onboarding.controller.ts
Normal file
31
backend/src/modules/onboarding/onboarding.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Controller, Get, Patch, Body, UseGuards, Request, BadRequestException } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { OnboardingService } from './onboarding.service';
|
||||||
|
|
||||||
|
@ApiTags('onboarding')
|
||||||
|
@Controller('onboarding')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class OnboardingController {
|
||||||
|
constructor(private onboardingService: OnboardingService) {}
|
||||||
|
|
||||||
|
@Get('progress')
|
||||||
|
@ApiOperation({ summary: 'Get onboarding progress for current org' })
|
||||||
|
@AllowViewer()
|
||||||
|
async getProgress(@Request() req: any) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
if (!orgId) throw new BadRequestException('No organization context');
|
||||||
|
return this.onboardingService.getProgress(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('progress')
|
||||||
|
@ApiOperation({ summary: 'Mark an onboarding step as complete' })
|
||||||
|
async markStep(@Request() req: any, @Body() body: { step: string }) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
if (!orgId) throw new BadRequestException('No organization context');
|
||||||
|
if (!body.step) throw new BadRequestException('step is required');
|
||||||
|
return this.onboardingService.markStepComplete(orgId, body.step);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/modules/onboarding/onboarding.module.ts
Normal file
10
backend/src/modules/onboarding/onboarding.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { OnboardingService } from './onboarding.service';
|
||||||
|
import { OnboardingController } from './onboarding.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OnboardingController],
|
||||||
|
providers: [OnboardingService],
|
||||||
|
exports: [OnboardingService],
|
||||||
|
})
|
||||||
|
export class OnboardingModule {}
|
||||||
79
backend/src/modules/onboarding/onboarding.service.ts
Normal file
79
backend/src/modules/onboarding/onboarding.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
const REQUIRED_STEPS = ['profile', 'workspace', 'invite_member', 'first_workflow'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OnboardingService {
|
||||||
|
private readonly logger = new Logger(OnboardingService.name);
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async getProgress(orgId: string) {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT completed_steps, completed_at, updated_at
|
||||||
|
FROM shared.onboarding_progress
|
||||||
|
WHERE organization_id = $1`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
// Create a fresh record
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.onboarding_progress (organization_id)
|
||||||
|
VALUES ($1) ON CONFLICT DO NOTHING`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
return { completedSteps: [], completedAt: null, requiredSteps: REQUIRED_STEPS };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
completedSteps: rows[0].completed_steps || [],
|
||||||
|
completedAt: rows[0].completed_at,
|
||||||
|
requiredSteps: REQUIRED_STEPS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async markStepComplete(orgId: string, step: string) {
|
||||||
|
// Add step to array (using array_append with dedup)
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.onboarding_progress (organization_id, completed_steps, updated_at)
|
||||||
|
VALUES ($1, ARRAY[$2::text], NOW())
|
||||||
|
ON CONFLICT (organization_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
completed_steps = CASE
|
||||||
|
WHEN $2 = ANY(onboarding_progress.completed_steps) THEN onboarding_progress.completed_steps
|
||||||
|
ELSE array_append(onboarding_progress.completed_steps, $2::text)
|
||||||
|
END,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[orgId, step],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if all required steps are done
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT completed_steps FROM shared.onboarding_progress WHERE organization_id = $1`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedSteps = rows[0]?.completed_steps || [];
|
||||||
|
const allDone = REQUIRED_STEPS.every((s) => completedSteps.includes(s));
|
||||||
|
|
||||||
|
if (allDone) {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.onboarding_progress SET completed_at = NOW() WHERE organization_id = $1 AND completed_at IS NULL`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getProgress(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetProgress(orgId: string) {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.onboarding_progress SET completed_steps = '{}', completed_at = NULL, updated_at = NOW()
|
||||||
|
WHERE organization_id = $1`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
return this.getProgress(orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { OrganizationsService } from './organizations.service';
|
import { OrganizationsService } from './organizations.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
|
import { resolveCapabilitiesArray, ALL_CAPABILITIES } from '../../common/permissions';
|
||||||
|
|
||||||
@ApiTags('organizations')
|
@ApiTags('organizations')
|
||||||
@Controller('organizations')
|
@Controller('organizations')
|
||||||
@@ -23,54 +25,87 @@ export class OrganizationsController {
|
|||||||
return this.orgService.findByUser(req.user.sub);
|
return this.orgService.findByUser(req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('my-capabilities')
|
||||||
|
@ApiOperation({ summary: 'Get resolved capabilities for current user in current org' })
|
||||||
|
async getMyCapabilities(@Request() req: any) {
|
||||||
|
const org = await this.orgService.findById(req.user.orgId);
|
||||||
|
const settings = org?.settings || {};
|
||||||
|
const capabilities = resolveCapabilitiesArray(req.user.role, settings.permissionOverrides);
|
||||||
|
return { role: req.user.role, capabilities };
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('settings')
|
@Patch('settings')
|
||||||
@ApiOperation({ summary: 'Update settings for the current organization' })
|
@ApiOperation({ summary: 'Update settings for the current organization' })
|
||||||
|
@RequireCapability('settings.org.edit')
|
||||||
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
||||||
this.requireTenantAdmin(req);
|
// Validate permissionOverrides if present
|
||||||
|
if (body.permissionOverrides) {
|
||||||
|
this.validatePermissionOverrides(body.permissionOverrides);
|
||||||
|
}
|
||||||
return this.orgService.updateSettings(req.user.orgId, body);
|
return this.orgService.updateSettings(req.user.orgId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Org Member Management ──
|
// ── Org Member Management ──
|
||||||
|
|
||||||
private requireTenantAdmin(req: any) {
|
|
||||||
const role = req.user.role;
|
|
||||||
if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) {
|
|
||||||
throw new ForbiddenException('Only organization administrators can manage members');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('members')
|
@Get('members')
|
||||||
@ApiOperation({ summary: 'List members of current organization' })
|
@ApiOperation({ summary: 'List members of current organization' })
|
||||||
|
@RequireCapability('settings.members.view')
|
||||||
async getMembers(@Request() req: any) {
|
async getMembers(@Request() req: any) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.getMembers(req.user.orgId);
|
return this.orgService.getMembers(req.user.orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('members')
|
@Post('members')
|
||||||
@ApiOperation({ summary: 'Add a member to the current organization' })
|
@ApiOperation({ summary: 'Add a member to the current organization' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async addMember(
|
async addMember(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
||||||
) {
|
) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.addMember(req.user.orgId, body);
|
return this.orgService.addMember(req.user.orgId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('members/:id/role')
|
@Put('members/:id/role')
|
||||||
@ApiOperation({ summary: 'Update a member role' })
|
@ApiOperation({ summary: 'Update a member role' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async updateMemberRole(
|
async updateMemberRole(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: { role: string },
|
@Body() body: { role: string },
|
||||||
) {
|
) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('members/:id')
|
@Delete('members/:id')
|
||||||
@ApiOperation({ summary: 'Remove a member from the organization' })
|
@ApiOperation({ summary: 'Remove a member from the organization' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async removeMember(@Request() req: any, @Param('id') id: string) {
|
async removeMember(@Request() req: any, @Param('id') id: string) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validatePermissionOverrides(overrides: any) {
|
||||||
|
if (typeof overrides !== 'object' || overrides === null) {
|
||||||
|
throw new ForbiddenException('permissionOverrides must be an object');
|
||||||
|
}
|
||||||
|
const validRoles = ['president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'];
|
||||||
|
for (const role of Object.keys(overrides)) {
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
throw new ForbiddenException(`Invalid role in permissionOverrides: ${role}`);
|
||||||
|
}
|
||||||
|
const entry = overrides[role];
|
||||||
|
if (entry.grant) {
|
||||||
|
for (const cap of entry.grant) {
|
||||||
|
if (!ALL_CAPABILITIES.has(cap)) {
|
||||||
|
throw new ForbiddenException(`Unknown capability in grant: ${cap}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.revoke) {
|
||||||
|
for (const cap of entry.revoke) {
|
||||||
|
if (!ALL_CAPABILITIES.has(cap)) {
|
||||||
|
throw new ForbiddenException(`Unknown capability in revoke: ${cap}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Organization } from './entities/organization.entity';
|
import { Organization } from './entities/organization.entity';
|
||||||
import { UserOrganization } from './entities/user-organization.entity';
|
import { UserOrganization } from './entities/user-organization.entity';
|
||||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
|
import { EmailService } from '../email/email.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsService {
|
export class OrganizationsService {
|
||||||
|
private readonly logger = new Logger(OrganizationsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Organization)
|
@InjectRepository(Organization)
|
||||||
private orgRepository: Repository<Organization>,
|
private orgRepository: Repository<Organization>,
|
||||||
@InjectRepository(UserOrganization)
|
@InjectRepository(UserOrganization)
|
||||||
private userOrgRepository: Repository<UserOrganization>,
|
private userOrgRepository: Repository<UserOrganization>,
|
||||||
private tenantSchemaService: TenantSchemaService,
|
private tenantSchemaService: TenantSchemaService,
|
||||||
|
private emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateOrganizationDto, userId: string) {
|
async create(dto: CreateOrganizationDto, userId: string) {
|
||||||
@@ -124,12 +128,29 @@ export class OrganizationsService {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly MEMBER_LIMIT_PLANS = ['starter', 'standard', 'professional'];
|
||||||
|
private static readonly MAX_MEMBERS = 5;
|
||||||
|
|
||||||
async addMember(
|
async addMember(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
data: { email: string; firstName: string; lastName: string; password: string; role: string },
|
data: { email: string; firstName: string; lastName: string; password: string; role: string },
|
||||||
) {
|
) {
|
||||||
const dataSource = this.orgRepository.manager.connection;
|
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
|
// Check if user already exists
|
||||||
let userRows = await dataSource.query(
|
let userRows = await dataSource.query(
|
||||||
`SELECT id FROM shared.users WHERE email = $1`,
|
`SELECT id FROM shared.users WHERE email = $1`,
|
||||||
@@ -153,6 +174,14 @@ export class OrganizationsService {
|
|||||||
existing.role = data.role;
|
existing.role = data.role;
|
||||||
return this.userOrgRepository.save(existing);
|
return this.userOrgRepository.save(existing);
|
||||||
}
|
}
|
||||||
|
// Update password for existing user being added to a new org
|
||||||
|
if (data.password) {
|
||||||
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
await dataSource.query(
|
||||||
|
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
|
||||||
|
[passwordHash, userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new user
|
// Create new user
|
||||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
@@ -171,7 +200,23 @@ export class OrganizationsService {
|
|||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
role: data.role,
|
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) {
|
async updateMemberRole(orgId: string, membershipId: string, role: string) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
|
|
||||||
@ApiTags('payments')
|
@ApiTags('payments')
|
||||||
@@ -11,11 +12,24 @@ export class PaymentsController {
|
|||||||
constructor(private paymentsService: PaymentsService) {}
|
constructor(private paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll() { return this.paymentsService.findAll(); }
|
findAll() { return this.paymentsService.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
|
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
||||||
|
return this.paymentsService.update(id, dto, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
|
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,17 +74,95 @@ export class PaymentsService {
|
|||||||
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
|
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update invoice if linked
|
// Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error
|
||||||
if (invoice) {
|
if (invoice) {
|
||||||
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
|
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
|
||||||
const invoiceAmt = parseFloat(invoice.amount);
|
const invoiceAmt = parseFloat(invoice.amount);
|
||||||
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
|
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`UPDATE invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $3`,
|
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $4`,
|
||||||
[newPaid, newStatus, invoice.id],
|
[newPaid, newStatus, newStatus, invoice.id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payment[0];
|
return payment[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: any, userId: string) {
|
||||||
|
const existing = await this.findOne(id);
|
||||||
|
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (dto.payment_date !== undefined) { sets.push(`payment_date = $${idx++}`); params.push(dto.payment_date); }
|
||||||
|
if (dto.amount !== undefined) { sets.push(`amount = $${idx++}`); params.push(dto.amount); }
|
||||||
|
if (dto.payment_method !== undefined) { sets.push(`payment_method = $${idx++}`); params.push(dto.payment_method); }
|
||||||
|
if (dto.reference_number !== undefined) { sets.push(`reference_number = $${idx++}`); params.push(dto.reference_number); }
|
||||||
|
if (dto.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(dto.notes); }
|
||||||
|
|
||||||
|
if (!sets.length) return this.findOne(id);
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE payments SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If amount changed and payment is linked to an invoice, recalculate invoice totals
|
||||||
|
if (dto.amount !== undefined && existing.invoice_id) {
|
||||||
|
await this.recalculateInvoice(existing.invoice_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const payment = await this.findOne(id);
|
||||||
|
const invoiceId = payment.invoice_id;
|
||||||
|
|
||||||
|
// Delete associated journal entry lines and journal entry
|
||||||
|
if (payment.journal_entry_id) {
|
||||||
|
await this.tenant.query('DELETE FROM journal_entry_lines WHERE journal_entry_id = $1', [payment.journal_entry_id]);
|
||||||
|
await this.tenant.query('DELETE FROM journal_entries WHERE id = $1', [payment.journal_entry_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the payment
|
||||||
|
await this.tenant.query('DELETE FROM payments WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
// Recalculate invoice totals if payment was linked
|
||||||
|
if (invoiceId) {
|
||||||
|
await this.recalculateInvoice(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recalculateInvoice(invoiceId: string) {
|
||||||
|
// Sum all remaining payments for this invoice
|
||||||
|
const result = await this.tenant.query(
|
||||||
|
'SELECT COALESCE(SUM(amount), 0) as total_paid FROM payments WHERE invoice_id = $1',
|
||||||
|
[invoiceId],
|
||||||
|
);
|
||||||
|
const totalPaid = parseFloat(result[0].total_paid);
|
||||||
|
|
||||||
|
// Get the invoice amount
|
||||||
|
const inv = await this.tenant.query('SELECT amount FROM invoices WHERE id = $1', [invoiceId]);
|
||||||
|
if (!inv.length) return;
|
||||||
|
|
||||||
|
const invoiceAmt = parseFloat(inv[0].amount);
|
||||||
|
let newStatus: string;
|
||||||
|
if (totalPaid >= invoiceAmt) {
|
||||||
|
newStatus = 'paid';
|
||||||
|
} else if (totalPaid > 0) {
|
||||||
|
newStatus = 'partial';
|
||||||
|
} else {
|
||||||
|
newStatus = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = $4`,
|
||||||
|
[totalPaid, newStatus, newStatus, invoiceId],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
@ApiTags('projects')
|
@ApiTags('projects')
|
||||||
@@ -12,9 +13,11 @@ export class ProjectsController {
|
|||||||
constructor(private service: ProjectsService) {}
|
constructor(private service: ProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.service.exportCSV();
|
const csv = await this.service.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
||||||
@@ -22,21 +25,27 @@ export class ProjectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('planning')
|
@Get('planning')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findForPlanning() { return this.service.findForPlanning(); }
|
findForPlanning() { return this.service.findForPlanning(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
@Put(':id/planned-date')
|
@Put(':id/planned-date')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
||||||
return this.service.updatePlannedDate(id, dto.planned_date);
|
return this.service.updatePlannedDate(id, dto.planned_date);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ export class ProjectsService {
|
|||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
|
|
||||||
|
// Date columns must be null (not empty string) for PostgreSQL DATE type
|
||||||
|
const dateFields = new Set(['last_replacement_date', 'next_replacement_date', 'planned_date']);
|
||||||
|
|
||||||
// Build dynamic SET clause
|
// Build dynamic SET clause
|
||||||
const fields: [string, string][] = [
|
const fields: [string, string][] = [
|
||||||
['name', 'name'], ['description', 'description'], ['category', 'category'],
|
['name', 'name'], ['description', 'description'], ['category', 'category'],
|
||||||
@@ -175,7 +178,8 @@ export class ProjectsService {
|
|||||||
for (const [dtoKey, dbCol] of fields) {
|
for (const [dtoKey, dbCol] of fields) {
|
||||||
if (dto[dtoKey] !== undefined) {
|
if (dto[dtoKey] !== undefined) {
|
||||||
sets.push(`${dbCol} = $${idx++}`);
|
sets.push(`${dbCol} = $${idx++}`);
|
||||||
params.push(dto[dtoKey]);
|
const val = dateFields.has(dtoKey) && dto[dtoKey] === '' ? null : dto[dtoKey];
|
||||||
|
params.push(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +280,7 @@ export class ProjectsService {
|
|||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
|
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
|
||||||
[id, planned_date],
|
[id, planned_date || null],
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
@ApiTags('reports')
|
@ApiTags('reports')
|
||||||
@@ -11,11 +12,13 @@ export class ReportsController {
|
|||||||
constructor(private reportsService: ReportsService) {}
|
constructor(private reportsService: ReportsService) {}
|
||||||
|
|
||||||
@Get('balance-sheet')
|
@Get('balance-sheet')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getBalanceSheet(@Query('as_of') asOf?: string) {
|
getBalanceSheet(@Query('as_of') asOf?: string) {
|
||||||
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('income-statement')
|
@Get('income-statement')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const defaultFrom = `${now.getFullYear()}-01-01`;
|
const defaultFrom = `${now.getFullYear()}-01-01`;
|
||||||
@@ -24,6 +27,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-sankey')
|
@Get('cash-flow-sankey')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowSankey(
|
getCashFlowSankey(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
@Query('source') source?: string,
|
@Query('source') source?: string,
|
||||||
@@ -37,6 +41,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow')
|
@Get('cash-flow')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowStatement(
|
getCashFlowStatement(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -51,21 +56,31 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('aging')
|
@Get('aging')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getAgingReport() {
|
getAgingReport() {
|
||||||
return this.reportsService.getAgingReport();
|
return this.reportsService.getAgingReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('year-end')
|
@Get('year-end')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getYearEndSummary(@Query('year') year?: string) {
|
getYearEndSummary(@Query('year') year?: string) {
|
||||||
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getDashboardKPIs() {
|
getDashboardKPIs() {
|
||||||
return this.reportsService.getDashboardKPIs();
|
return this.reportsService.getDashboardKPIs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('upcoming-investment-activities')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
|
getUpcomingInvestmentActivities() {
|
||||||
|
return this.reportsService.getUpcomingInvestmentActivities();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('cash-flow-forecast')
|
@Get('cash-flow-forecast')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowForecast(
|
getCashFlowForecast(
|
||||||
@Query('startYear') startYear?: string,
|
@Query('startYear') startYear?: string,
|
||||||
@Query('months') months?: string,
|
@Query('months') months?: string,
|
||||||
@@ -75,7 +90,16 @@ export class ReportsController {
|
|||||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('capital-planning')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
|
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
||||||
|
return this.reportsService.getCapitalPlanningReport(
|
||||||
|
parseInt(startYear || '') || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('quarterly')
|
@Get('quarterly')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getQuarterlyFinancial(
|
getQuarterlyFinancial(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
@Query('quarter') quarter?: string,
|
@Query('quarter') quarter?: string,
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ export class ReportsService {
|
|||||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||||
END as balance
|
END as balance
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN (
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
journal_entry_lines jel
|
||||||
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
AND je.entry_date <= $1
|
AND je.entry_date <= $1
|
||||||
|
) ON jel.account_id = a.id
|
||||||
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
|
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
|
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||||
HAVING CASE
|
HAVING CASE
|
||||||
@@ -32,6 +34,71 @@ export class ReportsService {
|
|||||||
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
|
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
|
||||||
const equity = rows.filter((r: any) => r.account_type === 'equity');
|
const equity = rows.filter((r: any) => r.account_type === 'equity');
|
||||||
|
|
||||||
|
// Compute current year net income (income - expenses) for the fiscal year through as_of date
|
||||||
|
// This balances the accounting equation: Assets = Liabilities + Equity + Net Income
|
||||||
|
const fiscalYearStart = `${asOf.substring(0, 4)}-01-01`;
|
||||||
|
const netIncomeSql = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN a.account_type = 'income'
|
||||||
|
THEN jel.credit - jel.debit ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
|
||||||
|
THEN jel.debit - jel.credit ELSE 0 END), 0) as net_income
|
||||||
|
FROM journal_entry_lines jel
|
||||||
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_date BETWEEN $1 AND $2
|
||||||
|
INNER JOIN accounts a ON a.id = jel.account_id
|
||||||
|
AND a.account_type IN ('income', 'expense') AND a.is_active = true
|
||||||
|
`;
|
||||||
|
const netIncomeResult = await this.tenant.query(netIncomeSql, [fiscalYearStart, asOf]);
|
||||||
|
const netIncome = parseFloat(netIncomeResult[0]?.net_income || '0');
|
||||||
|
|
||||||
|
// Add current year net income as a synthetic equity line
|
||||||
|
if (netIncome !== 0) {
|
||||||
|
equity.push({
|
||||||
|
id: null,
|
||||||
|
account_number: '',
|
||||||
|
name: 'Current Year Net Income',
|
||||||
|
account_type: 'equity',
|
||||||
|
fund_type: 'operating',
|
||||||
|
balance: netIncome.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add investment account balances to assets and corresponding equity
|
||||||
|
const investmentsSql = `
|
||||||
|
SELECT id, name, institution, current_value as balance, fund_type
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true AND current_value > 0
|
||||||
|
`;
|
||||||
|
const investments = await this.tenant.query(investmentsSql);
|
||||||
|
const investmentsByFund: Record<string, number> = {};
|
||||||
|
for (const inv of investments) {
|
||||||
|
assets.push({
|
||||||
|
id: inv.id,
|
||||||
|
account_number: '',
|
||||||
|
name: `${inv.name} (${inv.institution})`,
|
||||||
|
account_type: 'asset',
|
||||||
|
fund_type: inv.fund_type,
|
||||||
|
balance: parseFloat(inv.balance).toFixed(2),
|
||||||
|
});
|
||||||
|
investmentsByFund[inv.fund_type] = (investmentsByFund[inv.fund_type] || 0) + parseFloat(inv.balance);
|
||||||
|
}
|
||||||
|
// Add investment balances as synthetic equity lines to maintain A = L + E
|
||||||
|
for (const [fundType, total] of Object.entries(investmentsByFund)) {
|
||||||
|
if (total > 0) {
|
||||||
|
const label = fundType === 'reserve' ? 'Reserve' : 'Operating';
|
||||||
|
equity.push({
|
||||||
|
id: null,
|
||||||
|
account_number: '',
|
||||||
|
name: `${label} Investment Holdings`,
|
||||||
|
account_type: 'equity',
|
||||||
|
fund_type: fundType,
|
||||||
|
balance: total.toFixed(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||||
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||||
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||||
@@ -54,10 +121,12 @@ export class ReportsService {
|
|||||||
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||||
END as amount
|
END as amount
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN (
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
journal_entry_lines jel
|
||||||
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
AND je.entry_date BETWEEN $1 AND $2
|
AND je.entry_date BETWEEN $1 AND $2
|
||||||
|
) ON jel.account_id = a.id
|
||||||
WHERE a.is_active = true AND a.account_type IN ('income', 'expense')
|
WHERE a.is_active = true AND a.account_type IN ('income', 'expense')
|
||||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||||
HAVING CASE
|
HAVING CASE
|
||||||
@@ -340,20 +409,20 @@ export class ReportsService {
|
|||||||
ORDER BY a.name
|
ORDER BY a.name
|
||||||
`, [from, to]);
|
`, [from, to]);
|
||||||
|
|
||||||
// Asset filter: cash-only vs cash + investment accounts
|
// Asset filter: all asset accounts (bank/checking/savings are the cash accounts)
|
||||||
const assetFilter = includeInvestments
|
const assetFilter = `a.account_type = 'asset'`;
|
||||||
? `a.account_type = 'asset'`
|
|
||||||
: `a.account_type = 'asset' AND a.name LIKE '%Cash%'`;
|
|
||||||
|
|
||||||
// Cash beginning and ending balances
|
// Cash beginning and ending balances
|
||||||
const beginCash = await this.tenant.query(`
|
const beginCash = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN (
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
journal_entry_lines jel
|
||||||
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
AND je.entry_date < $1
|
AND je.entry_date < $1
|
||||||
|
) ON jel.account_id = a.id
|
||||||
WHERE ${assetFilter} AND a.is_active = true
|
WHERE ${assetFilter} AND a.is_active = true
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
@@ -363,10 +432,12 @@ export class ReportsService {
|
|||||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN (
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
journal_entry_lines jel
|
||||||
|
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
AND je.entry_date <= $1
|
AND je.entry_date <= $1
|
||||||
|
) ON jel.account_id = a.id
|
||||||
WHERE ${assetFilter} AND a.is_active = true
|
WHERE ${assetFilter} AND a.is_active = true
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
@@ -479,19 +550,22 @@ export class ReportsService {
|
|||||||
const incomeStmt = await this.getIncomeStatement(from, to);
|
const incomeStmt = await this.getIncomeStatement(from, to);
|
||||||
const balanceSheet = await this.getBalanceSheet(to);
|
const balanceSheet = await this.getBalanceSheet(to);
|
||||||
|
|
||||||
// 1099 vendor data
|
// 1099 vendor data — uses journal entries via vendor's default_account_id
|
||||||
const vendors1099 = await this.tenant.query(`
|
const vendors1099 = await this.tenant.query(`
|
||||||
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
|
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
|
||||||
COALESCE(SUM(p.amount), 0) as total_paid
|
COALESCE(SUM(p_amounts.amount), 0) as total_paid
|
||||||
FROM vendors v
|
FROM vendors v
|
||||||
JOIN (
|
LEFT JOIN (
|
||||||
SELECT vendor_id, amount FROM invoices
|
SELECT jel.account_id, jel.debit as amount
|
||||||
WHERE EXTRACT(YEAR FROM invoice_date) = $1
|
FROM journal_entry_lines jel
|
||||||
AND status IN ('paid', 'partial')
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
) p ON p.vendor_id = v.id
|
WHERE je.is_posted = true AND je.is_void = false
|
||||||
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
|
AND jel.debit > 0
|
||||||
|
) p_amounts ON p_amounts.account_id = v.default_account_id
|
||||||
WHERE v.is_1099_eligible = true
|
WHERE v.is_1099_eligible = true
|
||||||
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
|
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
|
||||||
HAVING COALESCE(SUM(p.amount), 0) >= 600
|
HAVING COALESCE(SUM(p_amounts.amount), 0) >= 600
|
||||||
ORDER BY v.name
|
ORDER BY v.name
|
||||||
`, [year]);
|
`, [year]);
|
||||||
|
|
||||||
@@ -642,14 +716,38 @@ export class ReportsService {
|
|||||||
`);
|
`);
|
||||||
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
|
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
|
||||||
|
|
||||||
// Interest earned YTD: approximate from current_value - principal (unrealized gains)
|
// Interest earned YTD: actual interest income from journal entries for current year
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
const interestEarned = await this.tenant.query(`
|
const interestEarned = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(current_value - principal), 0) as total
|
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
|
||||||
FROM investment_accounts WHERE is_active = true AND current_value > principal
|
FROM accounts a
|
||||||
`);
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
|
WHERE a.account_type = 'income' AND a.is_active = true
|
||||||
|
AND LOWER(a.name) LIKE '%interest%'
|
||||||
|
`, [currentYear]);
|
||||||
|
|
||||||
|
// Interest earned last year (for YoY comparison)
|
||||||
|
const interestLastYear = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
|
WHERE a.account_type = 'income' AND a.is_active = true
|
||||||
|
AND LOWER(a.name) LIKE '%interest%'
|
||||||
|
`, [currentYear - 1]);
|
||||||
|
|
||||||
|
// Projected interest for current year: YTD actual + remaining months using
|
||||||
|
// the rate-based est_monthly_interest (same source as the dashboard KPI)
|
||||||
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
|
const ytdInterest = parseFloat(interestEarned[0]?.total || '0');
|
||||||
|
const projectedInterest = ytdInterest + (estMonthlyInterest * (12 - currentMonth));
|
||||||
|
|
||||||
// Planned capital spend for current year
|
// Planned capital spend for current year
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const capitalSpend = await this.tenant.query(`
|
const capitalSpend = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||||
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
|
||||||
@@ -675,11 +773,85 @@ export class ReportsService {
|
|||||||
operating_investments: operatingInvestments.toFixed(2),
|
operating_investments: operatingInvestments.toFixed(2),
|
||||||
reserve_investments: reserveInvestments.toFixed(2),
|
reserve_investments: reserveInvestments.toFixed(2),
|
||||||
est_monthly_interest: estMonthlyInterest.toFixed(2),
|
est_monthly_interest: estMonthlyInterest.toFixed(2),
|
||||||
interest_earned_ytd: interestEarned[0]?.total || '0.00',
|
interest_earned_ytd: ytdInterest.toFixed(2),
|
||||||
|
interest_last_year: parseFloat(interestLastYear[0]?.total || '0').toFixed(2),
|
||||||
|
interest_projected: projectedInterest.toFixed(2),
|
||||||
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
planned_capital_spend: capitalSpend[0]?.total || '0.00',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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).
|
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
||||||
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
||||||
@@ -764,8 +936,29 @@ export class ReportsService {
|
|||||||
// We need budgets for startYear and startYear+1 to cover 24 months
|
// We need budgets for startYear and startYear+1 to cover 24 months
|
||||||
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||||
|
|
||||||
for (const yr of [startYear, startYear + 1, startYear + 2]) {
|
const endYear = startYear + Math.ceil(months / 12) + 1;
|
||||||
const budgetRows = await this.tenant.query(
|
for (let yr = startYear; yr <= endYear; yr++) {
|
||||||
|
let budgetRows: any[];
|
||||||
|
try {
|
||||||
|
budgetRows = await this.tenant.query(
|
||||||
|
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
|
||||||
|
SELECT b.account_id, 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,
|
||||||
|
1 as source_priority
|
||||||
|
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT bpl.account_id, bpl.fund_type, a.account_type,
|
||||||
|
bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt,
|
||||||
|
2 as source_priority
|
||||||
|
FROM budget_plan_lines bpl
|
||||||
|
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
|
||||||
|
JOIN accounts a ON a.id = bpl.account_id
|
||||||
|
WHERE bp.fiscal_year = $1
|
||||||
|
) combined
|
||||||
|
ORDER BY account_id, fund_type, source_priority`, [yr],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
budgetRows = await this.tenant.query(
|
||||||
`SELECT b.fund_type, a.account_type,
|
`SELECT b.fund_type, a.account_type,
|
||||||
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
|
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
|
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||||
@@ -773,6 +966,7 @@ export class ReportsService {
|
|||||||
JOIN accounts a ON a.id = b.account_id
|
JOIN accounts a ON a.id = b.account_id
|
||||||
WHERE b.fiscal_year = $1`, [yr],
|
WHERE b.fiscal_year = $1`, [yr],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
for (let m = 0; m < 12; m++) {
|
for (let m = 0; m < 12; m++) {
|
||||||
const key = `${yr}-${m + 1}`;
|
const key = `${yr}-${m + 1}`;
|
||||||
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
@@ -899,11 +1093,24 @@ export class ReportsService {
|
|||||||
let runOpInv = opInv;
|
let runOpInv = opInv;
|
||||||
let runResInv = resInv;
|
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++) {
|
for (let i = 0; i < months; i++) {
|
||||||
const year = startYear + Math.floor(i / 12);
|
const year = startYear + Math.floor(i / 12);
|
||||||
const month = (i % 12) + 1;
|
const month = (i % 12) + 1;
|
||||||
const key = `${year}-${month}`;
|
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}`;
|
const label = `${monthLabels[month - 1]} ${year}`;
|
||||||
|
|
||||||
if (isHistorical) {
|
if (isHistorical) {
|
||||||
@@ -1129,4 +1336,120 @@ export class ReportsService {
|
|||||||
over_budget_items: overBudgetItems,
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ReserveComponentsService } from './reserve-components.service';
|
import { ReserveComponentsService } from './reserve-components.service';
|
||||||
|
|
||||||
@ApiTags('reserve-components')
|
@ApiTags('reserve-components')
|
||||||
@@ -11,14 +12,18 @@ export class ReserveComponentsController {
|
|||||||
constructor(private service: ReserveComponentsService) {}
|
constructor(private service: ReserveComponentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
44
backend/src/modules/shadow-ai/entities/shadow-run.entity.ts
Normal file
44
backend/src/modules/shadow-ai/entities/shadow-run.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
118
backend/src/modules/shadow-ai/shadow-ai.controller.ts
Normal file
118
backend/src/modules/shadow-ai/shadow-ai.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/modules/shadow-ai/shadow-ai.module.ts
Normal file
26
backend/src/modules/shadow-ai/shadow-ai.module.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
723
backend/src/modules/shadow-ai/shadow-ai.service.ts
Normal file
723
backend/src/modules/shadow-ai/shadow-ai.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { UnitsService } from './units.service';
|
import { UnitsService } from './units.service';
|
||||||
|
|
||||||
@ApiTags('units')
|
@ApiTags('units')
|
||||||
@@ -12,9 +13,11 @@ export class UnitsController {
|
|||||||
constructor(private unitsService: UnitsService) {}
|
constructor(private unitsService: UnitsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
findAll() { return this.unitsService.findAll(); }
|
findAll() { return this.unitsService.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.unitsService.exportCSV();
|
const csv = await this.unitsService.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
||||||
@@ -22,17 +25,22 @@ export class UnitsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { VendorsService } from './vendors.service';
|
import { VendorsService } from './vendors.service';
|
||||||
|
|
||||||
@ApiTags('vendors')
|
@ApiTags('vendors')
|
||||||
@@ -12,9 +13,11 @@ export class VendorsController {
|
|||||||
constructor(private vendorsService: VendorsService) {}
|
constructor(private vendorsService: VendorsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
findAll() { return this.vendorsService.findAll(); }
|
findAll() { return this.vendorsService.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.vendorsService.exportCSV();
|
const csv = await this.vendorsService.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
||||||
@@ -22,19 +25,24 @@ export class VendorsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('1099-data')
|
@Get('1099-data')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
get1099Data(@Query('year') year: string) {
|
get1099Data(@Query('year') year: string) {
|
||||||
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ CREATE TABLE shared.user_organizations (
|
|||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, organization_id)
|
UNIQUE(user_id, organization_id)
|
||||||
|
|||||||
57
db/migrations/011-invoice-billing-frequency.sql
Normal file
57
db/migrations/011-invoice-billing-frequency.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-- Migration 011: Add billing frequency support to invoices
|
||||||
|
-- Adds due_months and due_day to assessment_groups
|
||||||
|
-- Adds period_start, period_end, assessment_group_id to invoices
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_schema TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR v_schema IN
|
||||||
|
SELECT schema_name FROM information_schema.schemata
|
||||||
|
WHERE schema_name LIKE 'tenant_%'
|
||||||
|
LOOP
|
||||||
|
-- Add due_months and due_day to assessment_groups
|
||||||
|
EXECUTE format('
|
||||||
|
ALTER TABLE %I.assessment_groups
|
||||||
|
ADD COLUMN IF NOT EXISTS due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
|
||||||
|
ADD COLUMN IF NOT EXISTS due_day INTEGER DEFAULT 1
|
||||||
|
', v_schema);
|
||||||
|
|
||||||
|
-- Add period tracking and assessment group link to invoices
|
||||||
|
EXECUTE format('
|
||||||
|
ALTER TABLE %I.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS period_start DATE,
|
||||||
|
ADD COLUMN IF NOT EXISTS period_end DATE,
|
||||||
|
ADD COLUMN IF NOT EXISTS assessment_group_id UUID
|
||||||
|
', v_schema);
|
||||||
|
|
||||||
|
-- Backfill due_months based on existing frequency values
|
||||||
|
EXECUTE format('
|
||||||
|
UPDATE %I.assessment_groups
|
||||||
|
SET due_months = CASE frequency
|
||||||
|
WHEN ''quarterly'' THEN ''{1,4,7,10}''::INTEGER[]
|
||||||
|
WHEN ''annual'' THEN ''{1}''::INTEGER[]
|
||||||
|
ELSE ''{1,2,3,4,5,6,7,8,9,10,11,12}''::INTEGER[]
|
||||||
|
END
|
||||||
|
WHERE due_months IS NULL OR due_months = ''{1,2,3,4,5,6,7,8,9,10,11,12}''
|
||||||
|
AND frequency != ''monthly''
|
||||||
|
', v_schema);
|
||||||
|
|
||||||
|
-- Backfill period_start/period_end for existing invoices (all monthly)
|
||||||
|
EXECUTE format('
|
||||||
|
UPDATE %I.invoices
|
||||||
|
SET period_start = invoice_date,
|
||||||
|
period_end = (invoice_date + INTERVAL ''1 month'' - INTERVAL ''1 day'')::DATE
|
||||||
|
WHERE period_start IS NULL AND invoice_type = ''regular_assessment''
|
||||||
|
', v_schema);
|
||||||
|
|
||||||
|
-- Backfill assessment_group_id on existing invoices from units
|
||||||
|
EXECUTE format('
|
||||||
|
UPDATE %I.invoices i
|
||||||
|
SET assessment_group_id = u.assessment_group_id
|
||||||
|
FROM %I.units u
|
||||||
|
WHERE i.unit_id = u.id AND i.assessment_group_id IS NULL
|
||||||
|
', v_schema, v_schema);
|
||||||
|
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
33
db/migrations/012-invoice-status-pending.sql
Normal file
33
db/migrations/012-invoice-status-pending.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Migration 012: Replace 'sent' status with 'pending' for invoices
|
||||||
|
-- 'sent' implied email delivery which doesn't exist; 'pending' is more accurate
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_schema TEXT;
|
||||||
|
v_constraint TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR v_schema IN
|
||||||
|
SELECT schema_name FROM information_schema.schemata
|
||||||
|
WHERE schema_name LIKE 'tenant_%'
|
||||||
|
LOOP
|
||||||
|
-- Find and drop the existing status check constraint
|
||||||
|
SELECT constraint_name INTO v_constraint
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = v_schema
|
||||||
|
AND table_name = 'invoices'
|
||||||
|
AND constraint_type = 'CHECK'
|
||||||
|
AND constraint_name LIKE '%status%';
|
||||||
|
|
||||||
|
IF v_constraint IS NOT NULL THEN
|
||||||
|
EXECUTE format('ALTER TABLE %I.invoices DROP CONSTRAINT %I', v_schema, v_constraint);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Add new constraint that includes 'pending'
|
||||||
|
EXECUTE format('ALTER TABLE %I.invoices ADD CONSTRAINT invoices_status_check CHECK (status IN (
|
||||||
|
''draft'', ''pending'', ''sent'', ''paid'', ''partial'', ''overdue'', ''void'', ''written_off''
|
||||||
|
))', v_schema);
|
||||||
|
|
||||||
|
-- Convert existing 'sent' invoices to 'pending'
|
||||||
|
EXECUTE format('UPDATE %I.invoices SET status = ''pending'' WHERE status = ''sent''', v_schema);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
83
db/migrations/013-board-planning.sql
Normal file
83
db/migrations/013-board-planning.sql
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
-- Migration 013: Board Planning tables (scenarios, investments, assessments)
|
||||||
|
-- Applies to all existing tenant schemas
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tenant_schema TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR tenant_schema IN
|
||||||
|
SELECT schema_name FROM information_schema.schemata
|
||||||
|
WHERE schema_name LIKE 'tenant_%'
|
||||||
|
LOOP
|
||||||
|
-- Board Scenarios
|
||||||
|
EXECUTE format('
|
||||||
|
CREATE TABLE IF NOT EXISTS %I.board_scenarios (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN (''investment'', ''assessment'')),
|
||||||
|
status VARCHAR(20) DEFAULT ''draft'' CHECK (status IN (''draft'', ''active'', ''approved'', ''archived'')),
|
||||||
|
projection_months INTEGER DEFAULT 36,
|
||||||
|
projection_cache JSONB,
|
||||||
|
projection_cached_at TIMESTAMPTZ,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)', tenant_schema);
|
||||||
|
|
||||||
|
-- Scenario Investments
|
||||||
|
EXECUTE format('
|
||||||
|
CREATE TABLE IF NOT EXISTS %I.scenario_investments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
scenario_id UUID NOT NULL REFERENCES %I.board_scenarios(id) ON DELETE CASCADE,
|
||||||
|
source_recommendation_id UUID,
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
investment_type VARCHAR(50) CHECK (investment_type IN (''cd'', ''money_market'', ''treasury'', ''savings'', ''other'')),
|
||||||
|
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN (''operating'', ''reserve'')),
|
||||||
|
principal DECIMAL(15,2) NOT NULL,
|
||||||
|
interest_rate DECIMAL(6,4),
|
||||||
|
term_months INTEGER,
|
||||||
|
institution VARCHAR(255),
|
||||||
|
purchase_date DATE,
|
||||||
|
maturity_date DATE,
|
||||||
|
auto_renew BOOLEAN DEFAULT FALSE,
|
||||||
|
executed_investment_id UUID,
|
||||||
|
notes TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)', tenant_schema, tenant_schema);
|
||||||
|
|
||||||
|
-- Scenario Assessments
|
||||||
|
EXECUTE format('
|
||||||
|
CREATE TABLE IF NOT EXISTS %I.scenario_assessments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
scenario_id UUID NOT NULL REFERENCES %I.board_scenarios(id) ON DELETE CASCADE,
|
||||||
|
change_type VARCHAR(30) NOT NULL CHECK (change_type IN (''dues_increase'', ''special_assessment'', ''dues_decrease'')),
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
target_fund VARCHAR(20) CHECK (target_fund IN (''operating'', ''reserve'', ''both'')),
|
||||||
|
percentage_change DECIMAL(6,3),
|
||||||
|
flat_amount_change DECIMAL(10,2),
|
||||||
|
special_total DECIMAL(15,2),
|
||||||
|
special_per_unit DECIMAL(10,2),
|
||||||
|
special_installments INTEGER DEFAULT 1,
|
||||||
|
effective_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
applies_to_group_id UUID,
|
||||||
|
notes TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)', tenant_schema, tenant_schema);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bs_type_status ON %I.board_scenarios(scenario_type, status)',
|
||||||
|
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||||
|
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_si_scenario ON %I.scenario_investments(scenario_id)',
|
||||||
|
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||||
|
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_sa_scenario ON %I.scenario_assessments(scenario_id)',
|
||||||
|
replace(tenant_schema, '.', '_'), tenant_schema);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Board planning tables created for schema: %', tenant_schema;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
54
db/migrations/014-budget-planning.sql
Normal file
54
db/migrations/014-budget-planning.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
-- Migration: Add budget_plans and budget_plan_lines tables to all tenant schemas
|
||||||
|
DO $migration$
|
||||||
|
DECLARE
|
||||||
|
s TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR s IN
|
||||||
|
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'
|
||||||
|
LOOP
|
||||||
|
-- budget_plans
|
||||||
|
EXECUTE format('
|
||||||
|
CREATE TABLE IF NOT EXISTS %I.budget_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
fiscal_year INTEGER NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT ''planning'' CHECK (status IN (''planning'', ''approved'', ''ratified'')),
|
||||||
|
base_year INTEGER NOT NULL,
|
||||||
|
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
|
||||||
|
notes TEXT,
|
||||||
|
created_by UUID,
|
||||||
|
approved_by UUID,
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
ratified_by UUID,
|
||||||
|
ratified_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(fiscal_year)
|
||||||
|
)', s);
|
||||||
|
|
||||||
|
-- budget_plan_lines
|
||||||
|
EXECUTE format('
|
||||||
|
CREATE TABLE IF NOT EXISTS %I.budget_plan_lines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
budget_plan_id UUID NOT NULL REFERENCES %I.budget_plans(id) ON DELETE CASCADE,
|
||||||
|
account_id UUID NOT NULL REFERENCES %I.accounts(id),
|
||||||
|
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN (''operating'', ''reserve'')),
|
||||||
|
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
|
||||||
|
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
|
||||||
|
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
|
||||||
|
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
|
||||||
|
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
|
||||||
|
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
|
||||||
|
is_manually_adjusted BOOLEAN DEFAULT FALSE,
|
||||||
|
notes TEXT,
|
||||||
|
UNIQUE(budget_plan_id, account_id, fund_type)
|
||||||
|
)', s, s, s);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bp_year ON %I.budget_plans(fiscal_year)', replace(s, 'tenant_', ''), s);
|
||||||
|
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bp_status ON %I.budget_plans(status)', replace(s, 'tenant_', ''), s);
|
||||||
|
EXECUTE format('CREATE INDEX IF NOT EXISTS idx_%s_bpl_plan ON %I.budget_plan_lines(budget_plan_id)', replace(s, 'tenant_', ''), s);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Migrated schema: %', s;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$migration$;
|
||||||
107
db/migrations/015-saas-onboarding-auth.sql
Normal file
107
db/migrations/015-saas-onboarding-auth.sql
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
-- Migration 015: SaaS Onboarding + Auth (Stripe, Refresh Tokens, MFA, SSO, Passkeys)
|
||||||
|
-- Adds tables for refresh tokens, stripe event tracking, invite tokens,
|
||||||
|
-- onboarding progress, and WebAuthn passkeys.
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 1. Modify shared.organizations — add Stripe billing columns
|
||||||
|
-- ============================================================================
|
||||||
|
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) UNIQUE;
|
||||||
|
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255) UNIQUE;
|
||||||
|
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Update plan_level CHECK constraint to include new SaaS plan tiers
|
||||||
|
-- (Drop and re-add since ALTER CHECK is not supported in PG)
|
||||||
|
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'));
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 2. New table: shared.refresh_tokens
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.refresh_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,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON shared.refresh_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON shared.refresh_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON shared.refresh_tokens(expires_at);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 3. New table: shared.stripe_events (idempotency for webhook processing)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.stripe_events (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
type VARCHAR(100) NOT NULL,
|
||||||
|
processed_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
payload JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 4. New table: shared.invite_tokens (magic link activation)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.invite_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
|
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_invite_tokens_hash ON shared.invite_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_user ON shared.invite_tokens(user_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 5. New table: shared.onboarding_progress
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.onboarding_progress (
|
||||||
|
organization_id UUID PRIMARY KEY REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
|
completed_steps TEXT[] DEFAULT '{}',
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 6. New table: shared.user_passkeys (WebAuthn)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.user_passkeys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
|
credential_id TEXT UNIQUE NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
counter BIGINT DEFAULT 0,
|
||||||
|
device_name VARCHAR(255),
|
||||||
|
transports TEXT[],
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user ON shared.user_passkeys(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_passkeys_cred ON shared.user_passkeys(credential_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 7. Modify shared.users — add MFA/WebAuthn columns
|
||||||
|
-- ============================================================================
|
||||||
|
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS totp_verified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS recovery_codes TEXT;
|
||||||
|
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS webauthn_challenge TEXT;
|
||||||
|
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 8. Stubbed email log table (for development — replaces real email sends)
|
||||||
|
-- ============================================================================
|
||||||
|
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()
|
||||||
|
);
|
||||||
25
db/migrations/016-password-reset-tokens.sql
Normal file
25
db/migrations/016-password-reset-tokens.sql
Normal 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()
|
||||||
|
);
|
||||||
27
db/migrations/017-billing-enhancements.sql
Normal file
27
db/migrations/017-billing-enhancements.sql
Normal 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'));
|
||||||
15
db/migrations/018-ideas.sql
Normal file
15
db/migrations/018-ideas.sql
Normal 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);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user