Compare commits
87 Commits
a550a8d0be
...
claude/foc
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -12,3 +12,8 @@ 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
|
||||||
|
|
||||||
|
# New Relic APM — set ENABLED=true and provide your license key to activate
|
||||||
|
NEW_RELIC_ENABLED=false
|
||||||
|
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here
|
||||||
|
NEW_RELIC_APP_NAME=HOALedgerIQ_App
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,6 +24,11 @@ postgres_data/
|
|||||||
redis_data/
|
redis_data/
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
|
# Database backups
|
||||||
|
backups/
|
||||||
|
*.dump
|
||||||
|
*.dump.gz
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
letsencrypt/
|
letsencrypt/
|
||||||
|
|
||||||
|
|||||||
576
ONBOARDING-AND-AUTH.md
Normal file
576
ONBOARDING-AND-AUTH.md
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide
|
||||||
|
|
||||||
|
> **Version:** 2026.03.17
|
||||||
|
> **Last updated:** March 17, 2026
|
||||||
|
> **Migration:** `db/migrations/015-saas-onboarding-auth.sql`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [High-Level Flow](#1-high-level-flow)
|
||||||
|
2. [Stripe Billing & Checkout](#2-stripe-billing--checkout)
|
||||||
|
3. [Provisioning Pipeline](#3-provisioning-pipeline)
|
||||||
|
4. [Account Activation (Magic Link)](#4-account-activation-magic-link)
|
||||||
|
5. [Guided Onboarding Checklist](#5-guided-onboarding-checklist)
|
||||||
|
6. [Authentication & Sessions](#6-authentication--sessions)
|
||||||
|
7. [Multi-Factor Authentication (TOTP)](#7-multi-factor-authentication-totp)
|
||||||
|
8. [Single Sign-On (SSO)](#8-single-sign-on-sso)
|
||||||
|
9. [Passkeys (WebAuthn)](#9-passkeys-webauthn)
|
||||||
|
10. [Environment Variables Reference](#10-environment-variables-reference)
|
||||||
|
11. [Manual Intervention & Ops Tasks](#11-manual-intervention--ops-tasks)
|
||||||
|
12. [What's Stubbed vs. Production-Ready](#12-whats-stubbed-vs-production-ready)
|
||||||
|
13. [API Endpoint Reference](#13-api-endpoint-reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. High-Level Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Visitor hits /pricing
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Selects a plan (Starter $29 / Professional $79 / Enterprise $199)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
POST /api/billing/create-checkout-session
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Redirect to Stripe Checkout (hosted by Stripe)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Stripe fires `checkout.session.completed` webhook
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Backend provisions: org -> schema -> user -> invite token -> "email"
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Frontend polls GET /api/billing/status?session_id=xxx
|
||||||
|
| (OnboardingPendingPage polls every 3s)
|
||||||
|
v
|
||||||
|
Status returns "active" -> user is redirected to /login
|
||||||
|
|
|
||||||
|
v
|
||||||
|
User clicks activation link from "email" (logged to console + DB)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
GET /activate?token=xxx -> validates token
|
||||||
|
POST /activate -> sets password + name, issues session
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Redirect to /onboarding (4-step guided wizard)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stripe Billing & Checkout
|
||||||
|
|
||||||
|
### Plans
|
||||||
|
|
||||||
|
| Plan ID | Name | Price | Unit Limit |
|
||||||
|
|---------------|--------------|---------|------------|
|
||||||
|
| `starter` | Starter | $29/mo | 50 units |
|
||||||
|
| `professional` | Professional | $79/mo | 200 units |
|
||||||
|
| `enterprise` | Enterprise | $199/mo | Unlimited |
|
||||||
|
|
||||||
|
### Checkout Flow
|
||||||
|
|
||||||
|
1. **Frontend** (`PricingPage.tsx`): User enters email + business name, selects a plan.
|
||||||
|
2. **API call**: `POST /api/billing/create-checkout-session`
|
||||||
|
- Body: `{ planId, email?, businessName? }`
|
||||||
|
- Returns: `{ url }` (Stripe hosted checkout URL)
|
||||||
|
- No auth required.
|
||||||
|
3. **Redirect**: Frontend does `window.location.href = url` to send user to Stripe.
|
||||||
|
4. **On success**: Stripe redirects to `/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`.
|
||||||
|
5. **On cancel**: Stripe redirects back to `/pricing`.
|
||||||
|
|
||||||
|
### Webhook Events Handled
|
||||||
|
|
||||||
|
The webhook endpoint is `POST /api/webhooks/stripe`.
|
||||||
|
|
||||||
|
| Event | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| `checkout.session.completed` | Triggers full provisioning pipeline |
|
||||||
|
| `invoice.payment_succeeded` | Sets org status to `active` (handles reactivation after failed payment) |
|
||||||
|
| `invoice.payment_failed` | Sends payment-failed "email" (stubbed) |
|
||||||
|
| `customer.subscription.deleted` | Sets org status to `archived` |
|
||||||
|
|
||||||
|
All webhook events are deduplicated via the `shared.stripe_events` table (idempotency by Stripe event ID).
|
||||||
|
|
||||||
|
### Stripe Customer Portal
|
||||||
|
|
||||||
|
`POST /api/billing/portal` (auth required) -- creates a Stripe Customer Portal session for managing subscription/payment methods. **Note: currently throws "not implemented" -- needs org-context customer ID lookup.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Provisioning Pipeline
|
||||||
|
|
||||||
|
When `checkout.session.completed` fires, the backend runs **inline provisioning** (no background queue):
|
||||||
|
|
||||||
|
1. **Create organization** in `shared.organizations` with:
|
||||||
|
- `name` = business name from checkout metadata
|
||||||
|
- `schema_name` = `tenant_{random_12_chars}`
|
||||||
|
- `status` = `active`
|
||||||
|
- `plan_level` = selected plan
|
||||||
|
- `stripe_customer_id` + `stripe_subscription_id`
|
||||||
|
- Uses `ON CONFLICT (stripe_customer_id)` for idempotency
|
||||||
|
|
||||||
|
2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`:
|
||||||
|
- Runs the full tenant DDL (accounts, journal entries, etc.)
|
||||||
|
- Skips if schema already exists
|
||||||
|
|
||||||
|
3. **Create or find user** in `shared.users` by email:
|
||||||
|
- New users are created with `is_email_verified = false` and no password
|
||||||
|
- Existing users are reused (linked to new org)
|
||||||
|
|
||||||
|
4. **Create membership** in `shared.user_organizations`:
|
||||||
|
- Role: `president`
|
||||||
|
- Idempotent via `ON CONFLICT DO NOTHING`
|
||||||
|
|
||||||
|
5. **Generate invite token** (JWT signed with `INVITE_TOKEN_SECRET`, 72-hour expiry):
|
||||||
|
- SHA-256 hash stored in `shared.invite_tokens`
|
||||||
|
- Raw token used in activation URL
|
||||||
|
|
||||||
|
6. **Send activation "email"** (stubbed -- see section 12):
|
||||||
|
- Logged to console and `shared.email_log` table
|
||||||
|
- Contains activation URL: `{APP_URL}/activate?token={jwt}`
|
||||||
|
|
||||||
|
7. **Initialize onboarding** progress row in `shared.onboarding_progress`
|
||||||
|
|
||||||
|
### Provisioning Status Polling
|
||||||
|
|
||||||
|
`GET /api/billing/status?session_id=xxx` (no auth required)
|
||||||
|
|
||||||
|
Returns: `{ status }` where status is one of:
|
||||||
|
- `not_configured` -- Stripe not set up
|
||||||
|
- `pending` -- no customer ID yet
|
||||||
|
- `provisioning` -- org exists but not active yet
|
||||||
|
- `active` -- ready to go
|
||||||
|
|
||||||
|
The `OnboardingPendingPage` polls this every 3 seconds and redirects to `/login` once active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Account Activation (Magic Link)
|
||||||
|
|
||||||
|
### Validate Token
|
||||||
|
|
||||||
|
`GET /api/auth/activate?token=xxx` (no auth required)
|
||||||
|
|
||||||
|
- Verifies JWT signature (using `INVITE_TOKEN_SECRET`)
|
||||||
|
- Checks `shared.invite_tokens` for existence, expiration, and prior use
|
||||||
|
- Returns: `{ valid, email, orgName, orgId, userId }`
|
||||||
|
|
||||||
|
### Activate Account
|
||||||
|
|
||||||
|
`POST /api/auth/activate` (no auth required)
|
||||||
|
|
||||||
|
- Body: `{ token, password, fullName }`
|
||||||
|
- Password must be >= 8 characters
|
||||||
|
- Sets `password_hash`, `first_name`, `last_name`, `is_email_verified = true`
|
||||||
|
- Marks invite token as used (`used_at = NOW()`)
|
||||||
|
- Issues full session (access token + refresh token cookie)
|
||||||
|
- Frontend redirects to `/onboarding`
|
||||||
|
|
||||||
|
### Frontend (ActivatePage.tsx)
|
||||||
|
|
||||||
|
- Validates token on mount
|
||||||
|
- Shows password setup form with strength indicator (color-coded bar)
|
||||||
|
- On success: stores auth in Zustand and navigates to `/onboarding`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Guided Onboarding Checklist
|
||||||
|
|
||||||
|
### Required Steps
|
||||||
|
|
||||||
|
| Step Key | UI Label | Description |
|
||||||
|
|-----------------|----------------|-------------|
|
||||||
|
| `profile` | Profile | Set up user profile |
|
||||||
|
| `workspace` | Workspace | Configure organization settings |
|
||||||
|
| `invite_member` | Invite Member | Invite at least one team member |
|
||||||
|
| `first_workflow` | First Account | Create the first chart-of-accounts entry |
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- `GET /api/onboarding/progress` (auth required): Returns `{ completedSteps[], completedAt, requiredSteps[] }`
|
||||||
|
- `PATCH /api/onboarding/progress` (auth required): Body `{ step }` -- marks a step complete
|
||||||
|
|
||||||
|
Steps are stored as a PostgreSQL text array. When all 4 required steps are complete, `completed_at` is set. Users can skip onboarding via a "Finish Later" button (navigates to dashboard).
|
||||||
|
|
||||||
|
### Frontend (OnboardingPage.tsx)
|
||||||
|
|
||||||
|
- Mantine Stepper with 4 steps
|
||||||
|
- Each step calls `PATCH /onboarding/progress` on completion
|
||||||
|
- Celebration screen shown when all steps are done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Authentication & Sessions
|
||||||
|
|
||||||
|
### Token Architecture
|
||||||
|
|
||||||
|
| Token | Type | Lifetime | Storage |
|
||||||
|
|-------|------|----------|---------|
|
||||||
|
| Access token | JWT | 1 hour | Frontend Zustand store (memory/localStorage) |
|
||||||
|
| Refresh token | Opaque (base64url, 64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) |
|
||||||
|
| MFA challenge | JWT | 5 minutes | Frontend state (in-memory only) |
|
||||||
|
| Invite/activation | JWT | 72 hours | URL query parameter |
|
||||||
|
|
||||||
|
### Refresh Token Flow
|
||||||
|
|
||||||
|
1. Access token expires (401 from any API call)
|
||||||
|
2. Axios interceptor catches 401, calls `POST /api/auth/refresh`
|
||||||
|
3. Refresh token is sent automatically via httpOnly cookie
|
||||||
|
4. Server validates token hash in `shared.refresh_tokens` table
|
||||||
|
5. New access token issued (refresh token is NOT rotated)
|
||||||
|
6. Original failed request is replayed with new token
|
||||||
|
7. Concurrent requests are queued during refresh (no thundering herd)
|
||||||
|
|
||||||
|
### Cookie Configuration
|
||||||
|
|
||||||
|
- Name: `ledgeriq_rt`
|
||||||
|
- Path: `/api/auth`
|
||||||
|
- httpOnly: `true`
|
||||||
|
- Secure: `true` in production, `false` in dev
|
||||||
|
- SameSite: `strict`
|
||||||
|
- Max-Age: 30 days
|
||||||
|
|
||||||
|
### Session Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `POST` | `/api/auth/login` | No | Email + password login |
|
||||||
|
| `POST` | `/api/auth/register` | No | Create account |
|
||||||
|
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
|
||||||
|
| `POST` | `/api/auth/logout` | Cookie | Revoke current refresh token |
|
||||||
|
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all user sessions |
|
||||||
|
| `POST` | `/api/auth/switch-org` | JWT | Switch org context (new tokens) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Multi-Factor Authentication (TOTP)
|
||||||
|
|
||||||
|
### Setup Flow
|
||||||
|
|
||||||
|
1. User goes to Settings > Security > Two-Factor Auth tab
|
||||||
|
2. `POST /api/auth/mfa/setup` -- returns `{ qrCodeDataUrl, secret, uri }`
|
||||||
|
3. User scans QR code in authenticator app (Google Authenticator, Authy, etc.)
|
||||||
|
4. User enters 6-digit code to confirm
|
||||||
|
5. `POST /api/auth/mfa/enable` with `{ token }` -- returns `{ recoveryCodes[] }`
|
||||||
|
6. **User must save their 10 recovery codes** (displayed once, bcrypt-hashed in DB)
|
||||||
|
|
||||||
|
### Login with MFA
|
||||||
|
|
||||||
|
1. `POST /api/auth/login` returns `{ mfaRequired: true, mfaToken }` instead of session
|
||||||
|
2. Frontend shows 6-digit PIN input (or recovery code input)
|
||||||
|
3. `POST /api/auth/mfa/verify` with `{ mfaToken, token, useRecovery? }`
|
||||||
|
4. On success: full session issued (access token + refresh cookie)
|
||||||
|
|
||||||
|
### Recovery Codes
|
||||||
|
|
||||||
|
- 10 codes generated on MFA enable
|
||||||
|
- Each code is single-use (removed from array after verification)
|
||||||
|
- Codes are bcrypt-hashed in `shared.users.recovery_codes` (JSON array)
|
||||||
|
|
||||||
|
### MFA Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR + secret |
|
||||||
|
| `POST` | `/api/auth/mfa/enable` | JWT | Verify code and enable MFA |
|
||||||
|
| `POST` | `/api/auth/mfa/verify` | No (uses mfaToken) | Verify during login |
|
||||||
|
| `POST` | `/api/auth/mfa/disable` | JWT | Disable MFA (requires password) |
|
||||||
|
| `GET` | `/api/auth/mfa/status` | JWT | Check if MFA is enabled |
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- Library: `otplib` v4 (`generateSecret`, `generateURI`, `verifySync`)
|
||||||
|
- QR codes: `qrcode` package (data URL output)
|
||||||
|
- Recovery codes: `crypto.randomBytes` + `bcryptjs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Single Sign-On (SSO)
|
||||||
|
|
||||||
|
### Supported Providers
|
||||||
|
|
||||||
|
| Provider | Library | Env Vars Required |
|
||||||
|
|----------|---------|-------------------|
|
||||||
|
| Google | `passport-google-oauth20` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` |
|
||||||
|
| Microsoft/Azure AD | `passport-azure-ad` | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_CALLBACK_URL` |
|
||||||
|
|
||||||
|
SSO providers are **conditionally loaded** -- they only appear on the login page if their env vars are configured. The `GET /api/auth/sso/providers` endpoint returns `{ google: boolean, azure: boolean }`.
|
||||||
|
|
||||||
|
### SSO Login Flow
|
||||||
|
|
||||||
|
1. Frontend redirects to `/api/auth/google` or `/api/auth/azure`
|
||||||
|
2. Passport handles OAuth redirect to provider
|
||||||
|
3. Provider redirects back to `/api/auth/{provider}/callback`
|
||||||
|
4. Backend creates or links user via `SsoService.findOrCreateSsoUser()`
|
||||||
|
5. Session tokens issued, redirect to `/sso-callback?token={accessToken}`
|
||||||
|
|
||||||
|
### Account Linking
|
||||||
|
|
||||||
|
- SSO fields stored on `shared.users`: `sso_provider`, `sso_id`
|
||||||
|
- If email matches existing user, SSO is auto-linked on first login
|
||||||
|
- Users can unlink: `DELETE /api/auth/sso/unlink/:provider`
|
||||||
|
|
||||||
|
### SSO Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `GET` | `/api/auth/sso/providers` | No | List configured providers |
|
||||||
|
| `GET` | `/api/auth/google/callback` | No (OAuth) | Google callback |
|
||||||
|
| `GET` | `/api/auth/azure/callback` | No (OAuth) | Azure callback |
|
||||||
|
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO provider |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Passkeys (WebAuthn)
|
||||||
|
|
||||||
|
### Registration Flow (authenticated user)
|
||||||
|
|
||||||
|
1. `POST /api/auth/passkeys/register-options` -- returns WebAuthn creation options
|
||||||
|
2. Browser `navigator.credentials.create()` via `@simplewebauthn/browser`
|
||||||
|
3. `POST /api/auth/passkeys/register` with `{ response, deviceName? }`
|
||||||
|
4. Credential stored in `shared.user_passkeys`
|
||||||
|
|
||||||
|
### Login Flow (unauthenticated)
|
||||||
|
|
||||||
|
1. `POST /api/auth/passkeys/login-options` with `{ email? }` -- returns assertion options
|
||||||
|
2. Browser `navigator.credentials.get()` via `@simplewebauthn/browser`
|
||||||
|
3. `POST /api/auth/passkeys/login` with `{ response, challenge }`
|
||||||
|
4. Full session issued on success
|
||||||
|
|
||||||
|
### Passkey Endpoints
|
||||||
|
|
||||||
|
| 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 registered passkeys |
|
||||||
|
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- `WEBAUTHN_RP_ID` -- Relying party ID (e.g., `localhost` for dev, `yourdomain.com` for prod)
|
||||||
|
- `WEBAUTHN_RP_ORIGIN` -- Expected origin (e.g., `http://localhost` or `https://yourdomain.com`)
|
||||||
|
- Passkey removal is blocked if the user has no password and no other passkeys (prevents lockout)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Environment Variables Reference
|
||||||
|
|
||||||
|
### Stripe (Required for billing)
|
||||||
|
|
||||||
|
| Variable | Example | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `STRIPE_SECRET_KEY` | `sk_test_...` | Stripe secret key. Must NOT contain "placeholder" to activate. |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Webhook endpoint signing secret |
|
||||||
|
| `STRIPE_STARTER_PRICE_ID` | `price_...` | Stripe Price ID for Starter plan |
|
||||||
|
| `STRIPE_PROFESSIONAL_PRICE_ID` | `price_...` | Stripe Price ID for Professional plan |
|
||||||
|
| `STRIPE_ENTERPRISE_PRICE_ID` | `price_...` | Stripe Price ID for Enterprise plan |
|
||||||
|
|
||||||
|
### SSO (Optional -- features hidden when not set)
|
||||||
|
|
||||||
|
| Variable | Example | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `GOOGLE_CLIENT_ID` | `xxx.apps.googleusercontent.com` | Google OAuth client ID |
|
||||||
|
| `GOOGLE_CLIENT_SECRET` | `GOCSPX-...` | Google OAuth client secret |
|
||||||
|
| `GOOGLE_CALLBACK_URL` | `http://localhost/api/auth/google/callback` | OAuth redirect URI |
|
||||||
|
| `AZURE_CLIENT_ID` | `uuid` | Azure AD application (client) ID |
|
||||||
|
| `AZURE_CLIENT_SECRET` | `...` | Azure AD client secret |
|
||||||
|
| `AZURE_TENANT_ID` | `uuid` | Azure AD tenant (directory) ID |
|
||||||
|
| `AZURE_CALLBACK_URL` | `http://localhost/api/auth/azure/callback` | OAuth redirect URI |
|
||||||
|
|
||||||
|
### WebAuthn / Passkeys
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `WEBAUTHN_RP_ID` | `localhost` | Relying party identifier (your domain) |
|
||||||
|
| `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin |
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for signing invite/activation JWTs. **Change in production.** |
|
||||||
|
| `APP_URL` | `http://localhost` | Base URL for generated links (activation emails, Stripe redirects) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Manual Intervention & Ops Tasks
|
||||||
|
|
||||||
|
### Before Going Live
|
||||||
|
|
||||||
|
1. **Set up Stripe products and prices** in the Stripe Dashboard:
|
||||||
|
- Create 3 products (Starter, Professional, Enterprise)
|
||||||
|
- Create monthly recurring prices for each
|
||||||
|
- Copy the Price IDs into `STRIPE_STARTER_PRICE_ID`, etc.
|
||||||
|
|
||||||
|
2. **Configure the Stripe webhook** in the Stripe Dashboard:
|
||||||
|
- Endpoint URL: `https://yourdomain.com/api/webhooks/stripe`
|
||||||
|
- Events to subscribe: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`
|
||||||
|
- Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`
|
||||||
|
|
||||||
|
3. **Replace the email stub** with a real provider:
|
||||||
|
- `backend/src/modules/email/email.service.ts` currently logs to console + DB
|
||||||
|
- Swap in Resend, SendGrid, SES, or your preferred provider
|
||||||
|
- The four email methods to implement: `sendActivationEmail`, `sendWelcomeEmail`, `sendPaymentFailedEmail`, `sendInviteMemberEmail`
|
||||||
|
|
||||||
|
4. **Set production secrets**:
|
||||||
|
- `INVITE_TOKEN_SECRET` -- use a strong random string (not `dev-invite-secret`)
|
||||||
|
- `JWT_SECRET` -- already required, verify it's strong
|
||||||
|
- `WEBAUTHN_RP_ID` and `WEBAUTHN_RP_ORIGIN` -- set to your production domain
|
||||||
|
|
||||||
|
5. **Configure SSO providers** (if desired):
|
||||||
|
- Register apps in Google Cloud Console and/or Azure AD
|
||||||
|
- Set the callback URLs to your production domain
|
||||||
|
- Add client IDs and secrets to env vars
|
||||||
|
|
||||||
|
6. **Set up the Stripe Customer Portal** in Stripe Dashboard:
|
||||||
|
- Configure allowed actions (cancel, upgrade/downgrade, payment method updates)
|
||||||
|
- The `/api/billing/portal` endpoint needs the org-context customer ID lookup completed
|
||||||
|
|
||||||
|
### Ongoing Ops
|
||||||
|
|
||||||
|
7. **Refresh token cleanup**: The `RefreshTokenService.cleanupExpired()` method deletes tokens that have been expired or revoked for 7+ days. **This is not currently called on a schedule.** Options:
|
||||||
|
- Add a cron job / scheduled task that calls it periodically
|
||||||
|
- Or add a NestJS `@Cron()` decorator (requires `@nestjs/schedule`)
|
||||||
|
|
||||||
|
8. **Monitor `shared.email_log`**: While email is stubbed, activation URLs are only visible in:
|
||||||
|
- Backend console logs (look for lines starting with `EMAIL STUB`)
|
||||||
|
- The `shared.email_log` table (query: `SELECT * FROM shared.email_log ORDER BY sent_at DESC`)
|
||||||
|
|
||||||
|
9. **Finding activation URLs manually** (dev/testing):
|
||||||
|
```sql
|
||||||
|
SELECT to_email, metadata->>'activationUrl' AS url, sent_at
|
||||||
|
FROM shared.email_log
|
||||||
|
WHERE template = 'activation'
|
||||||
|
ORDER BY sent_at DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Resend an activation email**: `POST /api/auth/resend-activation` with `{ email }` is stubbed (always returns success). To manually generate a new token:
|
||||||
|
```sql
|
||||||
|
-- Find the user and org
|
||||||
|
SELECT u.id AS user_id, uo.organization_id
|
||||||
|
FROM shared.users u
|
||||||
|
JOIN shared.user_organizations uo ON uo.user_id = u.id
|
||||||
|
WHERE u.email = 'user@example.com';
|
||||||
|
```
|
||||||
|
Then call `authService.generateInviteToken(userId, orgId, email)` or trigger a fresh checkout.
|
||||||
|
|
||||||
|
11. **Deprovisioning / cancellation**: When a Stripe subscription is deleted, the org is set to `archived`. Archived orgs:
|
||||||
|
- Block login (users see "Your organization has been suspended")
|
||||||
|
- Block API access (403 on org-scoped endpoints)
|
||||||
|
- Data is preserved (schema is NOT deleted)
|
||||||
|
- To restore: update `status` back to `active` in `shared.organizations`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. What's Stubbed vs. Production-Ready
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Stripe Checkout | **Ready** (test mode) | Switch to live keys for production |
|
||||||
|
| Stripe Webhooks | **Ready** | Signature verification, idempotency, event dispatch all implemented |
|
||||||
|
| Stripe Customer Portal | **Stubbed** | Endpoint exists but needs org-context customer ID lookup |
|
||||||
|
| Provisioning (org + schema + user) | **Ready** | Inline (synchronous). Consider BullMQ queue for production scale. |
|
||||||
|
| Email service | **Stubbed** | Logs to console + `shared.email_log`. Replace with real SMTP/API provider. |
|
||||||
|
| Activation (magic link) | **Ready** | Works end-to-end (token generation, validation, password set, session issue) |
|
||||||
|
| Onboarding checklist | **Ready** | Server-side progress tracking, step completion, UI wizard |
|
||||||
|
| Refresh tokens | **Ready** | Creation, validation, revocation, cleanup method (needs scheduling) |
|
||||||
|
| TOTP MFA | **Ready** | Setup, enable, verify, recovery codes, disable |
|
||||||
|
| SSO (Google) | **Ready** (needs keys) | Conditional loading, user creation/linking |
|
||||||
|
| SSO (Azure AD) | **Ready** (needs keys) | Uses deprecated `passport-azure-ad` (works, consider `@azure/msal-node`) |
|
||||||
|
| Passkeys (WebAuthn) | **Ready** | Registration, authentication, removal with lockout protection |
|
||||||
|
| Resend activation | **Stubbed** | Always returns success, no actual email sent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. API Endpoint Reference
|
||||||
|
|
||||||
|
### Billing (no auth unless noted)
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `POST` | `/api/billing/create-checkout-session` | No | Create Stripe Checkout, returns `{ url }` |
|
||||||
|
| `POST` | `/api/webhooks/stripe` | Stripe sig | Webhook receiver |
|
||||||
|
| `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status |
|
||||||
|
| `POST` | `/api/billing/portal` | JWT | Stripe Customer Portal (stubbed) |
|
||||||
|
|
||||||
|
### 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 (stubbed) |
|
||||||
|
| `GET` | `/api/auth/profile` | JWT | Get user profile |
|
||||||
|
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
|
||||||
|
|
||||||
|
### MFA
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR code + secret |
|
||||||
|
| `POST` | `/api/auth/mfa/enable` | JWT | Enable MFA with TOTP code |
|
||||||
|
| `POST` | `/api/auth/mfa/verify` | mfaToken | Verify during login |
|
||||||
|
| `POST` | `/api/auth/mfa/disable` | JWT | Disable (requires password) |
|
||||||
|
| `GET` | `/api/auth/mfa/status` | JWT | Check MFA status |
|
||||||
|
|
||||||
|
### SSO
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `GET` | `/api/auth/sso/providers` | No | List available providers |
|
||||||
|
| `GET` | `/api/auth/google/callback` | OAuth | Google callback handler |
|
||||||
|
| `GET` | `/api/auth/azure/callback` | OAuth | Azure callback handler |
|
||||||
|
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO account |
|
||||||
|
|
||||||
|
### Passkeys
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `POST` | `/api/auth/passkeys/register-options` | JWT | Get registration options |
|
||||||
|
| `POST` | `/api/auth/passkeys/register` | JWT | Complete registration |
|
||||||
|
| `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options |
|
||||||
|
| `POST` | `/api/auth/passkeys/login` | No | Authenticate with passkey |
|
||||||
|
| `GET` | `/api/auth/passkeys` | JWT | List user's passkeys |
|
||||||
|
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
|
||||||
|
|
||||||
|
### Onboarding
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress |
|
||||||
|
| `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Tables Added (Migration 015)
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `shared.refresh_tokens` | Stores SHA-256 hashed refresh tokens with expiry/revocation |
|
||||||
|
| `shared.stripe_events` | Idempotency ledger for Stripe webhook events |
|
||||||
|
| `shared.invite_tokens` | Tracks activation/invite magic links |
|
||||||
|
| `shared.onboarding_progress` | Per-org onboarding step completion |
|
||||||
|
| `shared.user_passkeys` | WebAuthn credential storage |
|
||||||
|
| `shared.email_log` | Stubbed email audit trail |
|
||||||
|
|
||||||
|
Columns added to existing tables:
|
||||||
|
- `shared.organizations`: `stripe_customer_id`, `stripe_subscription_id`, `trial_ends_at`
|
||||||
|
- `shared.users`: `totp_verified_at`, `recovery_codes`, `webauthn_challenge`
|
||||||
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.17",
|
||||||
"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,13 @@
|
|||||||
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 { 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 +29,10 @@ 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 { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -43,8 +49,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 +84,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
AttachmentsModule,
|
AttachmentsModule,
|
||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
HealthScoresModule,
|
HealthScoresModule,
|
||||||
|
BoardPlanningModule,
|
||||||
|
BillingModule,
|
||||||
|
EmailModule,
|
||||||
|
OnboardingModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
@@ -75,6 +96,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: WriteAccessGuard,
|
useClass: WriteAccessGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: NoCacheInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
|
|||||||
|
|
||||||
@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 +30,25 @@ 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;
|
||||||
}
|
}
|
||||||
|
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 +57,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();
|
|
||||||
|
|||||||
@@ -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()');
|
||||||
|
|||||||
@@ -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,8 +4,15 @@ 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';
|
||||||
@@ -21,12 +28,27 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
|||||||
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,37 @@ 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';
|
||||||
|
|
||||||
@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 +88,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 +119,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,15 +153,18 @@ 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);
|
||||||
|
|
||||||
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,
|
||||||
@@ -136,10 +174,244 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 +429,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 +449,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,11 +466,11 @@ 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) => ({
|
||||||
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,
|
||||||
})),
|
})),
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
63
backend/src/modules/billing/billing.controller.ts
Normal file
63
backend/src/modules/billing/billing.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
RawBodyRequest,
|
||||||
|
BadRequestException,
|
||||||
|
Request,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { Request as ExpressRequest } from 'express';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('billing')
|
||||||
|
@Controller()
|
||||||
|
export class BillingController {
|
||||||
|
constructor(private billingService: BillingService) {}
|
||||||
|
|
||||||
|
@Post('billing/create-checkout-session')
|
||||||
|
@ApiOperation({ summary: 'Create a Stripe Checkout Session' })
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
|
async createCheckout(
|
||||||
|
@Body() body: { planId: string; email?: string; businessName?: string },
|
||||||
|
) {
|
||||||
|
if (!body.planId) throw new BadRequestException('planId is required');
|
||||||
|
return this.billingService.createCheckoutSession(body.planId, 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' })
|
||||||
|
async getStatus(@Query('session_id') sessionId: string) {
|
||||||
|
if (!sessionId) throw new BadRequestException('session_id required');
|
||||||
|
return this.billingService.getProvisioningStatus(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('billing/portal')
|
||||||
|
@ApiOperation({ summary: 'Create Stripe Customer Portal session' })
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async createPortal(@Request() req: any) {
|
||||||
|
// Lookup the org's stripe_customer_id
|
||||||
|
// Only allow president or superadmin
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
if (!orgId) throw new BadRequestException('No organization context');
|
||||||
|
// For now, we'd look this up from the org
|
||||||
|
throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context');
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
294
backend/src/modules/billing/billing.service.ts
Normal file
294
backend/src/modules/billing/billing.service.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingService {
|
||||||
|
private readonly logger = new Logger(BillingService.name);
|
||||||
|
private stripe: Stripe | null = null;
|
||||||
|
private webhookSecret: string;
|
||||||
|
private priceMap: Record<string, string>;
|
||||||
|
|
||||||
|
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.priceMap = {
|
||||||
|
starter: this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
|
||||||
|
professional: this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
|
||||||
|
enterprise: this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Checkout Session for a new subscription.
|
||||||
|
*/
|
||||||
|
async createCheckoutSession(planId: string, email?: string, businessName?: string): Promise<{ url: string }> {
|
||||||
|
if (!this.stripe) {
|
||||||
|
throw new BadRequestException('Stripe not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceId = this.priceMap[planId];
|
||||||
|
if (!priceId || priceId.includes('placeholder')) {
|
||||||
|
throw new BadRequestException(`Invalid plan: ${planId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.stripe.checkout.sessions.create({
|
||||||
|
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 || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url: session.url! };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
default:
|
||||||
|
this.logger.log(`Unhandled Stripe event: ${event.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provisioning status for a checkout session.
|
||||||
|
*/
|
||||||
|
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
|
||||||
|
if (!this.stripe) return { status: 'not_configured' };
|
||||||
|
|
||||||
|
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
|
||||||
|
const customerId = session.customer as string;
|
||||||
|
|
||||||
|
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 (rows[0].status === 'active') return { status: 'active' };
|
||||||
|
return { status: 'provisioning' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Customer Portal session.
|
||||||
|
*/
|
||||||
|
async createPortalSession(customerId: string): Promise<{ url: string }> {
|
||||||
|
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
||||||
|
|
||||||
|
const session = await this.stripe.billingPortal.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
return_url: `${this.getAppUrl()}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url: session.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provisioning (inline, no BullMQ for now — add queue later) ─────
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName);
|
||||||
|
} 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
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.organizations SET status = 'active', updated_at = NOW()
|
||||||
|
WHERE stripe_customer_id = $1 AND status != 'active'`,
|
||||||
|
[customerId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
||||||
|
const customerId = invoice.customer as string;
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`,
|
||||||
|
[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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full provisioning flow: create org, schema, user, invite token, email.
|
||||||
|
*/
|
||||||
|
async provisionOrganization(
|
||||||
|
customerId: string,
|
||||||
|
subscriptionId: string,
|
||||||
|
email: string,
|
||||||
|
planId: string,
|
||||||
|
businessName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// 1. Create or upsert organization
|
||||||
|
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
|
||||||
|
|
||||||
|
const orgRows = await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email)
|
||||||
|
VALUES ($1, $2, 'active', $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (stripe_customer_id) DO UPDATE SET
|
||||||
|
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
|
||||||
|
plan_level = EXCLUDED.plan_level,
|
||||||
|
status = 'active',
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, schema_name`,
|
||||||
|
[businessName, schemaName, planId, customerId, subscriptionId, email],
|
||||||
|
);
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAppUrl(): string {
|
||||||
|
return this.configService.get<string>('APP_URL') || 'http://localhost';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
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. */
|
||||||
|
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];
|
||||||
|
|
||||||
|
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 ──
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
// Auto-renew: immediately reinvest
|
||||||
|
if (inv.auto_renew) {
|
||||||
|
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
||||||
|
else { resCashFlow -= principal; resInvChange += principal; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
200
backend/src/modules/board-planning/board-planning.controller.ts
Normal file
200
backend/src/modules/board-planning/board-planning.controller.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
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 { 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()
|
||||||
|
listScenarios(@Query('type') type?: string) {
|
||||||
|
return this.service.listScenarios(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('scenarios/:id')
|
||||||
|
@AllowViewer()
|
||||||
|
getScenario(@Param('id') id: string) {
|
||||||
|
return this.service.getScenario(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios')
|
||||||
|
createScenario(@Body() dto: any, @Req() req: any) {
|
||||||
|
return this.service.createScenario(dto, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('scenarios/:id')
|
||||||
|
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
||||||
|
return this.service.updateScenario(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('scenarios/:id')
|
||||||
|
deleteScenario(@Param('id') id: string) {
|
||||||
|
return this.service.deleteScenario(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario Investments ──
|
||||||
|
|
||||||
|
@Get('scenarios/:scenarioId/investments')
|
||||||
|
@AllowViewer()
|
||||||
|
listInvestments(@Param('scenarioId') scenarioId: string) {
|
||||||
|
return this.service.listInvestments(scenarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios/:scenarioId/investments')
|
||||||
|
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
|
return this.service.addInvestment(scenarioId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
||||||
|
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
|
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('investments/:id')
|
||||||
|
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
||||||
|
return this.service.updateInvestment(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('investments/:id')
|
||||||
|
removeInvestment(@Param('id') id: string) {
|
||||||
|
return this.service.removeInvestment(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario Assessments ──
|
||||||
|
|
||||||
|
@Get('scenarios/:scenarioId/assessments')
|
||||||
|
@AllowViewer()
|
||||||
|
listAssessments(@Param('scenarioId') scenarioId: string) {
|
||||||
|
return this.service.listAssessments(scenarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios/:scenarioId/assessments')
|
||||||
|
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
|
return this.service.addAssessment(scenarioId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('assessments/:id')
|
||||||
|
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
||||||
|
return this.service.updateAssessment(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('assessments/:id')
|
||||||
|
removeAssessment(@Param('id') id: string) {
|
||||||
|
return this.service.removeAssessment(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projections ──
|
||||||
|
|
||||||
|
@Get('scenarios/:id/projection')
|
||||||
|
@AllowViewer()
|
||||||
|
getProjection(@Param('id') id: string) {
|
||||||
|
return this.projection.getProjection(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('scenarios/:id/projection/refresh')
|
||||||
|
refreshProjection(@Param('id') id: string) {
|
||||||
|
return this.projection.computeProjection(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comparison ──
|
||||||
|
|
||||||
|
@Get('compare')
|
||||||
|
@AllowViewer()
|
||||||
|
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')
|
||||||
|
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()
|
||||||
|
listBudgetPlans() {
|
||||||
|
return this.budgetPlanning.listPlans();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('budget-plans/available-years')
|
||||||
|
@AllowViewer()
|
||||||
|
getAvailableYears() {
|
||||||
|
return this.budgetPlanning.getAvailableYears();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('budget-plans/:year')
|
||||||
|
@AllowViewer()
|
||||||
|
getBudgetPlan(@Param('year') year: string) {
|
||||||
|
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('budget-plans')
|
||||||
|
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')
|
||||||
|
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
||||||
|
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('budget-plans/:year/inflation')
|
||||||
|
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
||||||
|
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('budget-plans/:year/status')
|
||||||
|
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')
|
||||||
|
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')
|
||||||
|
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')
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
289
backend/src/modules/email/email.service.ts
Normal file
289
backend/src/modules/email/email.service.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
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 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.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +252,7 @@ export class HealthScoresService {
|
|||||||
private async gatherOperatingData(qr: any) {
|
private 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,
|
||||||
@@ -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,9 +625,13 @@ 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
|
// Components needing replacement within 5 years — use whichever source has data
|
||||||
const urgentComponents = reserveComponents.filter(
|
const urgentComponents = useComponentsTable
|
||||||
|
? reserveComponents.filter(
|
||||||
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
||||||
|
)
|
||||||
|
: reserveProjects.filter(
|
||||||
|
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Build 12-month forward reserve cash flow projection ──
|
// ── Build 12-month forward reserve cash flow projection ──
|
||||||
@@ -674,6 +763,7 @@ export class HealthScoresService {
|
|||||||
accounts,
|
accounts,
|
||||||
investments,
|
investments,
|
||||||
reserveComponents,
|
reserveComponents,
|
||||||
|
reserveProjects,
|
||||||
projects,
|
projects,
|
||||||
budgets,
|
budgets,
|
||||||
assessments,
|
assessments,
|
||||||
@@ -715,6 +805,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 +840,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 +877,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 ===
|
||||||
@@ -852,13 +974,15 @@ 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.'
|
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
|
||||||
: data.reserveComponents.map((c: any) => {
|
const componentLines = componentSource.length === 0
|
||||||
const cost = parseFloat(c.replacement_cost || '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)}`;
|
return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const projectLines = data.projects.length === 0
|
const projectLines = data.projects.length === 0
|
||||||
@@ -874,7 +998,7 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
const urgentLines = data.urgentComponents.length === 0
|
const urgentLines = data.urgentComponents.length === 0
|
||||||
? 'None — no components due within 5 years.'
|
? 'None — no components due within 5 years.'
|
||||||
: data.urgentComponents.map((c: any) => {
|
: data.urgentComponents.map((c: any) => {
|
||||||
const cost = parseFloat(c.replacement_cost || '0');
|
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 gap = cost - funded;
|
const gap = cost - funded;
|
||||||
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
|
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
|
||||||
@@ -890,8 +1014,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)}
|
||||||
@@ -918,11 +1042,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 +1106,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 +1132,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 +1146,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);
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export class InvestmentPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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) {
|
triggerRecommendations(@Req() req: any) {
|
||||||
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
@@ -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
|
||||||
@@ -873,7 +1118,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 +1132,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);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export class InvoicesController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
||||||
|
|
||||||
|
@Post('generate-preview')
|
||||||
|
generatePreview(@Body() dto: { month: number; year: number }) {
|
||||||
|
return this.invoicesService.generatePreview(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('generate-bulk')
|
@Post('generate-bulk')
|
||||||
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);
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,6 +153,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);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
@@ -18,4 +18,12 @@ export class PaymentsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
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')
|
||||||
|
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
||||||
|
return this.paymentsService.update(id, dto, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,17 +74,95 @@ export class PaymentsService {
|
|||||||
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
|
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],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +773,9 @@ 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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -764,8 +864,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 +894,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 };
|
||||||
|
|||||||
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()
|
||||||
|
);
|
||||||
@@ -204,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
|
|||||||
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'',
|
frequency VARCHAR(20) DEFAULT ''monthly'',
|
||||||
|
due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
|
||||||
|
due_day INTEGER DEFAULT 1,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)', v_schema);
|
)', v_schema);
|
||||||
@@ -244,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices (
|
|||||||
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'',
|
status VARCHAR(20) DEFAULT ''draft'',
|
||||||
|
period_start DATE,
|
||||||
|
period_end DATE,
|
||||||
|
assessment_group_id UUID,
|
||||||
journal_entry_id UUID,
|
journal_entry_id UUID,
|
||||||
sent_at TIMESTAMPTZ,
|
sent_at TIMESTAMPTZ,
|
||||||
paid_at TIMESTAMPTZ,
|
paid_at TIMESTAMPTZ,
|
||||||
@@ -443,10 +449,10 @@ END LOOP;
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 4b. Seed Assessment Groups
|
-- 4b. Seed Assessment Groups
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES
|
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day) VALUES
|
||||||
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20),
|
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 15),
|
||||||
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15),
|
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 1275.00, 0.00, 15, ''quarterly'', ''{1,4,7,10}'', 1),
|
||||||
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15)
|
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1)
|
||||||
', v_schema);
|
', v_schema);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
114
docker-compose.prod.yml
Normal file
114
docker-compose.prod.yml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Production override — use with:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
#
|
||||||
|
# What this changes from the base (dev) config:
|
||||||
|
# - Disables the Docker nginx container (host nginx handles routing + SSL)
|
||||||
|
# - Backend: production Dockerfile (compiled JS, no watch, no devDeps)
|
||||||
|
# - Frontend: production Dockerfile (static build served by nginx on port 3001)
|
||||||
|
# - Backend + Frontend bound to 127.0.0.1 only (host nginx proxies to them)
|
||||||
|
# - No source-code volume mounts (uses baked-in built code)
|
||||||
|
# - Memory limits and health checks on backend
|
||||||
|
# - Tuned PostgreSQL for production workloads
|
||||||
|
# - Restart policies for reliability
|
||||||
|
#
|
||||||
|
# SSL/TLS and request routing are handled by the host-level nginx.
|
||||||
|
# See nginx/host-production.conf for a ready-to-use reference config.
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
# Disabled in production — host nginx handles routing + SSL directly.
|
||||||
|
# The dev-only Docker nginx is still used by the base docker-compose.yml.
|
||||||
|
deploy:
|
||||||
|
replicas: 0
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile # production Dockerfile (compiled JS)
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000" # loopback only — host nginx proxies here
|
||||||
|
volumes: [] # override: no source mounts in prod
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- NODE_ENV=production
|
||||||
|
- AI_API_URL=${AI_API_URL}
|
||||||
|
- AI_API_KEY=${AI_API_KEY}
|
||||||
|
- AI_MODEL=${AI_MODEL}
|
||||||
|
- AI_DEBUG=${AI_DEBUG:-false}
|
||||||
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||||
|
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
|
||||||
|
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}
|
||||||
|
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
|
||||||
|
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
|
||||||
|
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
|
||||||
|
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/azure/callback}
|
||||||
|
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-app.hoaledgeriq.com}
|
||||||
|
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-https://app.hoaledgeriq.com}
|
||||||
|
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-}
|
||||||
|
- APP_URL=${APP_URL:-https://app.hoaledgeriq.com}
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
|
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
|
||||||
|
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-sales@hoaledgeriq.com}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1024M
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile # production Dockerfile (static nginx)
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001" # loopback only — host nginx proxies here
|
||||||
|
volumes: [] # override: no source mounts in prod
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
# Tune PostgreSQL for production workloads
|
||||||
|
command: >
|
||||||
|
postgres
|
||||||
|
-c max_connections=200
|
||||||
|
-c shared_buffers=256MB
|
||||||
|
-c effective_cache_size=512MB
|
||||||
|
-c work_mem=4MB
|
||||||
|
-c maintenance_work_mem=64MB
|
||||||
|
-c checkpoint_completion_target=0.9
|
||||||
|
-c wal_buffers=16MB
|
||||||
|
-c random_page_cost=1.1
|
||||||
|
# No host port mapping — backend reaches postgres via the Docker network.
|
||||||
|
# Removes 2 docker-proxy processes and closes 0.0.0.0:5432 to the internet.
|
||||||
|
ports: []
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1024M
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
# No host port mapping — backend reaches redis via the Docker network.
|
||||||
|
# Removes 2 docker-proxy processes and closes 0.0.0.0:6379 to the internet.
|
||||||
|
ports: []
|
||||||
|
restart: unless-stopped
|
||||||
28
docker-compose.ssl.yml
Normal file
28
docker-compose.ssl.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# SSL override — use with: docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d
|
||||||
|
#
|
||||||
|
# This adds port 443, certbot volumes, and a certbot renewal service
|
||||||
|
# to the base docker-compose.yml configuration.
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/ssl.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- certbot_www:/var/www/certbot:ro
|
||||||
|
- certbot_conf:/etc/letsencrypt:ro
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot:latest
|
||||||
|
volumes:
|
||||||
|
- certbot_www:/var/www/certbot
|
||||||
|
- certbot_conf:/etc/letsencrypt
|
||||||
|
networks:
|
||||||
|
- hoanet
|
||||||
|
# Auto-renew: check twice daily, only renews if < 30 days remain
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done'"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
certbot_www:
|
||||||
|
certbot_conf:
|
||||||
@@ -15,8 +15,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
# No host port mapping — dev traffic goes through the Docker nginx container.
|
||||||
- "3000:3000"
|
# Production overlay maps 127.0.0.1:3000 for the host reverse proxy.
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- REDIS_URL=${REDIS_URL}
|
- REDIS_URL=${REDIS_URL}
|
||||||
@@ -26,6 +26,28 @@ services:
|
|||||||
- AI_API_KEY=${AI_API_KEY}
|
- AI_API_KEY=${AI_API_KEY}
|
||||||
- AI_MODEL=${AI_MODEL}
|
- AI_MODEL=${AI_MODEL}
|
||||||
- AI_DEBUG=${AI_DEBUG:-false}
|
- AI_DEBUG=${AI_DEBUG:-false}
|
||||||
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||||
|
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
|
||||||
|
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}
|
||||||
|
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
|
||||||
|
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
|
||||||
|
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
|
||||||
|
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-http://localhost/api/auth/azure/callback}
|
||||||
|
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
|
||||||
|
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
|
||||||
|
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
|
||||||
|
- APP_URL=${APP_URL:-http://localhost}
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
|
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
|
||||||
|
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||||
@@ -43,8 +65,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
# No host port mapping — dev traffic goes through the Docker nginx container.
|
||||||
- "5173:5173"
|
# Production overlay maps 127.0.0.1:3001 for the host reverse proxy.
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV=${NODE_ENV}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
545
docs/AI_FEATURE_AUDIT.md
Normal file
545
docs/AI_FEATURE_AUDIT.md
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
# AI Feature Audit Report
|
||||||
|
|
||||||
|
**Audit Date:** 2026-03-05
|
||||||
|
**Tenant Under Test:** Pine Creek HOA (`tenant_pine_creek_hoa_q33i`)
|
||||||
|
**AI Model:** Qwen 3.5-397B-A17B via NVIDIA NIM (Temperature: 0.3)
|
||||||
|
**Auditor:** Claude Opus 4.6 (automated)
|
||||||
|
**Data Snapshot Date:** 2026-03-04
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Three AI-powered features were audited against ground-truth database records: **Operating Fund Health**, **Reserve Fund Health**, and **Investment Recommendations**. Overall, the AI demonstrates strong financial reasoning and produces actionable, fiduciary-appropriate recommendations. However, score consistency across runs is a concern (16-point spread on operating, 20-point spread on reserve), and several specific data interpretation issues were identified.
|
||||||
|
|
||||||
|
| Feature | Latest Score/Grade | Concurrence | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Operating Fund Health | 88 / Good | **72%** | Score ~10-15 pts high; cash runway below its own "Good" threshold |
|
||||||
|
| Reserve Fund Health | 45 / Needs Attention | **85%** | Well-calibrated; minor data misquote on annual contributions |
|
||||||
|
| Investment Recommendations | 6 recommendations | **88%** | Excellent specificity; all market rates verified accurate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Foundation (Ground Truth)
|
||||||
|
|
||||||
|
### Financial Position
|
||||||
|
|
||||||
|
| Metric | Value | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Operating Cash (Checking) | $27,418.81 | GL balance |
|
||||||
|
| Reserve Cash (Savings) | $10,688.45 | GL balance |
|
||||||
|
| Reserve CD #1a (FCB) | $10,000 @ 3.67%, matures 6/19/26 | `investment_accounts` |
|
||||||
|
| Reserve CD #2a (FCB) | $8,000 @ 3.60%, matures 4/14/26 | `investment_accounts` |
|
||||||
|
| Reserve CD #3a (FCB) | $10,000 @ 3.67%, matures 8/18/26 | `investment_accounts` |
|
||||||
|
| Total Reserve Fund | $38,688.45 | Cash + Investments |
|
||||||
|
| Total Assets | $66,107.26 | Operating + Reserve |
|
||||||
|
|
||||||
|
### Budget (FY2026)
|
||||||
|
|
||||||
|
| Category | Annual Total |
|
||||||
|
|---|---|
|
||||||
|
| Operating Income | $184,207.40 |
|
||||||
|
| Operating Expense | $139,979.95 |
|
||||||
|
| **Net Operating Surplus** | **$44,227.45** |
|
||||||
|
| Monthly Expense Run Rate | $11,665.00 |
|
||||||
|
| Reserve Interest Income | $1,449.96 |
|
||||||
|
| Reserve Disbursements | $22,000.00 (Mar $13K, Apr $9K) |
|
||||||
|
|
||||||
|
### Assessment Structure
|
||||||
|
|
||||||
|
- **67 units** at $2,328.14/year regular + $300.00/year special (annual frequency)
|
||||||
|
- Total annual regular assessments: ~$155,985
|
||||||
|
- Total annual special assessments: ~$20,100
|
||||||
|
- Budget timing: assessments front-loaded in Mar-Jun
|
||||||
|
|
||||||
|
### Actuals (YTD through March 4, 2026)
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| YTD Income | $88.16 (ARC fees $100 - $50 adj + $38.16 interest) |
|
||||||
|
| YTD Expenses | $1,850.42 (January only) |
|
||||||
|
| Delinquent Invoices | 0 ($0.00) |
|
||||||
|
| Journal Entries Posted | 4 (Jan actuals + Feb adjusting + Feb opening balances) |
|
||||||
|
|
||||||
|
### Capital Projects (from `projects` table, 26 total)
|
||||||
|
|
||||||
|
| Project | Cost | Target | Funded % |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Pond Spillway | $7,000 | Mar 2026 | 0% |
|
||||||
|
| Tuscany Drain Box | $5,500 | May 2026 | 0% |
|
||||||
|
| Front Entrance Power Washing | $1,500 | Mar 2027 | 0% |
|
||||||
|
| Irrigation Pump Replacement | $1,500 | Jun 2027 | 0% |
|
||||||
|
| **Road Sealing - All Roads** | **$80,000** | **Jun 2029** | **0%** |
|
||||||
|
| Asphalt Repair - Creek Stone Dr | $43,000 | TBD | 0% |
|
||||||
|
| Pavilion & Vineyard Structures | $7,000 | Jun 2035 | 0% |
|
||||||
|
| 16 placeholder items | $1.00 each | TBD | 0% |
|
||||||
|
| **Total Planned** | **$152,016** | | **0%** |
|
||||||
|
|
||||||
|
### Reserve Components
|
||||||
|
|
||||||
|
- **0 components tracked** (empty `reserve_components` table)
|
||||||
|
|
||||||
|
### Market Rates (fetched 2026-03-04)
|
||||||
|
|
||||||
|
| Type | Top Rate | Bank | Term |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CD | 4.10% | E*TRADE / Synchrony | 12-14 mo |
|
||||||
|
| High-Yield Savings | 4.09% | Openbank | Liquid |
|
||||||
|
| Money Market | 4.03% | Vio Bank | Liquid |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Operating Fund Health Score
|
||||||
|
|
||||||
|
**Latest Score:** 88 (Good) — Generated 2026-03-04T19:24:36Z
|
||||||
|
**Score History:** 48 → 72 → 78 → 72 → 78 → **88** (6 runs, March 2-4)
|
||||||
|
**Overall Concurrence: 72%**
|
||||||
|
|
||||||
|
### Factor-by-Factor Analysis
|
||||||
|
|
||||||
|
#### Factor 1: "Projected Cash Flow" — Impact: Positive
|
||||||
|
> "12-month forecast shows consistent positive liquidity, with cash balances never dipping below the starting $27,419 and peaking at $142,788 in June."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Budget surplus ($184K income vs $140K expense) | **Verified** ✅ |
|
||||||
|
| Assessments front-loaded Mar-Jun | **Verified** ✅ (budget shows $48K Mar, $64K Apr, $32K May, $16K Jun) |
|
||||||
|
| Peak of ~$142K in June | **Plausible** ✅ ($27K + cumulative income through June) |
|
||||||
|
| Cash never below starting $27K | **Plausible** ✅ (expenses < income by month) |
|
||||||
|
|
||||||
|
**Concurrence: 95%** — Forecast logic is sound. The only risk is the assumption that assessments are collected on the exact budget schedule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 2: "Delinquency Rate" — Impact: Positive
|
||||||
|
> "$0.00 in overdue invoices and a 0.0% delinquency rate."
|
||||||
|
|
||||||
|
**Concurrence: 100%** ✅ — Database confirms zero delinquent invoices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 3: "Budget Performance (Timing)" — Impact: Neutral
|
||||||
|
> "YTD income is 99.8% below budget ($55k variance) primarily due to the timing of the large Special Assessment ($20,700) and regular assessments appearing in future projected months."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| YTD income $88.16 | **Verified** ✅ |
|
||||||
|
| Budget includes March ($55K) in YTD calc | **Accurate** — AI uses month 3 of 12, includes full March budget |
|
||||||
|
| Timing explanation | **Reasonable** — we're only 4 days into March |
|
||||||
|
| Rating as "neutral" vs "negative" | **Appropriate** ✅ — correctly avoids penalizing for calendar timing |
|
||||||
|
|
||||||
|
**Concurrence: 80%** — The variance is accurately computed but presenting a $55K "variance" when we're 4 days into March could alarm a board member. The YTD window through month 3 includes all of March's budget despite only 4 days having elapsed. Consider computing YTD budget pro-rata or through the prior complete month.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Add a note to the prompt about pro-rating the current month's budget, or instruct the AI to note "X days into the current month" when the variance is driven by incomplete-month timing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 4: "Cash Reserves" — Impact: Positive
|
||||||
|
> "Current operating cash of $27,419 provides 2.4 months of runway based on the annual expense run rate."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| $27,419 / ($139,980 / 12) = 2.35 months | **Math verified** ✅ |
|
||||||
|
| Rated as "positive" | **Questionable** ⚠️ |
|
||||||
|
|
||||||
|
**Concurrence: 60%** — The math is correct, but rating 2.4 months as "positive" contradicts the scoring guidelines which state 2-3 months = "Fair" (60-74) and 3-6 months = "Good" (75-89). This factor should be "neutral" at best, and the overall score should reflect that the HOA is *below* the "Good" threshold for cash reserves.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Add explicit guidance in the prompt: "If cash runway is below 3 months, this factor MUST be neutral or negative, regardless of projected future inflows."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 5: "Expense Management" — Impact: Positive
|
||||||
|
> "YTD expenses are $36,313 under budget (4.8% of annual budget spent vs 25% of year elapsed)."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| YTD expenses $1,850.42 | **Verified** ✅ |
|
||||||
|
| Budget YTD (3 months): ~$38,164 | **Correct** ✅ |
|
||||||
|
| $1,850 / $38,164 = 4.85% | **Math verified** ✅ |
|
||||||
|
| "25% of year elapsed" | **Correct** (month 3 of 12) |
|
||||||
|
| Phrasing "of annual budget" | **Misleading** ⚠️ — it's actually 4.8% of YTD budget, not annual |
|
||||||
|
|
||||||
|
**Concurrence: 70%** — The percentage is correctly calculated against YTD budget, but the phrasing "of annual budget" is incorrect. Also, the low spend is not necessarily positive — only January actuals exist; February hasn't been posted yet, which the AI partially acknowledges with "or delayed billing cycles."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recommendation Assessment
|
||||||
|
|
||||||
|
| # | Recommendation | Priority | Concurrence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | "Verify the posting schedule for the $20,700 Special Assessment" | Low | **90%** ✅ Valid; assessments are annual, collection timing matters |
|
||||||
|
| 2 | "Investigate the low YTD expense recognition ($1,850 vs $38,164)" | Medium | **95%** ✅ Excellent catch; Feb expenses not posted yet |
|
||||||
|
| 3 | "Consider moving excess cash over $100K in Q2 to interest-bearing account" | Low | **85%** ✅ Sound advice; aligns with HY Savings at 4.09% |
|
||||||
|
|
||||||
|
**Recommendation Concurrence: 90%** — All three recommendations are actionable and data-backed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Score Assessment
|
||||||
|
|
||||||
|
**Is 88 (Good) the right score?**
|
||||||
|
|
||||||
|
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Cash reserves | 3-6 months for "Good" | 2.4 months | ❌ Below threshold |
|
||||||
|
| Income vs expenses | "Roughly matching" for Good | $184K vs $140K (surplus) | ✅ Exceeds |
|
||||||
|
| Delinquency | "Manageable" for Good | 0% | ✅ Excellent |
|
||||||
|
| Budget performance | No major overruns for Good | Under budget (timing) | ✅ Positive |
|
||||||
|
| Projected cash flow | Not explicitly in guidelines | Strong positive trajectory | ✅ Positive |
|
||||||
|
|
||||||
|
The cash runway of 2.4 months is below the stated "Good" (75-89) threshold of 3-6 months and technically falls in the "Fair" (60-74) range of 2-3 months. Earlier AI runs scored this 72-78, which better aligns with the guidelines. The 88 appears to overweight the projected future cash flow (which is speculative) vs the current actual position.
|
||||||
|
|
||||||
|
**Suggested correct score: 74-80** (high end of Fair to low end of Good)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Score Consistency Concern
|
||||||
|
|
||||||
|
| Run Date | Score | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| Mar 2 15:07 | 48 | Needs Attention |
|
||||||
|
| Mar 2 15:12 | 78 | Good |
|
||||||
|
| Mar 2 15:36 | 72 | Fair |
|
||||||
|
| Mar 2 17:09 | 78 | Good |
|
||||||
|
| Mar 3 02:03 | 72 | Fair |
|
||||||
|
| Mar 4 19:24 | 88 | Good |
|
||||||
|
|
||||||
|
A **40-point spread** (48-88) across 6 runs with essentially the same data is concerning. Even excluding the outlier first run (which noted a data config issue with "1 units"), the remaining 5 runs span 72-88 (16 points). At temperature 0.3, this suggests the model is not deterministic enough for financial scoring.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Consider lowering temperature to 0.1 for health score calculations to improve consistency. Alternatively, implement a moving average of the last 3 scores to smooth volatility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Reserve Fund Health Score
|
||||||
|
|
||||||
|
**Latest Score:** 45 (Needs Attention) — Generated 2026-03-04T19:24:50Z
|
||||||
|
**Score History:** 25 → 48 → 42 → 25 → 45 → 35 → **45** (7 runs, March 2-4)
|
||||||
|
**Overall Concurrence: 85%**
|
||||||
|
|
||||||
|
### Factor-by-Factor Analysis
|
||||||
|
|
||||||
|
#### Factor 1: "Funded Ratio" — Impact: Negative
|
||||||
|
> "Calculated at 0% because no reserve components have been inventoried or assigned replacement costs, making it impossible to measure true funding health against the $152,016 in planned projects."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| 0 reserve components in DB | **Verified** ✅ |
|
||||||
|
| $152,016 in planned projects | **Verified** ✅ (sum of all `projects` rows) |
|
||||||
|
| 0% funded ratio | **Technically accurate** ✅ (no denominator from components) |
|
||||||
|
| Distinction between components and projects | **Well articulated** ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 95%** — The AI correctly identifies that the 0% is an artifact of missing reserve study data, not a literal lack of funds. It appropriately flags this as a governance failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 2: "Projected Cash Flow" — Impact: Positive
|
||||||
|
> "Strong immediate liquidity; cash balance is projected to rise from $10,688 to over $49,000 by May 2026 due to special assessment income covering the $12,500 in urgent 2026 project costs."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Starting reserve cash $10,688 | **Verified** ✅ |
|
||||||
|
| 2026 project costs: $7K (Mar) + $5.5K (May) = $12,500 | **Verified** ✅ |
|
||||||
|
| Special assessment: $300 × 67 = $20,100/year | **Verified** ✅ |
|
||||||
|
| CD maturities: $8K (Apr), $10K (Jun), $10K (Aug) | **Verified** ✅ |
|
||||||
|
| Projected rise to $49K by May | **Plausible** ✅ (income + maturities - project costs) |
|
||||||
|
|
||||||
|
**Concurrence: 85%** — Math is directionally correct. However, the assessment is annual frequency so the full $20,100 may arrive in a single payment, not spread monthly. The timing assumption is critical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 3: "Component Tracking" — Impact: Negative
|
||||||
|
> "Critical failure in governance: 'No reserve components tracked' means the association is flying blind on the condition and remaining useful life of major assets like roads and irrigation."
|
||||||
|
|
||||||
|
**Concurrence: 100%** ✅ — Database confirms 0 rows in `reserve_components`. This is objectively a critical gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Factor 4: "Annual Contributions" — Impact: Negative
|
||||||
|
> "Recurring annual reserve income is only $300 (plus minimal interest), which is grossly insufficient to fund the $80,000 road sealing project due in 2029."
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Reserve budget income: $1,449.96/yr (interest only) | **Verified** ✅ |
|
||||||
|
| Special assessment: $300/unit × 67 = $20,100/yr | **Verified** ✅ |
|
||||||
|
| "$300" cited as annual reserve income | **Incorrect** ⚠️ |
|
||||||
|
| Road Sealing $80K in June 2029 | **Verified** ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 65%** — The concern about insufficient contributions is valid, but the "$300" figure appears to confuse the per-unit special assessment amount ($300/unit) with the total annual reserve income. Actual annual reserve income = $1,450 (interest) + $20,100 (special assessments) = **$21,550/yr**. Even at $21,550/yr, the 3 years until Road Sealing would accumulate ~$64,650, still short of $80K. So the directional concern is correct, but the magnitude is significantly misstated.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** The prompt should explicitly label the special assessment income total (not per-unit) in the data context. Currently the data says "$300.00/unit × 67 units (annual)" — the AI should compute $20,100 but sometimes fixates on the $300 per-unit figure. Consider pre-computing and passing the total.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recommendation Assessment
|
||||||
|
|
||||||
|
| # | Recommendation | Priority | Concurrence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | "Commission a professional Reserve Study to inventory assets and establish funded ratio" | High | **100%** ✅ Critical and universally correct |
|
||||||
|
| 2 | "Develop a long-term funding plan for the $80,000 Road Sealing project (2029)" | High | **90%** ✅ Verified project exists; $80K with 0% funded |
|
||||||
|
| 3 | "Formalize collection of special assessments into the reserve fund vs operating" | Medium | **95%** ✅ Budget shows special assessments in operating income section |
|
||||||
|
|
||||||
|
**Recommendation Concurrence: 95%** — All recommendations are actionable, appropriately prioritized, and backed by database evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Score Assessment
|
||||||
|
|
||||||
|
**Is 45 (Needs Attention) the right score?**
|
||||||
|
|
||||||
|
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Percent funded | 20-30% for "Needs Attention" | 0% (no components) | ⬇️ Worse than threshold |
|
||||||
|
| Contributions | "Inadequate" for Needs Attention | $21,550/yr for $152K in projects | ⚠️ Borderline |
|
||||||
|
| Component tracking | "Multiple urgent unfunded" | 0 tracked, 2 due in 2026 | ❌ Critical gap |
|
||||||
|
| Investments | Not scored negatively | 3 CDs earning 3.6-3.67% | ✅ Positive |
|
||||||
|
| Capital readiness | | $12.5K due soon, only $10.7K cash | ⚠️ Tight |
|
||||||
|
|
||||||
|
A score of 45 is reasonable. The 0% funded ratio technically suggests "At Risk" (20-39), but the presence of real assets ($38.7K), active investments, and manageable near-term liquidity justifies bumping it into the "Needs Attention" band. The AI's balancing of the artificial 0% metric against actual fund health shows good judgment.
|
||||||
|
|
||||||
|
**Suggested correct score: 40-50** — the AI's 45 is well-calibrated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Score Consistency Concern
|
||||||
|
|
||||||
|
| Run Date | Score | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| Mar 2 15:06 | 25 | At Risk |
|
||||||
|
| Mar 2 15:13 | 25 | At Risk |
|
||||||
|
| Mar 2 15:37 | 48 | Needs Attention |
|
||||||
|
| Mar 2 17:10 | 42 | Needs Attention |
|
||||||
|
| Mar 3 02:04 | 45 | Needs Attention |
|
||||||
|
| Mar 4 18:49 | 35 | At Risk |
|
||||||
|
| Mar 4 19:24 | 45 | Needs Attention |
|
||||||
|
|
||||||
|
A **23-point spread** (25-48) across 7 runs. The scores oscillate between "At Risk" and "Needs Attention" — the model cannot consistently decide which band this falls into. The most recent 3 runs (35, 45, 45) are more stable.
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Add boundary guidance to the prompt: "When the score falls within ±5 points of a threshold (40, 60, 75, 90), explicitly justify which side of the boundary the HOA falls on."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. AI Investment Recommendations
|
||||||
|
|
||||||
|
**Latest Run:** 2026-03-04T19:28:22Z (3 runs saved)
|
||||||
|
**Overall Concurrence: 88%**
|
||||||
|
|
||||||
|
### Overall Assessment
|
||||||
|
> "The HOA has a healthy long-term cash flow outlook with significant surpluses projected by mid-2026, but faces an immediate liquidity pinch in the Reserve Fund for March/April capital projects. The current investment strategy relies on older, lower-yielding CDs (3.60-3.67%) that are maturing soon."
|
||||||
|
|
||||||
|
**Concurrence: 92%** ✅ — Every claim verified:
|
||||||
|
- CDs are at 3.60-3.67% vs market 4.10% (verified)
|
||||||
|
- March project ($7K) vs reserve cash ($10.7K) is tight (verified)
|
||||||
|
- Long-term surplus projected from assessment income (verified from budget)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recommendation-by-Recommendation Analysis
|
||||||
|
|
||||||
|
#### Rec 1: "Critical Reserve Shortfall for March Project" — HIGH / Liquidity Warning
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| Reserve cash = $10,688 | $10,688.45 | ✅ Exact |
|
||||||
|
| $7,000 Pond Spillway project due March | Projects table: $7,000, Mar 2026 | ✅ Exact |
|
||||||
|
| Shortfall risk | $10,688 - $7,000 = $3,688 remaining — tight but feasible | ✅ |
|
||||||
|
| Suggested action: expedite special assessment or transfer from operating | Sound advice | ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 90%** — The liquidity concern is real. After paying the $7K project, only $3.7K would remain in reserve cash before the $5.5K May project. The AI correctly flags the timing risk even though the fund is technically solvent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 2: "Reinvest Maturing CD #2a at Higher Rate" — HIGH / Maturity Action
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| CD #2a = $8,000 | $8,000.00 | ✅ Exact |
|
||||||
|
| Current rate = 3.60% | 3.60% | ✅ Exact |
|
||||||
|
| Maturity = April 14, 2026 | 2026-04-14 | ✅ Exact |
|
||||||
|
| Market rate = 4.10% (E*TRADE) | CD rates: E*TRADE 4.10%, 1 year, $0 min | ✅ Exact |
|
||||||
|
| Additional yield: ~$40/year per $8K | $8K × 0.50% = $40 | ✅ Math correct |
|
||||||
|
|
||||||
|
**Concurrence: 95%** ✅ — Textbook-correct recommendation. Every data point verified. The 50 bps improvement is risk-free income.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 3: "Establish 12-Month CD Ladder for Reserves" — MEDIUM / CD Ladder
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| ~$38K total reserve portfolio | $38,688.45 | ✅ Exact |
|
||||||
|
| Suggest 4-rung ladder (3/6/9/12 mo) | Standard strategy | ✅ |
|
||||||
|
| Rates up to 4.10% | Market data confirmed | ✅ |
|
||||||
|
| $9K matures every quarter | $38K / 4 = $9.5K per rung | ✅ Approximate |
|
||||||
|
|
||||||
|
**Concurrence: 75%** — Strategy is sound in principle, but the recommendation overlooks two constraints:
|
||||||
|
1. **Immediate project costs ($12.5K in 2026)** must be reserved first, leaving ~$26K for laddering
|
||||||
|
2. **Investing the entire $38K** is aggressive — some cash buffer should remain liquid
|
||||||
|
|
||||||
|
**🔧 Tuning Suggestion:** Add a constraint to the prompt: "When recommending CD ladders, always subtract upcoming project costs (next 12 months) and a minimum emergency reserve (1 month of budgeted reserve expenses) before calculating the investable amount."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 4: "Deploy Excess Operating Cash to High-Yield Savings" — MEDIUM / New Investment
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| Operating cash = $27,418 | $27,418.81 | ✅ Exact |
|
||||||
|
| 3-month buffer = ~$35,000 | $11,665 × 3 = $34,995 | ✅ Math correct |
|
||||||
|
| Current cash below buffer | $27.4K < $35K | ✅ Correctly identified |
|
||||||
|
| Openbank 4.09% APY | Market data: Openbank 4.09%, $0.01 min | ✅ Exact |
|
||||||
|
| Trigger: "As soon as balance exceeds $35K" | Sound deferred recommendation | ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 90%** ✅ — The AI correctly identifies the current shortfall and provides a forward-looking trigger. Well-structured advice that respects the liquidity constraint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 5: "Optimize Reserve Cash Yield Post-Project" — LOW / Reallocation
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| Vio Bank Money Market at 4.03% | Market data: Vio Bank 4.03%, $0 min | ✅ Exact |
|
||||||
|
| Post-project reserve cash deployment | Appropriate timing | ✅ |
|
||||||
|
| T+1 liquidity for emergencies | Correct MM account characteristic | ✅ |
|
||||||
|
|
||||||
|
**Concurrence: 85%** ✅ — Reasonable low-priority optimization. Correctly uses market data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Rec 6: "Formalize Special Assessment Collection for Reserves" — LOW / General
|
||||||
|
|
||||||
|
| Claim | Database Value | Match |
|
||||||
|
|---|---|---|
|
||||||
|
| $300/unit special assessment | Assessment groups: $300.00 special | ✅ Exact |
|
||||||
|
| Risk of commingling with operating | Budget shows special assessments in operating income | ✅ Identified |
|
||||||
|
|
||||||
|
**Concurrence: 90%** ✅ — Important governance recommendation. The budget structure does show special assessments as operating income, which could lead to improper fund commingling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Risk Notes Assessment
|
||||||
|
|
||||||
|
| Risk Note | Verified | Concurrence |
|
||||||
|
|---|---|---|
|
||||||
|
| "Reserve cash ($10.6K) barely sufficient for $7K + $5.5K projects" | ✅ $10,688 vs $12,500 in projects | **95%** |
|
||||||
|
| "Concentration risk: CDs maturing in 4-month window (Apr-Aug)" | ✅ All 3 CDs mature Apr-Aug 2026 | **100%** |
|
||||||
|
| "Operating cash ballooning to $140K+ without investment plan" | ✅ Budget shows large Q2 surplus | **85%** |
|
||||||
|
| "Road Sealing $80K in 2029 needs dedicated savings plan" | ✅ Project exists, 0% funded | **95%** |
|
||||||
|
|
||||||
|
**Risk Notes Concurrence: 94%** — All risk items are data-backed and appropriately flagged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cross-Run Consistency (Investment Recommendations)
|
||||||
|
|
||||||
|
Three runs were compared. Key observations:
|
||||||
|
- **Core recommendations are highly consistent** across runs: CD reinvestment, HY savings for operating, CD ladder for reserves
|
||||||
|
- **Dollar amounts match exactly** across all runs (same data inputs)
|
||||||
|
- **Bank name recommendations vary slightly** (E*TRADE vs "Top CD Rate") — cosmetic, not substantive
|
||||||
|
- **Priority levels are stable** (HIGH for liquidity warnings, MEDIUM for optimization)
|
||||||
|
|
||||||
|
**Consistency Grade: A-** — Investment recommendations show much better consistency than health scores, likely because the structured data (specific CDs, specific rates) constrains the output more than the subjective health scoring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Cutting Issues
|
||||||
|
|
||||||
|
### Issue 1: Score Volatility (MEDIUM Priority)
|
||||||
|
|
||||||
|
Health scores vary significantly across runs despite identical input data:
|
||||||
|
- Operating: 40-point spread (48-88)
|
||||||
|
- Reserve: 23-point spread (25-48)
|
||||||
|
|
||||||
|
**Root Cause:** Temperature 0.3 allows too much variance for numerical scoring. The model interprets guidelines subjectively.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
1. Reduce temperature to **0.1** for health score calculations
|
||||||
|
2. Implement a **3-run moving average** to smooth individual run variance
|
||||||
|
3. Add explicit **boundary justification** requirements to prompts
|
||||||
|
|
||||||
|
### Issue 2: YTD Budget Calculation Includes Incomplete Month (LOW Priority)
|
||||||
|
|
||||||
|
The operating health score computes YTD budget through the current month (March), but actual data may only cover a few days. This creates alarming income variances (e.g., "$55K variance") that are pure timing artifacts.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Compute YTD budget through the **prior completed month** (February)
|
||||||
|
- OR pro-rate the current month's budget by days elapsed
|
||||||
|
- Add a note to the prompt: "If the variance is driven by the current incomplete month, flag it as 'timing' and weight it minimally."
|
||||||
|
|
||||||
|
### Issue 3: Per-Unit vs Total Confusion on Special Assessments (LOW Priority)
|
||||||
|
|
||||||
|
The AI sometimes quotes "$300" as the annual reserve income instead of $300 × 67 = $20,100. The data passed says "$300.00/unit × 67 units (annual)" but the model occasionally fixates on the per-unit figure.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Pre-compute and include the total in the data: "Total Annual Special Assessment Income: $20,100.00"
|
||||||
|
- Keep the per-unit breakdown for context but lead with the total
|
||||||
|
|
||||||
|
### Issue 4: Cash Runway Classification Inconsistency (MEDIUM Priority)
|
||||||
|
|
||||||
|
The operating health score rates 2.4 months of cash runway as "positive" despite the scoring guidelines defining 2-3 months as "Fair" territory. This inflates the overall score.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Add explicit prompt guidance: "Cash runway categorization: <2 months = negative, 2-3 months = neutral, 3-6 months = positive, 6+ months = strongly positive. Do NOT rate below-threshold runway as positive based on projected future inflows."
|
||||||
|
|
||||||
|
### Issue 5: Dual Project Tables (INFORMATIONAL)
|
||||||
|
|
||||||
|
The schema contains both `capital_projects` (empty) and `projects` (26 rows). The health score service correctly queries `projects`, but auditors initially checked `capital_projects` and found no data. This dual-table pattern could confuse future developers.
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
- Consolidate into a single table, OR
|
||||||
|
- Add a comment/documentation clarifying the canonical source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concurrence Summary by Recommendation
|
||||||
|
|
||||||
|
### Operating Fund Health — Recommendations
|
||||||
|
| Recommendation | Concurrence |
|
||||||
|
|---|---|
|
||||||
|
| Verify posting schedule for $20,700 Special Assessment | 90% |
|
||||||
|
| Investigate low YTD expense recognition | 95% |
|
||||||
|
| Move excess cash to interest-bearing account | 85% |
|
||||||
|
| **Average** | **90%** |
|
||||||
|
|
||||||
|
### Reserve Fund Health — Recommendations
|
||||||
|
| Recommendation | Concurrence |
|
||||||
|
|---|---|
|
||||||
|
| Commission professional Reserve Study | 100% |
|
||||||
|
| Develop funding plan for $80K Road Sealing | 90% |
|
||||||
|
| Formalize special assessment collection for reserves | 95% |
|
||||||
|
| **Average** | **95%** |
|
||||||
|
|
||||||
|
### Investment Planning — Recommendations
|
||||||
|
| Recommendation | Concurrence |
|
||||||
|
|---|---|
|
||||||
|
| Critical Reserve Shortfall for March Project | 90% |
|
||||||
|
| Reinvest Maturing CD #2a at Higher Rate | 95% |
|
||||||
|
| Establish 12-Month CD Ladder | 75% |
|
||||||
|
| Deploy Operating Cash to HY Savings | 90% |
|
||||||
|
| Optimize Reserve Cash Post-Project | 85% |
|
||||||
|
| Formalize Special Assessment Collection | 90% |
|
||||||
|
| **Average** | **88%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Grades
|
||||||
|
|
||||||
|
| Feature | Score Accuracy | Recommendation Quality | Data Fidelity | Consistency | **Overall** |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Operating Fund Health | C+ (score ~15 pts high) | A (90%) | B+ (minor math phrasing) | C (16-pt spread) | **72% — B-** |
|
||||||
|
| Reserve Fund Health | A- (well-calibrated) | A (95%) | B (per-unit confusion) | B- (23-pt spread) | **85% — B+** |
|
||||||
|
| Investment Recommendations | N/A (no single score) | A (88%) | A (exact data matches) | A- (stable across runs) | **88% — A-** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Action Items for Tuning
|
||||||
|
|
||||||
|
1. **[HIGH]** Reduce AI temperature from 0.3 → 0.1 for health score calculations to reduce score volatility
|
||||||
|
2. **[MEDIUM]** Add explicit cash-runway-to-impact mapping in operating prompt to prevent misclassification
|
||||||
|
3. **[MEDIUM]** Pre-compute total special assessment income in data context (not just per-unit)
|
||||||
|
4. **[LOW]** Adjust YTD budget calculation to use prior completed month or pro-rate current month
|
||||||
|
5. **[LOW]** Add boundary justification requirement to scoring prompts
|
||||||
|
6. **[LOW]** Consider implementing 3-run moving average for displayed health scores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated by Claude Opus 4.6 — Automated AI Feature Audit*
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
# HOA LedgerIQ — Deployment Guide
|
|
||||||
|
|
||||||
**Version:** 2026.3.2 (beta)
|
|
||||||
**Last updated:** 2026-03-02
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [Prerequisites](#prerequisites)
|
|
||||||
2. [Deploy to a Fresh Docker Server](#deploy-to-a-fresh-docker-server)
|
|
||||||
3. [Backup the Local Test Database](#backup-the-local-test-database)
|
|
||||||
4. [Restore a Backup into the Staged Environment](#restore-a-backup-into-the-staged-environment)
|
|
||||||
5. [Running Migrations on the Staged Environment](#running-migrations-on-the-staged-environment)
|
|
||||||
6. [Verifying the Deployment](#verifying-the-deployment)
|
|
||||||
7. [Environment Variable Reference](#environment-variable-reference)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
On the **target server**, ensure the following are installed:
|
|
||||||
|
|
||||||
| Tool | Minimum Version |
|
|
||||||
|-----------------|-----------------|
|
|
||||||
| Docker Engine | 24+ |
|
|
||||||
| Docker Compose | v2+ |
|
|
||||||
| Git | 2.x |
|
|
||||||
| `psql` (client) | 15+ *(optional, for manual DB work)* |
|
|
||||||
|
|
||||||
The app runs five containers — nginx, backend (NestJS), frontend (Vite/React),
|
|
||||||
PostgreSQL 15, and Redis 7. Total memory footprint is roughly **1–2 GB** idle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deploy to a Fresh Docker Server
|
|
||||||
|
|
||||||
### 1. Clone the repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh your-staging-server
|
|
||||||
|
|
||||||
git clone <repo-url> /opt/hoa-ledgeriq
|
|
||||||
cd /opt/hoa-ledgeriq
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create the environment file
|
|
||||||
|
|
||||||
Copy the example and fill in real values:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env # or vi, your choice
|
|
||||||
```
|
|
||||||
|
|
||||||
**Required changes from defaults:**
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
# --- CHANGE THESE ---
|
|
||||||
POSTGRES_PASSWORD=<strong-random-password>
|
|
||||||
JWT_SECRET=<random-64-char-string>
|
|
||||||
|
|
||||||
# Database URL must match the password above
|
|
||||||
DATABASE_URL=postgresql://hoafinance:<same-password>@postgres:5432/hoafinance
|
|
||||||
|
|
||||||
# AI features (get a key from build.nvidia.com)
|
|
||||||
AI_API_KEY=nvapi-xxxxxxxxxxxx
|
|
||||||
|
|
||||||
# --- Usually fine as-is ---
|
|
||||||
POSTGRES_USER=hoafinance
|
|
||||||
POSTGRES_DB=hoafinance
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
NODE_ENV=development # keep as development for staging
|
|
||||||
AI_API_URL=https://integrate.api.nvidia.com/v1
|
|
||||||
AI_MODEL=qwen/qwen3.5-397b-a17b
|
|
||||||
AI_DEBUG=false
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Tip:** Generate secrets quickly:
|
|
||||||
> ```bash
|
|
||||||
> openssl rand -hex 32 # good for JWT_SECRET
|
|
||||||
> openssl rand -base64 24 # good for POSTGRES_PASSWORD
|
|
||||||
> ```
|
|
||||||
|
|
||||||
### 3. Build and start the stack
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
- Build the backend and frontend images
|
|
||||||
- Pull `postgres:15-alpine`, `redis:7-alpine`, and `nginx:alpine`
|
|
||||||
- Initialize the PostgreSQL database with the shared schema (`db/init/00-init.sql`)
|
|
||||||
- Start all five services on the `hoanet` bridge network
|
|
||||||
|
|
||||||
### 4. Wait for healthy services
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
All five containers should show `Up` (postgres and redis should also show
|
|
||||||
`(healthy)`). If the backend is restarting, check logs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs backend --tail=50
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. (Optional) Seed with demo data
|
|
||||||
|
|
||||||
If deploying a fresh environment for testing and you want the Sunrise Valley
|
|
||||||
HOA demo tenant:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -T postgres psql -U hoafinance -d hoafinance < db/seed/seed.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates:
|
|
||||||
- Platform admin: `admin@hoaledgeriq.com` / `password123`
|
|
||||||
- Tenant admin: `admin@sunrisevalley.org` / `password123`
|
|
||||||
- Tenant viewer: `viewer@sunrisevalley.org` / `password123`
|
|
||||||
|
|
||||||
### 6. Access the application
|
|
||||||
|
|
||||||
| Service | URL |
|
|
||||||
|-----------|--------------------------------|
|
|
||||||
| App (UI) | `http://<server-ip>` |
|
|
||||||
| API | `http://<server-ip>/api` |
|
|
||||||
| Postgres | `<server-ip>:5432` (direct) |
|
|
||||||
|
|
||||||
> **Note:** For production, add an SSL-terminating proxy (Caddy, Traefik, or
|
|
||||||
> an nginx TLS config) in front of port 80.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backup the Local Test Database
|
|
||||||
|
|
||||||
### Full database dump (recommended)
|
|
||||||
|
|
||||||
From your **local development machine** where the app is currently running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/HOA_Financial_Platform
|
|
||||||
|
|
||||||
# Dump the entire database (all schemas, roles, data)
|
|
||||||
docker compose exec -T postgres pg_dump \
|
|
||||||
-U hoafinance \
|
|
||||||
-d hoafinance \
|
|
||||||
--no-owner \
|
|
||||||
--no-privileges \
|
|
||||||
--format=custom \
|
|
||||||
-f /tmp/hoafinance_backup.dump
|
|
||||||
|
|
||||||
# Copy the dump file out of the container
|
|
||||||
docker compose cp postgres:/tmp/hoafinance_backup.dump ./hoafinance_backup.dump
|
|
||||||
```
|
|
||||||
|
|
||||||
The `--format=custom` flag produces a compressed binary format that supports
|
|
||||||
selective restore. The file is typically 50–80% smaller than plain SQL.
|
|
||||||
|
|
||||||
### Alternative: Plain SQL dump
|
|
||||||
|
|
||||||
If you prefer a human-readable SQL file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -T postgres pg_dump \
|
|
||||||
-U hoafinance \
|
|
||||||
-d hoafinance \
|
|
||||||
--no-owner \
|
|
||||||
--no-privileges \
|
|
||||||
> hoafinance_backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backup a single tenant schema
|
|
||||||
|
|
||||||
To export just one tenant (e.g., Pine Creek HOA):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -T postgres pg_dump \
|
|
||||||
-U hoafinance \
|
|
||||||
-d hoafinance \
|
|
||||||
--no-owner \
|
|
||||||
--no-privileges \
|
|
||||||
--schema=tenant_pine_creek_hoa_q33i \
|
|
||||||
> pine_creek_backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Finding a tenant's schema name:**
|
|
||||||
> ```bash
|
|
||||||
> docker compose exec -T postgres psql -U hoafinance -d hoafinance \
|
|
||||||
> -c "SELECT name, schema_name FROM shared.organizations WHERE status = 'active';"
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Restore a Backup into the Staged Environment
|
|
||||||
|
|
||||||
### 1. Transfer the backup to the staging server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scp hoafinance_backup.dump user@staging-server:/opt/hoa-ledgeriq/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Ensure the stack is running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/hoa-ledgeriq
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Drop and recreate the database (clean slate)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Connect to postgres and reset the database
|
|
||||||
docker compose exec -T postgres psql -U hoafinance -d postgres -c "
|
|
||||||
SELECT pg_terminate_backend(pid)
|
|
||||||
FROM pg_stat_activity
|
|
||||||
WHERE datname = 'hoafinance' AND pid <> pg_backend_pid();
|
|
||||||
"
|
|
||||||
docker compose exec -T postgres dropdb -U hoafinance hoafinance
|
|
||||||
docker compose exec -T postgres createdb -U hoafinance hoafinance
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4a. Restore from custom-format dump
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Copy the dump into the container
|
|
||||||
docker compose cp hoafinance_backup.dump postgres:/tmp/hoafinance_backup.dump
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
docker compose exec -T postgres pg_restore \
|
|
||||||
-U hoafinance \
|
|
||||||
-d hoafinance \
|
|
||||||
--no-owner \
|
|
||||||
--no-privileges \
|
|
||||||
/tmp/hoafinance_backup.dump
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4b. Restore from plain SQL dump
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -T postgres psql \
|
|
||||||
-U hoafinance \
|
|
||||||
-d hoafinance \
|
|
||||||
< hoafinance_backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Restart the backend
|
|
||||||
|
|
||||||
After restoring, restart the backend so NestJS re-establishes its connection
|
|
||||||
pool and picks up the restored schemas:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose restart backend
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running Migrations on the Staged Environment
|
|
||||||
|
|
||||||
Migrations live in `db/migrations/` and are numbered sequentially. After
|
|
||||||
restoring an older backup, you may need to apply newer migrations.
|
|
||||||
|
|
||||||
Check which migrations exist:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -la db/migrations/
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply them in order:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all migrations sequentially
|
|
||||||
for f in db/migrations/*.sql; do
|
|
||||||
echo "Applying $f ..."
|
|
||||||
docker compose exec -T postgres psql \
|
|
||||||
-U hoafinance \
|
|
||||||
-d hoafinance \
|
|
||||||
< "$f"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
Or apply a specific migration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -T postgres psql \
|
|
||||||
-U hoafinance \
|
|
||||||
-d hoafinance \
|
|
||||||
< db/migrations/010-health-scores.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** Migrations are idempotent where possible (`IF NOT EXISTS`,
|
|
||||||
> `DO $$ ... $$` blocks), so re-running one that has already been applied
|
|
||||||
> is generally safe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verifying the Deployment
|
|
||||||
|
|
||||||
### Quick health checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backend is responding
|
|
||||||
curl -s http://localhost/api/auth/login | head -c 100
|
|
||||||
|
|
||||||
# Database is accessible
|
|
||||||
docker compose exec -T postgres psql -U hoafinance -d hoafinance \
|
|
||||||
-c "SELECT count(*) AS tenants FROM shared.organizations WHERE status = 'active';"
|
|
||||||
|
|
||||||
# Redis is working
|
|
||||||
docker compose exec -T redis redis-cli ping
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full smoke test
|
|
||||||
|
|
||||||
1. Open `http://<server-ip>` in a browser
|
|
||||||
2. Log in with a known account
|
|
||||||
3. Navigate to Dashboard — verify health scores load
|
|
||||||
4. Navigate to Capital Planning — verify Kanban columns render
|
|
||||||
5. Navigate to Projects — verify project list loads
|
|
||||||
6. Check the Settings page — version should read **2026.3.2 (beta)**
|
|
||||||
|
|
||||||
### View logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f # all services
|
|
||||||
docker compose logs -f backend # backend only
|
|
||||||
docker compose logs -f postgres # database only
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Variable Reference
|
|
||||||
|
|
||||||
| Variable | Required | Description |
|
|
||||||
|-------------------|----------|----------------------------------------------------|
|
|
||||||
| `POSTGRES_USER` | Yes | PostgreSQL username |
|
|
||||||
| `POSTGRES_PASSWORD`| Yes | PostgreSQL password (**change from default**) |
|
|
||||||
| `POSTGRES_DB` | Yes | Database name |
|
|
||||||
| `DATABASE_URL` | Yes | Full connection string for the backend |
|
|
||||||
| `REDIS_URL` | Yes | Redis connection string |
|
|
||||||
| `JWT_SECRET` | Yes | Secret for signing JWT tokens (**change from default**) |
|
|
||||||
| `NODE_ENV` | Yes | `development` or `production` |
|
|
||||||
| `AI_API_URL` | Yes | OpenAI-compatible inference endpoint |
|
|
||||||
| `AI_API_KEY` | Yes | API key for AI provider (Nvidia) |
|
|
||||||
| `AI_MODEL` | Yes | Model identifier for AI calls |
|
|
||||||
| `AI_DEBUG` | No | Set `true` to log raw AI prompts/responses |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐
|
|
||||||
Browser ────────► │ nginx :80 │
|
|
||||||
└──────┬──────┘
|
|
||||||
┌────────┴────────┐
|
|
||||||
▼ ▼
|
|
||||||
┌──────────────┐ ┌──────────────┐
|
|
||||||
│ backend :3000│ │frontend :5173│
|
|
||||||
│ (NestJS) │ │ (Vite/React) │
|
|
||||||
└──────┬───────┘ └──────────────┘
|
|
||||||
┌────┴────┐
|
|
||||||
▼ ▼
|
|
||||||
┌────────────┐ ┌───────────┐
|
|
||||||
│postgres:5432│ │redis :6379│
|
|
||||||
│ (PG 15) │ │ (Redis 7) │
|
|
||||||
└────────────┘ └───────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Multi-tenant isolation:** Each HOA organization gets its own PostgreSQL
|
|
||||||
schema (e.g., `tenant_pine_creek_hoa_q33i`). The `shared` schema holds
|
|
||||||
cross-tenant tables (users, organizations, market rates). Tenant context
|
|
||||||
is resolved from the JWT token on every API request.
|
|
||||||
22
frontend/Dockerfile
Normal file
22
frontend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# ---- Production Dockerfile for React frontend ----
|
||||||
|
# Multi-stage build: compile to static assets, serve with nginx
|
||||||
|
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy the built static files
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy a small nginx config for SPA routing
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -9,5 +9,34 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
<script>
|
||||||
|
(function(d,t) {
|
||||||
|
var BASE_URL="https://chat.hoaledgeriq.com";
|
||||||
|
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||||
|
g.src=BASE_URL+"/packs/js/sdk.js";
|
||||||
|
g.async=true;
|
||||||
|
s.parentNode.insertBefore(g,s);
|
||||||
|
g.onload=function(){
|
||||||
|
window.chatwootSDK.run({
|
||||||
|
websiteToken:'K6VXvTtKXvaCMvre4yK85SPb',
|
||||||
|
baseUrl:BASE_URL
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})(document,"script");
|
||||||
|
window.addEventListener('chatwoot:ready', function() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('ledgeriq-auth');
|
||||||
|
if (!raw) return;
|
||||||
|
var auth = JSON.parse(raw);
|
||||||
|
var user = auth && auth.state && auth.state.user;
|
||||||
|
if (user && window.$chatwoot) {
|
||||||
|
window.$chatwoot.setUser(user.id, {
|
||||||
|
name: (user.firstName || '') + ' ' + (user.lastName || ''),
|
||||||
|
email: user.email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Minimal nginx config for serving the React SPA inside the frontend container.
|
||||||
|
# The outer nginx reverse proxy forwards non-API requests here.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 3001;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Serve static assets with long cache (Vite hashes filenames)
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — any non-file route returns index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
frontend/package-lock.json
generated
147
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "0.1.0",
|
"version": "2026.3.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "0.1.0",
|
"version": "2026.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@mantine/hooks": "^7.15.3",
|
"@mantine/hooks": "^7.15.3",
|
||||||
"@mantine/modals": "^7.15.3",
|
"@mantine/modals": "^7.15.3",
|
||||||
"@mantine/notifications": "^7.15.3",
|
"@mantine/notifications": "^7.15.3",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@tabler/icons-react": "^3.28.1",
|
"@tabler/icons-react": "^3.28.1",
|
||||||
"@tanstack/react-query": "^5.64.2",
|
"@tanstack/react-query": "^5.64.2",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-joyride": "^2.9.3",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^6.28.2",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
@@ -772,6 +774,12 @@
|
|||||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@gilbarbara/deep-equal": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -1282,6 +1290,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@simplewebauthn/browser": {
|
||||||
|
"version": "13.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||||
|
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tabler/icons": {
|
"node_modules/@tabler/icons": {
|
||||||
"version": "3.36.1",
|
"version": "3.36.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",
|
||||||
@@ -1464,14 +1478,12 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.28",
|
"version": "18.3.28",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -1813,6 +1825,22 @@
|
|||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-diff": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/deepmerge": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -2165,6 +2193,12 @@
|
|||||||
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
|
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-lite": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -2326,6 +2360,17 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/popper.js": {
|
||||||
|
"version": "1.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||||
|
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
|
||||||
|
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -2523,12 +2568,84 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-floater": {
|
||||||
|
"version": "0.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz",
|
||||||
|
"integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"is-lite": "^0.8.2",
|
||||||
|
"popper.js": "^1.16.0",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"tree-changes": "^0.9.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "15 - 18",
|
||||||
|
"react-dom": "15 - 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-floater/node_modules/@gilbarbara/deep-equal": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-floater/node_modules/is-lite": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-floater/node_modules/tree-changes": {
|
||||||
|
"version": "0.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz",
|
||||||
|
"integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.1.1",
|
||||||
|
"is-lite": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-innertext": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=0.0.0 <=99",
|
||||||
|
"react": ">=0.0.0 <=99"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-joyride": {
|
||||||
|
"version": "2.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz",
|
||||||
|
"integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.3.1",
|
||||||
|
"deep-diff": "^1.0.2",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"is-lite": "^1.2.1",
|
||||||
|
"react-floater": "^0.7.9",
|
||||||
|
"react-innertext": "^1.1.5",
|
||||||
|
"react-is": "^16.13.1",
|
||||||
|
"scroll": "^3.0.1",
|
||||||
|
"scrollparent": "^2.1.0",
|
||||||
|
"tree-changes": "^0.11.2",
|
||||||
|
"type-fest": "^4.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "15 - 18",
|
||||||
|
"react-dom": "15 - 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-number-format": {
|
"node_modules/react-number-format": {
|
||||||
"version": "5.4.4",
|
"version": "5.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
|
||||||
@@ -2790,6 +2907,18 @@
|
|||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/scroll": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/scrollparent": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -2862,6 +2991,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tree-changes": {
|
||||||
|
"version": "0.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz",
|
||||||
|
"integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@gilbarbara/deep-equal": "^0.3.1",
|
||||||
|
"is-lite": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.2-beta",
|
"version": "2026.3.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,31 +11,33 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/hooks": "^7.15.3",
|
|
||||||
"@mantine/form": "^7.15.3",
|
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
"@mantine/notifications": "^7.15.3",
|
"@mantine/form": "^7.15.3",
|
||||||
|
"@mantine/hooks": "^7.15.3",
|
||||||
"@mantine/modals": "^7.15.3",
|
"@mantine/modals": "^7.15.3",
|
||||||
|
"@mantine/notifications": "^7.15.3",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@tabler/icons-react": "^3.28.1",
|
"@tabler/icons-react": "^3.28.1",
|
||||||
|
"@tanstack/react-query": "^5.64.2",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"d3-sankey": "^0.12.3",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-joyride": "^2.9.3",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^6.28.2",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"d3-sankey": "^0.12.3",
|
"zustand": "^4.5.5"
|
||||||
"zustand": "^4.5.5",
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"@tanstack/react-query": "^5.64.2",
|
|
||||||
"dayjs": "^1.11.13"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/d3-sankey": "^0.12.4",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/d3-sankey": "^0.12.4",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"vite": "^5.4.14",
|
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1"
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^5.4.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AppLayout } from './components/layout/AppLayout';
|
|||||||
import { LoginPage } from './pages/auth/LoginPage';
|
import { LoginPage } from './pages/auth/LoginPage';
|
||||||
import { RegisterPage } from './pages/auth/RegisterPage';
|
import { RegisterPage } from './pages/auth/RegisterPage';
|
||||||
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
|
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
|
||||||
|
import { ActivatePage } from './pages/auth/ActivatePage';
|
||||||
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
||||||
import { AccountsPage } from './pages/accounts/AccountsPage';
|
import { AccountsPage } from './pages/accounts/AccountsPage';
|
||||||
import { TransactionsPage } from './pages/transactions/TransactionsPage';
|
import { TransactionsPage } from './pages/transactions/TransactionsPage';
|
||||||
@@ -31,6 +32,15 @@ import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroups
|
|||||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||||
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||||
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
||||||
|
import { InvestmentScenariosPage } from './pages/board-planning/InvestmentScenariosPage';
|
||||||
|
import { InvestmentScenarioDetailPage } from './pages/board-planning/InvestmentScenarioDetailPage';
|
||||||
|
import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenariosPage';
|
||||||
|
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
|
||||||
|
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
||||||
|
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
||||||
|
import { PricingPage } from './pages/pricing/PricingPage';
|
||||||
|
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
||||||
|
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -71,6 +81,12 @@ function AuthRoute({ children }: { children: React.ReactNode }) {
|
|||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public routes (no auth required) */}
|
||||||
|
<Route path="/pricing" element={<PricingPage />} />
|
||||||
|
<Route path="/activate" element={<ActivatePage />} />
|
||||||
|
<Route path="/onboarding/pending" element={<OnboardingPendingPage />} />
|
||||||
|
|
||||||
|
{/* Auth routes (redirect if already logged in) */}
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
@@ -95,6 +111,18 @@ export function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Onboarding (requires auth but not org selection) */}
|
||||||
|
<Route
|
||||||
|
path="/onboarding"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<OnboardingPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
@@ -105,6 +133,8 @@ export function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<AdminPage />} />
|
<Route index element={<AdminPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Main app routes (require auth + org) */}
|
||||||
<Route
|
<Route
|
||||||
path="/*"
|
path="/*"
|
||||||
element={
|
element={
|
||||||
@@ -137,6 +167,12 @@ export function App() {
|
|||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||||
|
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
|
||||||
|
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
||||||
|
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
||||||
|
<Route path="board-planning/assessments" element={<AssessmentScenariosPage />} />
|
||||||
|
<Route path="board-planning/assessments/:id" element={<AssessmentScenarioDetailPage />} />
|
||||||
|
<Route path="board-planning/compare" element={<ScenarioComparisonPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
|
|||||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconLogout,
|
IconLogout,
|
||||||
@@ -9,17 +9,21 @@ import {
|
|||||||
IconUserCog,
|
IconUserCog,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
|
IconSun,
|
||||||
|
IconMoon,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { AppTour } from '../onboarding/AppTour';
|
import { AppTour } from '../onboarding/AppTour';
|
||||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
import logoSrc from '../../assets/logo.svg';
|
import logoSrc from '../../assets/logo.png';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [opened, { toggle, close }] = useDisclosure();
|
const [opened, { toggle, close }] = useDisclosure();
|
||||||
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||||
|
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
@@ -102,12 +106,31 @@ export function AppLayout() {
|
|||||||
<Group h={60} px="md" justify="space-between">
|
<Group h={60} px="md" justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt="HOA LedgerIQ"
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
...(colorScheme === 'dark' ? {
|
||||||
|
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||||
|
} : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
{currentOrg && (
|
{currentOrg && (
|
||||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||||
)}
|
)}
|
||||||
|
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
onClick={toggleColorScheme}
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
>
|
||||||
|
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
<Menu shadow="md" width={220}>
|
<Menu shadow="md" width={220}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
IconChartAreaLine,
|
IconChartAreaLine,
|
||||||
IconClipboardCheck,
|
IconClipboardCheck,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHeartRateMonitor,
|
IconCalculator,
|
||||||
|
IconGitCompare,
|
||||||
|
IconScale,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -43,6 +45,30 @@ const navSections = [
|
|||||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Board Planning',
|
||||||
|
items: [
|
||||||
|
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
|
||||||
|
{
|
||||||
|
label: 'Projects', icon: IconShieldCheck, path: '/projects',
|
||||||
|
children: [
|
||||||
|
{ label: 'Capital Planning', path: '/capital-projects' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
||||||
|
},
|
||||||
|
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
||||||
|
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
||||||
|
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Board Reference',
|
||||||
|
items: [
|
||||||
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
items: [
|
items: [
|
||||||
@@ -51,15 +77,6 @@ const navSections = [
|
|||||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Planning',
|
|
||||||
items: [
|
|
||||||
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
|
||||||
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
|
||||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
|
||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
items: [
|
items: [
|
||||||
@@ -141,7 +158,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{section.items.map((item: any) =>
|
{section.items.map((item: any) =>
|
||||||
item.children ? (
|
item.children && !item.path ? (
|
||||||
|
// Collapsible group without a parent route (e.g. Reports)
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.label}
|
key={item.label}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
@@ -160,6 +178,29 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
) : item.children && item.path ? (
|
||||||
|
// Parent with its own route + nested children (e.g. Projects > Capital Planning)
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
label={item.label}
|
||||||
|
leftSection={<item.icon size={18} />}
|
||||||
|
defaultOpened={
|
||||||
|
location.pathname === item.path ||
|
||||||
|
item.children.some((c: any) => location.pathname.startsWith(c.path))
|
||||||
|
}
|
||||||
|
data-tour={item.tourId || undefined}
|
||||||
|
active={location.pathname === item.path}
|
||||||
|
onClick={() => go(item.path!)}
|
||||||
|
>
|
||||||
|
{item.children.map((child: any) => (
|
||||||
|
<NavLink
|
||||||
|
key={child.path}
|
||||||
|
label={child.label}
|
||||||
|
active={location.pathname === child.path}
|
||||||
|
onClick={(e: React.MouseEvent) => { e.stopPropagation(); go(child.path); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</NavLink>
|
||||||
) : (
|
) : (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import '@mantine/core/styles.css';
|
|||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { theme } from './theme/theme';
|
import { defaultTheme, compactTheme } from './theme/theme';
|
||||||
|
import { usePreferencesStore } from './stores/preferencesStore';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -21,9 +22,13 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
function Root() {
|
||||||
<React.StrictMode>
|
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
||||||
<MantineProvider theme={theme}>
|
const compactView = usePreferencesStore((s) => s.compactView);
|
||||||
|
const activeTheme = compactView ? compactTheme : defaultTheme;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineProvider theme={activeTheme} forceColorScheme={colorScheme}>
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -33,5 +38,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Root />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ export function AccountsPage() {
|
|||||||
{investments.filter(i => i.is_active).length > 0 && (
|
{investments.filter(i => i.is_active).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -605,7 +605,7 @@ export function AccountsPage() {
|
|||||||
{operatingInvestments.length > 0 && (
|
{operatingInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -623,7 +623,7 @@ export function AccountsPage() {
|
|||||||
{reserveInvestments.length > 0 && (
|
{reserveInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1087,9 +1087,11 @@ function AccountTable({
|
|||||||
function InvestmentMiniTable({
|
function InvestmentMiniTable({
|
||||||
investments,
|
investments,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
isReadOnly = false,
|
||||||
}: {
|
}: {
|
||||||
investments: Investment[];
|
investments: Investment[];
|
||||||
onEdit: (inv: Investment) => void;
|
onEdit: (inv: Investment) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
||||||
const totalValue = investments.reduce(
|
const totalValue = investments.reduce(
|
||||||
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Th ta="right">Maturity Value</Table.Th>
|
<Table.Th ta="right">Maturity Value</Table.Th>
|
||||||
<Table.Th>Maturity Date</Table.Th>
|
<Table.Th>Maturity Date</Table.Th>
|
||||||
<Table.Th ta="right">Days Remaining</Table.Th>
|
<Table.Th ta="right">Days Remaining</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
{!isReadOnly && <Table.Th></Table.Th>}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -1182,6 +1184,7 @@ function InvestmentMiniTable({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Tooltip label="Edit investment">
|
<Tooltip label="Edit investment">
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
||||||
@@ -1189,6 +1192,7 @@ function InvestmentMiniTable({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
)}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||||
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
|
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
|
||||||
|
MultiSelect,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
@@ -21,6 +22,8 @@ interface AssessmentGroup {
|
|||||||
special_assessment: string;
|
special_assessment: string;
|
||||||
unit_count: number;
|
unit_count: number;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
|
due_months: number[];
|
||||||
|
due_day: number;
|
||||||
actual_unit_count: string;
|
actual_unit_count: string;
|
||||||
monthly_operating_income: string;
|
monthly_operating_income: string;
|
||||||
monthly_reserve_income: string;
|
monthly_reserve_income: string;
|
||||||
@@ -49,6 +52,29 @@ const frequencyColors: Record<string, string> = {
|
|||||||
annual: 'violet',
|
annual: 'violet',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MONTH_OPTIONS = [
|
||||||
|
{ value: '1', label: 'January' },
|
||||||
|
{ value: '2', label: 'February' },
|
||||||
|
{ value: '3', label: 'March' },
|
||||||
|
{ value: '4', label: 'April' },
|
||||||
|
{ value: '5', label: 'May' },
|
||||||
|
{ value: '6', label: 'June' },
|
||||||
|
{ value: '7', label: 'July' },
|
||||||
|
{ value: '8', label: 'August' },
|
||||||
|
{ value: '9', label: 'September' },
|
||||||
|
{ value: '10', label: 'October' },
|
||||||
|
{ value: '11', label: 'November' },
|
||||||
|
{ value: '12', label: 'December' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTH_ABBREV = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
const DEFAULT_DUE_MONTHS: Record<string, string[]> = {
|
||||||
|
monthly: ['1','2','3','4','5','6','7','8','9','10','11','12'],
|
||||||
|
quarterly: ['1','4','7','10'],
|
||||||
|
annual: ['1'],
|
||||||
|
};
|
||||||
|
|
||||||
export function AssessmentGroupsPage() {
|
export function AssessmentGroupsPage() {
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
||||||
@@ -73,18 +99,31 @@ export function AssessmentGroupsPage() {
|
|||||||
specialAssessment: 0,
|
specialAssessment: 0,
|
||||||
unitCount: 0,
|
unitCount: 0,
|
||||||
frequency: 'monthly',
|
frequency: 'monthly',
|
||||||
|
dueMonths: DEFAULT_DUE_MONTHS.monthly,
|
||||||
|
dueDay: 1,
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
name: (v) => (v.length > 0 ? null : 'Required'),
|
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
|
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
|
||||||
|
dueMonths: (v, values) => {
|
||||||
|
if (values.frequency === 'quarterly' && v.length !== 4) return 'Quarterly requires exactly 4 months';
|
||||||
|
if (values.frequency === 'annual' && v.length !== 1) return 'Annual requires exactly 1 month';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
dueDay: (v) => (v >= 1 && v <= 28 ? null : 'Must be 1-28'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: any) =>
|
mutationFn: (values: any) => {
|
||||||
editing
|
const payload = {
|
||||||
? api.put(`/assessment-groups/${editing.id}`, values)
|
...values,
|
||||||
: api.post('/assessment-groups', values),
|
dueMonths: values.dueMonths.map(Number),
|
||||||
|
};
|
||||||
|
return editing
|
||||||
|
? api.put(`/assessment-groups/${editing.id}`, payload)
|
||||||
|
: api.post('/assessment-groups', payload);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
|
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
|
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
|
||||||
@@ -121,6 +160,9 @@ export function AssessmentGroupsPage() {
|
|||||||
|
|
||||||
const handleEdit = (group: AssessmentGroup) => {
|
const handleEdit = (group: AssessmentGroup) => {
|
||||||
setEditing(group);
|
setEditing(group);
|
||||||
|
const dueMonths = group.due_months
|
||||||
|
? group.due_months.map(String)
|
||||||
|
: DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly;
|
||||||
form.setValues({
|
form.setValues({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
description: group.description || '',
|
description: group.description || '',
|
||||||
@@ -128,6 +170,8 @@ export function AssessmentGroupsPage() {
|
|||||||
specialAssessment: parseFloat(group.special_assessment || '0'),
|
specialAssessment: parseFloat(group.special_assessment || '0'),
|
||||||
unitCount: group.unit_count || 0,
|
unitCount: group.unit_count || 0,
|
||||||
frequency: group.frequency || 'monthly',
|
frequency: group.frequency || 'monthly',
|
||||||
|
dueMonths,
|
||||||
|
dueDay: group.due_day || 1,
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
@@ -138,6 +182,12 @@ export function AssessmentGroupsPage() {
|
|||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFrequencyChange = (value: string | null) => {
|
||||||
|
if (!value) return;
|
||||||
|
form.setFieldValue('frequency', value);
|
||||||
|
form.setFieldValue('dueMonths', DEFAULT_DUE_MONTHS[value] || DEFAULT_DUE_MONTHS.monthly);
|
||||||
|
};
|
||||||
|
|
||||||
const fmt = (v: string | number) =>
|
const fmt = (v: string | number) =>
|
||||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
@@ -149,6 +199,11 @@ export function AssessmentGroupsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDueMonths = (months: number[], frequency: string) => {
|
||||||
|
if (!months || frequency === 'monthly') return 'Every month';
|
||||||
|
return months.map((m) => MONTH_ABBREV[m]).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -219,6 +274,7 @@ export function AssessmentGroupsPage() {
|
|||||||
<Table.Th>Group Name</Table.Th>
|
<Table.Th>Group Name</Table.Th>
|
||||||
<Table.Th ta="center">Units</Table.Th>
|
<Table.Th ta="center">Units</Table.Th>
|
||||||
<Table.Th>Frequency</Table.Th>
|
<Table.Th>Frequency</Table.Th>
|
||||||
|
<Table.Th>Due Months</Table.Th>
|
||||||
<Table.Th ta="right">Regular Assessment</Table.Th>
|
<Table.Th ta="right">Regular Assessment</Table.Th>
|
||||||
<Table.Th ta="right">Special Assessment</Table.Th>
|
<Table.Th ta="right">Special Assessment</Table.Th>
|
||||||
<Table.Th ta="right">Monthly Equiv.</Table.Th>
|
<Table.Th ta="right">Monthly Equiv.</Table.Th>
|
||||||
@@ -229,7 +285,7 @@ export function AssessmentGroupsPage() {
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{groups.length === 0 && (
|
{groups.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={8}>
|
<Table.Td colSpan={9}>
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
|
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -263,6 +319,9 @@ export function AssessmentGroupsPage() {
|
|||||||
{frequencyLabels[g.frequency] || 'Monthly'}
|
{frequencyLabels[g.frequency] || 'Monthly'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">{formatDueMonths(g.due_months, g.frequency)}</Text>
|
||||||
|
</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">
|
<Table.Td ta="right" ff="monospace">
|
||||||
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
|
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -322,8 +381,22 @@ export function AssessmentGroupsPage() {
|
|||||||
{ value: 'quarterly', label: 'Quarterly' },
|
{ value: 'quarterly', label: 'Quarterly' },
|
||||||
{ value: 'annual', label: 'Annual' },
|
{ value: 'annual', label: 'Annual' },
|
||||||
]}
|
]}
|
||||||
{...form.getInputProps('frequency')}
|
value={form.values.frequency}
|
||||||
|
onChange={handleFrequencyChange}
|
||||||
/>
|
/>
|
||||||
|
{form.values.frequency !== 'monthly' && (
|
||||||
|
<MultiSelect
|
||||||
|
label={form.values.frequency === 'quarterly' ? 'Billing Quarters (select 4 months)' : 'Due Month'}
|
||||||
|
description={form.values.frequency === 'quarterly'
|
||||||
|
? 'Select the first month of each quarter when assessments are due'
|
||||||
|
: 'Select the month when the annual assessment is due'}
|
||||||
|
data={MONTH_OPTIONS}
|
||||||
|
value={form.values.dueMonths}
|
||||||
|
onChange={(v) => form.setFieldValue('dueMonths', v)}
|
||||||
|
error={form.errors.dueMonths}
|
||||||
|
maxValues={form.values.frequency === 'annual' ? 1 : 4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
|
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
|
||||||
@@ -340,7 +413,16 @@ export function AssessmentGroupsPage() {
|
|||||||
{...form.getInputProps('specialAssessment')}
|
{...form.getInputProps('specialAssessment')}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group grow>
|
||||||
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
|
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
|
||||||
|
<NumberInput
|
||||||
|
label="Due Day of Month"
|
||||||
|
description="Day invoices are due (1-28)"
|
||||||
|
min={1}
|
||||||
|
max={28}
|
||||||
|
{...form.getInputProps('dueDay')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
<Button type="submit" loading={saveMutation.isPending}>
|
<Button type="submit" loading={saveMutation.isPending}>
|
||||||
{editing ? 'Update' : 'Create'}
|
{editing ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
179
frontend/src/pages/auth/ActivatePage.tsx
Normal file
179
frontend/src/pages/auth/ActivatePage.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container, Paper, Title, Text, TextInput, PasswordInput,
|
||||||
|
Button, Stack, Alert, Center, Loader, Progress, Anchor,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconAlertCircle, IconCheck, IconShieldCheck } from '@tabler/icons-react';
|
||||||
|
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import logoSrc from '../../assets/logo.png';
|
||||||
|
|
||||||
|
function getPasswordStrength(pw: string): number {
|
||||||
|
let score = 0;
|
||||||
|
if (pw.length >= 8) score += 25;
|
||||||
|
if (pw.length >= 12) score += 15;
|
||||||
|
if (/[A-Z]/.test(pw)) score += 20;
|
||||||
|
if (/[a-z]/.test(pw)) score += 10;
|
||||||
|
if (/[0-9]/.test(pw)) score += 15;
|
||||||
|
if (/[^A-Za-z0-9]/.test(pw)) score += 15;
|
||||||
|
return Math.min(score, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function strengthColor(s: number): string {
|
||||||
|
if (s < 40) return 'red';
|
||||||
|
if (s < 70) return 'orange';
|
||||||
|
return 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivatePage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const [validating, setValidating] = useState(true);
|
||||||
|
const [tokenInfo, setTokenInfo] = useState<any>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: { fullName: '', password: '', confirmPassword: '' },
|
||||||
|
validate: {
|
||||||
|
fullName: (v) => (v.trim().length >= 2 ? null : 'Name is required'),
|
||||||
|
password: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'),
|
||||||
|
confirmPassword: (v, values) => (v === values.password ? null : 'Passwords do not match'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setError('No activation token provided');
|
||||||
|
setValidating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.get(`/auth/activate?token=${token}`)
|
||||||
|
.then(({ data }) => {
|
||||||
|
setTokenInfo(data);
|
||||||
|
setValidating(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.response?.data?.message || 'Invalid or expired activation link');
|
||||||
|
setValidating(false);
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: typeof form.values) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/auth/activate', {
|
||||||
|
token,
|
||||||
|
password: values.password,
|
||||||
|
fullName: values.fullName,
|
||||||
|
});
|
||||||
|
setAuth(data.accessToken, data.user, data.organizations);
|
||||||
|
navigate('/onboarding');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'Activation failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength(form.values.password);
|
||||||
|
|
||||||
|
if (validating) {
|
||||||
|
return (
|
||||||
|
<Container size={420} my={80}>
|
||||||
|
<Center><Loader size="lg" /></Center>
|
||||||
|
<Text ta="center" mt="md" c="dimmed">Validating activation link...</Text>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !tokenInfo) {
|
||||||
|
return (
|
||||||
|
<Container size={420} my={80}>
|
||||||
|
<Center>
|
||||||
|
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
||||||
|
</Center>
|
||||||
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light" mb="md">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
<Stack>
|
||||||
|
<Anchor component={Link} to="/login" size="sm" ta="center">
|
||||||
|
Go to Login
|
||||||
|
</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size={420} my={80}>
|
||||||
|
<Center>
|
||||||
|
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
||||||
|
</Center>
|
||||||
|
<Text ta="center" mt={5} c="dimmed" size="sm">
|
||||||
|
Activate your account for <strong>{tokenInfo?.orgName || 'your organization'}</strong>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
{error && (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Full Name"
|
||||||
|
placeholder="John Doe"
|
||||||
|
required
|
||||||
|
{...form.getInputProps('fullName')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Create a strong password"
|
||||||
|
required
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
{form.values.password && (
|
||||||
|
<Progress
|
||||||
|
value={passwordStrength}
|
||||||
|
color={strengthColor(passwordStrength)}
|
||||||
|
size="xs"
|
||||||
|
mt={4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Confirm Password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
required
|
||||||
|
{...form.getInputProps('confirmPassword')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconShieldCheck size={16} />}
|
||||||
|
>
|
||||||
|
Activate Account
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
@@ -10,19 +10,44 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Stack,
|
Stack,
|
||||||
Alert,
|
Alert,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
PinInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { IconAlertCircle } from '@tabler/icons-react';
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconBrandGoogle,
|
||||||
|
IconBrandWindows,
|
||||||
|
IconFingerprint,
|
||||||
|
IconShieldLock,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import logoSrc from '../../assets/logo.svg';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
import logoSrc from '../../assets/logo.png';
|
||||||
|
|
||||||
|
type LoginState = 'credentials' | 'mfa';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [loginState, setLoginState] = useState<LoginState>('credentials');
|
||||||
|
const [mfaToken, setMfaToken] = useState('');
|
||||||
|
const [mfaCode, setMfaCode] = useState('');
|
||||||
|
const [useRecovery, setUseRecovery] = useState(false);
|
||||||
|
const [recoveryCode, setRecoveryCode] = useState('');
|
||||||
|
const [ssoProviders, setSsoProviders] = useState<{ google: boolean; azure: boolean }>({
|
||||||
|
google: false,
|
||||||
|
azure: false,
|
||||||
|
});
|
||||||
|
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: { email: '', password: '' },
|
initialValues: { email: '', password: '' },
|
||||||
@@ -32,20 +57,42 @@ export function LoginPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch SSO providers & check passkey support on mount
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get('/auth/sso/providers')
|
||||||
|
.then(({ data }) => setSsoProviders(data))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
if (
|
||||||
|
window.PublicKeyCredential &&
|
||||||
|
typeof window.PublicKeyCredential === 'function'
|
||||||
|
) {
|
||||||
|
setPasskeySupported(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoginSuccess = (data: any) => {
|
||||||
|
setAuth(data.accessToken, data.user, data.organizations);
|
||||||
|
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
||||||
|
navigate('/admin');
|
||||||
|
} else if (data.organizations.length >= 1) {
|
||||||
|
navigate('/select-org');
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values: typeof form.values) => {
|
const handleSubmit = async (values: typeof form.values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/login', values);
|
const { data } = await api.post('/auth/login', values);
|
||||||
setAuth(data.accessToken, data.user, data.organizations);
|
if (data.mfaRequired) {
|
||||||
// Platform owner / superadmin with no orgs → admin panel
|
setMfaToken(data.mfaToken);
|
||||||
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
setLoginState('mfa');
|
||||||
navigate('/admin');
|
|
||||||
} else if (data.organizations.length >= 1) {
|
|
||||||
// Always go through org selection to ensure correct JWT with orgSchema
|
|
||||||
navigate('/select-org');
|
|
||||||
} else {
|
} else {
|
||||||
navigate('/');
|
handleLoginSuccess(data);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || 'Login failed');
|
setError(err.response?.data?.message || 'Login failed');
|
||||||
@@ -54,10 +101,197 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMfaVerify = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const token = useRecovery ? recoveryCode : mfaCode;
|
||||||
|
const { data } = await api.post('/auth/mfa/verify', {
|
||||||
|
mfaToken,
|
||||||
|
token,
|
||||||
|
isRecoveryCode: useRecovery,
|
||||||
|
});
|
||||||
|
handleLoginSuccess(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'MFA verification failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasskeyLogin = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
// Get authentication options
|
||||||
|
const { data: options } = await api.post('/auth/passkeys/login-options', {
|
||||||
|
email: form.values.email || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger browser WebAuthn prompt
|
||||||
|
const credential = await startAuthentication({ optionsJSON: options });
|
||||||
|
|
||||||
|
// Verify with server
|
||||||
|
const { data } = await api.post('/auth/passkeys/login', {
|
||||||
|
response: credential,
|
||||||
|
challenge: options.challenge,
|
||||||
|
});
|
||||||
|
handleLoginSuccess(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
setError('Passkey authentication was cancelled');
|
||||||
|
} else {
|
||||||
|
setError(err.response?.data?.message || err.message || 'Passkey login failed');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSso = ssoProviders.google || ssoProviders.azure;
|
||||||
|
|
||||||
|
// MFA verification screen
|
||||||
|
if (loginState === 'mfa') {
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={80}>
|
<Container size={420} my={80}>
|
||||||
<Center>
|
<Center>
|
||||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 60 }} />
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt="HOA LedgerIQ"
|
||||||
|
style={{
|
||||||
|
height: 60,
|
||||||
|
...(isDark
|
||||||
|
? {
|
||||||
|
filter:
|
||||||
|
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
|
||||||
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
|
<Stack>
|
||||||
|
<Group gap="xs" justify="center">
|
||||||
|
<IconShieldLock size={24} />
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
Two-Factor Authentication
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!useRecovery ? (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
Enter the 6-digit code from your authenticator app
|
||||||
|
</Text>
|
||||||
|
<Center>
|
||||||
|
<PinInput
|
||||||
|
length={6}
|
||||||
|
type="number"
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={setMfaCode}
|
||||||
|
oneTimeCode
|
||||||
|
autoFocus
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleMfaVerify}
|
||||||
|
disabled={mfaCode.length !== 6}
|
||||||
|
>
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
ta="center"
|
||||||
|
onClick={() => {
|
||||||
|
setUseRecovery(true);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Use a recovery code instead
|
||||||
|
</Anchor>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
Enter one of your recovery codes
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="xxxxxxxx"
|
||||||
|
value={recoveryCode}
|
||||||
|
onChange={(e) => setRecoveryCode(e.currentTarget.value)}
|
||||||
|
autoFocus
|
||||||
|
ff="monospace"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleMfaVerify}
|
||||||
|
disabled={!recoveryCode.trim()}
|
||||||
|
>
|
||||||
|
Verify Recovery Code
|
||||||
|
</Button>
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
ta="center"
|
||||||
|
onClick={() => {
|
||||||
|
setUseRecovery(false);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Use authenticator code instead
|
||||||
|
</Anchor>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
ta="center"
|
||||||
|
onClick={() => {
|
||||||
|
setLoginState('credentials');
|
||||||
|
setMfaToken('');
|
||||||
|
setMfaCode('');
|
||||||
|
setRecoveryCode('');
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
← Back to login
|
||||||
|
</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main login form
|
||||||
|
return (
|
||||||
|
<Container size={420} my={80}>
|
||||||
|
<Center>
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt="HOA LedgerIQ"
|
||||||
|
style={{
|
||||||
|
height: 60,
|
||||||
|
...(isDark
|
||||||
|
? {
|
||||||
|
filter:
|
||||||
|
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
@@ -91,6 +325,53 @@ export function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Passkey login */}
|
||||||
|
{passkeySupported && (
|
||||||
|
<>
|
||||||
|
<Divider label="or" labelPosition="center" my="md" />
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
fullWidth
|
||||||
|
leftSection={<IconFingerprint size={18} />}
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Sign in with Passkey
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SSO providers */}
|
||||||
|
{hasSso && (
|
||||||
|
<>
|
||||||
|
<Divider label="or continue with" labelPosition="center" my="md" />
|
||||||
|
<Group grow>
|
||||||
|
{ssoProviders.google && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconBrandGoogle size={18} color="#4285F4" />}
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = '/api/auth/google';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{ssoProviders.azure && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconBrandWindows size={18} color="#0078D4" />}
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = '/api/auth/azure';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Microsoft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -120,11 +120,6 @@ export function SelectOrgPage() {
|
|||||||
<Text fw={500}>{org.name}</Text>
|
<Text fw={500}>{org.name}</Text>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Badge size="sm" variant="light">{org.role}</Badge>
|
<Badge size="sm" variant="light">{org.role}</Badge>
|
||||||
{org.schemaName && (
|
|
||||||
<Badge size="xs" variant="dot" color="gray">
|
|
||||||
{org.schemaName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||||
|
Loader, Center, Select, SimpleGrid, Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconPlus, IconArrowLeft, IconTrash, IconEdit,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { AssessmentChangeForm } from './components/AssessmentChangeForm';
|
||||||
|
import { ProjectionChart } from './components/ProjectionChart';
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTypeLabels: Record<string, string> = {
|
||||||
|
dues_increase: 'Dues Increase',
|
||||||
|
dues_decrease: 'Dues Decrease',
|
||||||
|
special_assessment: 'Special Assessment',
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTypeColors: Record<string, string> = {
|
||||||
|
dues_increase: 'green',
|
||||||
|
dues_decrease: 'orange',
|
||||||
|
special_assessment: 'violet',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AssessmentScenarioDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [editAsmt, setEditAsmt] = useState<any>(null);
|
||||||
|
|
||||||
|
const { data: scenario, isLoading } = useQuery({
|
||||||
|
queryKey: ['board-planning-scenario', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/board-planning/scenarios/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: projection, isLoading: projLoading } = useQuery({
|
||||||
|
queryKey: ['board-planning-projection', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/assessments`, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||||
|
setAddOpen(false);
|
||||||
|
notifications.show({ message: 'Assessment change added', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ asmtId, ...dto }: any) => api.put(`/board-planning/assessments/${asmtId}`, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||||
|
setEditAsmt(null);
|
||||||
|
notifications.show({ message: 'Assessment change updated', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (asmtId: string) => api.delete(`/board-planning/assessments/${asmtId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||||
|
notifications.show({ message: 'Assessment change removed', color: 'orange' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (status: string) => api.put(`/board-planning/scenarios/${id}`, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
|
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||||
|
|
||||||
|
const assessments = scenario.assessments || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Group>
|
||||||
|
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/assessments')}>
|
||||||
|
<IconArrowLeft size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
<div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Title order={2}>{scenario.name}</Title>
|
||||||
|
<Badge color={statusColors[scenario.status]}>{scenario.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
{scenario.description && <Text c="dimmed" size="sm">{scenario.description}</Text>}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
value={scenario.status}
|
||||||
|
onChange={(v) => v && statusMutation.mutate(v)}
|
||||||
|
data={[
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'active', label: 'Active' },
|
||||||
|
{ value: 'approved', label: 'Approved' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
||||||
|
Add Change
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{projection?.summary && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">{fmt(projection.summary.end_liquidity || 0)}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
||||||
|
<Text fw={700} size="xl" ff="monospace" c={projection.summary.period_change >= 0 ? 'green' : 'red'}>
|
||||||
|
{projection.summary.period_change >= 0 ? '+' : ''}{fmt(projection.summary.period_change || 0)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Min Liquidity</Text>
|
||||||
|
<Text fw={700} size="xl" ff="monospace" c={projection.summary.min_liquidity < 0 ? 'red' : undefined}>
|
||||||
|
{fmt(projection.summary.min_liquidity || 0)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Coverage</Text>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{projection.summary.reserve_coverage_months > 0
|
||||||
|
? `${projection.summary.reserve_coverage_months.toFixed(1)} mo`
|
||||||
|
: 'N/A'}
|
||||||
|
</Text>
|
||||||
|
{projection.summary.reserve_coverage_months <= 0 && (
|
||||||
|
<Text size="xs" c="dimmed">No planned capital projects</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assessment Changes Table */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Title order={4} mb="md">Assessment Changes ({assessments.length})</Title>
|
||||||
|
{assessments.length > 0 ? (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Label</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Target Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Change</Table.Th>
|
||||||
|
<Table.Th>Effective</Table.Th>
|
||||||
|
<Table.Th>End</Table.Th>
|
||||||
|
<Table.Th w={80}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{assessments.map((a: any) => (
|
||||||
|
<Table.Tr key={a.id}>
|
||||||
|
<Table.Td fw={500}>{a.label}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={changeTypeColors[a.change_type] || 'gray'}>
|
||||||
|
{changeTypeLabels[a.change_type] || a.change_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{a.target_fund}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{a.change_type === 'special_assessment'
|
||||||
|
? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit${(() => {
|
||||||
|
const inst = parseInt(a.special_installments) || 1;
|
||||||
|
if (inst === 1) return ', one-time';
|
||||||
|
if (inst === 3) return ', quarterly';
|
||||||
|
if (inst === 12) return ', annual';
|
||||||
|
return `, ${inst} mo`;
|
||||||
|
})()}`
|
||||||
|
: a.percentage_change
|
||||||
|
? `${parseFloat(a.percentage_change).toFixed(1)}%`
|
||||||
|
: a.flat_amount_change
|
||||||
|
? `${fmt(parseFloat(a.flat_amount_change))}/unit/mo`
|
||||||
|
: '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{a.effective_date ? new Date(a.effective_date).toLocaleDateString() : '-'}</Table.Td>
|
||||||
|
<Table.Td>{a.end_date ? new Date(a.end_date).toLocaleDateString() : 'Ongoing'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Tooltip label="Edit">
|
||||||
|
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditAsmt(a)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Remove">
|
||||||
|
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(a.id)}>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No assessment changes added yet. Click "Add Change" to model a dues increase or special assessment.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Projection Chart */}
|
||||||
|
{projection && (
|
||||||
|
<ProjectionChart
|
||||||
|
datapoints={projection.datapoints || []}
|
||||||
|
title="Assessment Impact Projection"
|
||||||
|
summary={projection.summary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
<AssessmentChangeForm
|
||||||
|
opened={addOpen || !!editAsmt}
|
||||||
|
onClose={() => { setAddOpen(false); setEditAsmt(null); }}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
if (editAsmt) {
|
||||||
|
updateMutation.mutate({ asmtId: editAsmt.id, ...data });
|
||||||
|
} else {
|
||||||
|
addMutation.mutate(data);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialData={editAsmt}
|
||||||
|
loading={addMutation.isPending || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/pages/board-planning/AssessmentScenariosPage.tsx
Normal file
128
frontend/src/pages/board-planning/AssessmentScenariosPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Title, Text, Stack, Group, Button, SimpleGrid, Modal, TextInput, Textarea, Loader, Center } from '@mantine/core';
|
||||||
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { ScenarioCard } from './components/ScenarioCard';
|
||||||
|
|
||||||
|
export function AssessmentScenariosPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editScenario, setEditScenario] = useState<any>(null);
|
||||||
|
const [form, setForm] = useState({ name: '', description: '' });
|
||||||
|
|
||||||
|
const { data: scenarios, isLoading } = useQuery<any[]>({
|
||||||
|
queryKey: ['board-planning-scenarios', 'assessment'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/board-planning/scenarios?type=assessment');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (dto: any) => api.post('/board-planning/scenarios', dto),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({ name: '', description: '' });
|
||||||
|
notifications.show({ message: 'Scenario created', color: 'green' });
|
||||||
|
navigate(`/board-planning/assessments/${res.data.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...dto }: any) => api.put(`/board-planning/scenarios/${id}`, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
setEditScenario(null);
|
||||||
|
notifications.show({ message: 'Scenario updated', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/board-planning/scenarios/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
notifications.show({ message: 'Scenario archived', color: 'orange' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Assessment Scenarios</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Model dues increases, special assessments, and their impact on cash flow and reserves
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||||
|
New Scenario
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{scenarios && scenarios.length > 0 ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||||
|
{scenarios.map((s) => (
|
||||||
|
<ScenarioCard
|
||||||
|
key={s.id}
|
||||||
|
scenario={s}
|
||||||
|
onClick={() => navigate(`/board-planning/assessments/${s.id}`)}
|
||||||
|
onEdit={() => { setEditScenario(s); setForm({ name: s.name, description: s.description || '' }); }}
|
||||||
|
onDelete={() => deleteMutation.mutate(s.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<Text c="dimmed">No assessment scenarios yet</Text>
|
||||||
|
<Text size="sm" c="dimmed" maw={400} ta="center">
|
||||||
|
Create a scenario to model dues increases, special assessments, and multi-year assessment planning.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title="New Assessment Scenario">
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. 5% Annual Increase" />
|
||||||
|
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe this assessment strategy..." />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createMutation.mutate({ name: form.name, description: form.description, scenarioType: 'assessment' })}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
disabled={!form.name}
|
||||||
|
>
|
||||||
|
Create Scenario
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal opened={!!editScenario} onClose={() => setEditScenario(null)} title="Edit Scenario">
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setEditScenario(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => updateMutation.mutate({ id: editScenario.id, name: form.name, description: form.description })}
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
776
frontend/src/pages/board-planning/BudgetPlanningPage.tsx
Normal file
776
frontend/src/pages/board-planning/BudgetPlanningPage.tsx
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
|
Select, Loader, Center, Badge, Card, Alert, Modal,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
|
||||||
|
IconCheck, IconArrowBack, IconTrash, IconRefresh,
|
||||||
|
IconUpload, IconDownload,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
|
interface PlanLine {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
account_number: string;
|
||||||
|
account_name: string;
|
||||||
|
account_type: string;
|
||||||
|
fund_type: string;
|
||||||
|
is_manually_adjusted: boolean;
|
||||||
|
jan: number; feb: number; mar: number; apr: number;
|
||||||
|
may: number; jun: number; jul: number; aug: number;
|
||||||
|
sep: number; oct: number; nov: number; dec_amt: number;
|
||||||
|
annual_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthKeys = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||||
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||||
|
|
||||||
|
function hydrateLine(row: any): PlanLine {
|
||||||
|
const line: any = { ...row };
|
||||||
|
for (const m of monthKeys) {
|
||||||
|
line[m] = Number(line[m]) || 0;
|
||||||
|
}
|
||||||
|
line.annual_total = monthKeys.reduce((sum, m) => sum + (line[m] || 0), 0);
|
||||||
|
return line as PlanLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCurrencyValue(val: string): number {
|
||||||
|
if (!val) return 0;
|
||||||
|
let s = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.trim().split('\n');
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||||
|
const rows: Record<string, string>[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let j = 0; j < line.length; j++) {
|
||||||
|
const ch = line[j];
|
||||||
|
if (ch === '"') { inQuotes = !inQuotes; }
|
||||||
|
else if (ch === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
|
||||||
|
else { current += ch; }
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((h, idx) => { row[h] = values[idx] || ''; });
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
planning: 'blue',
|
||||||
|
approved: 'yellow',
|
||||||
|
ratified: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BudgetPlanningPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
|
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||||
|
|
||||||
|
const [selectedYear, setSelectedYear] = useState<string | null>(null);
|
||||||
|
const [lineData, setLineData] = useState<PlanLine[]>([]);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [inflationInput, setInflationInput] = useState<number>(2.5);
|
||||||
|
const [confirmModal, setConfirmModal] = useState<{ action: string; title: string; message: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Available years
|
||||||
|
const { data: availableYears } = useQuery<any>({
|
||||||
|
queryKey: ['budget-plan-available-years'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/board-planning/budget-plans/available-years');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set default year when available
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableYears?.years?.length && !selectedYear) {
|
||||||
|
setSelectedYear(String(availableYears.years[0].year));
|
||||||
|
}
|
||||||
|
}, [availableYears, selectedYear]);
|
||||||
|
|
||||||
|
// Plan data for selected year
|
||||||
|
const { data: plan, isLoading } = useQuery<any>({
|
||||||
|
queryKey: ['budget-plan', selectedYear],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/board-planning/budget-plans/${selectedYear}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!selectedYear,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hydrate lines when plan changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (plan?.lines) {
|
||||||
|
setLineData(plan.lines.map(hydrateLine));
|
||||||
|
setInflationInput(parseFloat(plan.inflation_rate) || 2.5);
|
||||||
|
setIsEditing(false);
|
||||||
|
} else {
|
||||||
|
setLineData([]);
|
||||||
|
}
|
||||||
|
}, [plan]);
|
||||||
|
|
||||||
|
const hasBaseBudget = !!availableYears?.latestBudgetYear;
|
||||||
|
|
||||||
|
const yearOptions = (availableYears?.years || []).map((y: any) => ({
|
||||||
|
value: String(y.year),
|
||||||
|
label: `${y.year}${y.hasPlan ? ` (${y.status})` : ''}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// If no base budget at all, also offer the current year as an option
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const allYearOptions = !hasBaseBudget && !yearOptions.find((y: any) => y.value === String(currentYear))
|
||||||
|
? [{ value: String(currentYear), label: String(currentYear) }, ...yearOptions]
|
||||||
|
: yearOptions;
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const fiscalYear = parseInt(selectedYear!, 10);
|
||||||
|
const baseYear = availableYears?.latestBudgetYear || new Date().getFullYear();
|
||||||
|
const { data } = await api.post('/board-planning/budget-plans', {
|
||||||
|
fiscalYear,
|
||||||
|
baseYear,
|
||||||
|
inflationRate: inflationInput,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
|
||||||
|
notifications.show({ message: 'Budget plan created', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Create failed', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = lineData.map((l) => ({
|
||||||
|
accountId: l.account_id,
|
||||||
|
fundType: l.fund_type,
|
||||||
|
jan: l.jan, feb: l.feb, mar: l.mar, apr: l.apr,
|
||||||
|
may: l.may, jun: l.jun, jul: l.jul, aug: l.aug,
|
||||||
|
sep: l.sep, oct: l.oct, nov: l.nov, dec: l.dec_amt,
|
||||||
|
}));
|
||||||
|
return api.put(`/board-planning/budget-plans/${selectedYear}/lines`, {
|
||||||
|
planId: plan.id,
|
||||||
|
lines: payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||||
|
setIsEditing(false);
|
||||||
|
notifications.show({ message: 'Budget plan saved', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Save failed', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: async (lines: Record<string, string>[]) => {
|
||||||
|
const parsed = lines.map((row) => ({
|
||||||
|
account_number: row.account_number || row.accountnumber || '',
|
||||||
|
account_name: row.account_name || row.accountname || '',
|
||||||
|
jan: parseCurrencyValue(row.jan),
|
||||||
|
feb: parseCurrencyValue(row.feb),
|
||||||
|
mar: parseCurrencyValue(row.mar),
|
||||||
|
apr: parseCurrencyValue(row.apr),
|
||||||
|
may: parseCurrencyValue(row.may),
|
||||||
|
jun: parseCurrencyValue(row.jun),
|
||||||
|
jul: parseCurrencyValue(row.jul),
|
||||||
|
aug: parseCurrencyValue(row.aug),
|
||||||
|
sep: parseCurrencyValue(row.sep),
|
||||||
|
oct: parseCurrencyValue(row.oct),
|
||||||
|
nov: parseCurrencyValue(row.nov),
|
||||||
|
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
|
||||||
|
}));
|
||||||
|
const { data } = await api.post(`/board-planning/budget-plans/${selectedYear}/import`, parsed);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
let msg = `Imported ${data.imported} budget line(s)`;
|
||||||
|
if (data.created?.length) msg += `. Created ${data.created.length} new account(s)`;
|
||||||
|
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
|
||||||
|
notifications.show({
|
||||||
|
message: msg,
|
||||||
|
color: data.errors?.length ? 'yellow' : 'green',
|
||||||
|
autoClose: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const inflationMutation = useMutation({
|
||||||
|
mutationFn: () => api.put(`/board-planning/budget-plans/${selectedYear}/inflation`, {
|
||||||
|
inflationRate: inflationInput,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||||
|
notifications.show({ message: 'Inflation rate applied', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (status: string) => api.put(`/board-planning/budget-plans/${selectedYear}/status`, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budgets'] });
|
||||||
|
setConfirmModal(null);
|
||||||
|
notifications.show({ message: 'Status updated', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Status update failed', color: 'red' });
|
||||||
|
setConfirmModal(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/board-planning/budget-plans/${selectedYear}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
|
||||||
|
setConfirmModal(null);
|
||||||
|
notifications.show({ message: 'Budget plan deleted', color: 'orange' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCell = (idx: number, month: string, value: number) => {
|
||||||
|
const updated = [...lineData];
|
||||||
|
(updated[idx] as any)[month] = value || 0;
|
||||||
|
updated[idx].annual_total = monthKeys.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
|
||||||
|
setLineData(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const yr = selectedYear || currentYear;
|
||||||
|
const response = await api.get(`/board-planning/budget-plans/${yr}/template`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `budget_template_${yr}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err: any) {
|
||||||
|
notifications.show({ message: 'Failed to download template', color: 'red' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportCSV = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (rows.length === 0) { notifications.show({ message: 'No data rows found in CSV', color: 'red' }); return; }
|
||||||
|
importMutation.mutate(rows);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPlan = !!plan?.id;
|
||||||
|
const status = plan?.status || 'planning';
|
||||||
|
const cellsEditable = !isReadOnly && isEditing && status !== 'ratified';
|
||||||
|
|
||||||
|
const incomeLines = lineData.filter((b) => b.account_type === 'income');
|
||||||
|
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
|
||||||
|
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
|
||||||
|
const expenseLines = lineData.filter((b) => b.account_type === 'expense');
|
||||||
|
const totalOperatingIncome = operatingIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
|
||||||
|
const totalReserveIncome = reserveIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
|
||||||
|
const totalExpense = expenseLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Group align="center">
|
||||||
|
<Title order={2}>Budget Planning</Title>
|
||||||
|
{hasPlan && (
|
||||||
|
<Badge size="lg" color={statusColors[status]}>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
data={allYearOptions}
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={setSelectedYear}
|
||||||
|
w={180}
|
||||||
|
placeholder="Select year"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Download Template
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Hidden file input for CSV import */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
accept=".csv,.txt"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && <Center h={300}><Loader /></Center>}
|
||||||
|
|
||||||
|
{/* Empty state - no base budget exists at all */}
|
||||||
|
{!isLoading && !hasPlan && selectedYear && !hasBaseBudget && (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="orange" variant="light">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text fw={600}>No budget data found in the system</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
To get started with budget planning, you need to load an initial budget.
|
||||||
|
You can either create a new budget from scratch or import an existing budget from a CSV file.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Use <Text span fw={600}>Download Template</Text> above to get a CSV with your chart of accounts pre-populated,
|
||||||
|
fill in the monthly amounts, then import it below.
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconUpload size={16} />}
|
||||||
|
onClick={handleImportCSV}
|
||||||
|
loading={importMutation.isPending}
|
||||||
|
>
|
||||||
|
Import Budget from CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
>
|
||||||
|
Create Empty Budget Plan
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state - base budget exists but no plan for this year */}
|
||||||
|
{!isLoading && !hasPlan && selectedYear && hasBaseBudget && (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text>No budget plan exists for {selectedYear}. Create one based on the {availableYears?.latestBudgetYear} budget with an inflation adjustment, or import a CSV directly.</Text>
|
||||||
|
<Group>
|
||||||
|
<NumberInput
|
||||||
|
label="Inflation Rate (%)"
|
||||||
|
value={inflationInput}
|
||||||
|
onChange={(v) => setInflationInput(Number(v) || 0)}
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step={0.5}
|
||||||
|
decimalScale={2}
|
||||||
|
w={160}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
mt={24}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
>
|
||||||
|
Create Budget Plan for {selectedYear}
|
||||||
|
</Button>
|
||||||
|
<Text mt={24} c="dimmed">or</Text>
|
||||||
|
<Button
|
||||||
|
mt={24}
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconUpload size={16} />}
|
||||||
|
onClick={handleImportCSV}
|
||||||
|
loading={importMutation.isPending}
|
||||||
|
>
|
||||||
|
Import from CSV
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Base year: {availableYears?.latestBudgetYear}. Each monthly amount will be compounded annually by the specified inflation rate.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plan controls */}
|
||||||
|
{hasPlan && (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<NumberInput
|
||||||
|
label="Inflation Rate (%)"
|
||||||
|
value={inflationInput}
|
||||||
|
onChange={(v) => setInflationInput(Number(v) || 0)}
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step={0.5}
|
||||||
|
decimalScale={2}
|
||||||
|
w={140}
|
||||||
|
size="xs"
|
||||||
|
disabled={status === 'ratified' || isReadOnly}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
mt={24}
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmModal({
|
||||||
|
action: 'inflation',
|
||||||
|
title: 'Apply Inflation Rate',
|
||||||
|
message: `This will recalculate all non-manually-adjusted lines using ${inflationInput}% inflation compounded annually from the base year (${plan.base_year}). Manually adjusted lines will be preserved.`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={status === 'ratified' || isReadOnly}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Text size="xs" c="dimmed" mt={24}>Base year: {plan.base_year}</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
{/* Import CSV into existing plan */}
|
||||||
|
{status !== 'ratified' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconUpload size={16} />}
|
||||||
|
onClick={handleImportCSV}
|
||||||
|
loading={importMutation.isPending}
|
||||||
|
>
|
||||||
|
Import CSV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status actions */}
|
||||||
|
{status === 'planning' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
leftSection={<IconCheck size={16} />}
|
||||||
|
onClick={() => setConfirmModal({
|
||||||
|
action: 'approved',
|
||||||
|
title: 'Approve Budget Plan',
|
||||||
|
message: `Mark the ${selectedYear} budget plan as approved? This indicates the board has reviewed and accepted the plan.`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
onClick={() => setConfirmModal({
|
||||||
|
action: 'delete',
|
||||||
|
title: 'Delete Budget Plan',
|
||||||
|
message: `Permanently delete the ${selectedYear} budget plan? This cannot be undone.`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'approved' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconArrowBack size={16} />}
|
||||||
|
onClick={() => setConfirmModal({
|
||||||
|
action: 'planning',
|
||||||
|
title: 'Revert to Planning',
|
||||||
|
message: `Revert the ${selectedYear} budget plan back to planning status?`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Revert to Planning
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="green"
|
||||||
|
leftSection={<IconCheck size={16} />}
|
||||||
|
onClick={() => setConfirmModal({
|
||||||
|
action: 'ratified',
|
||||||
|
title: 'Ratify Budget',
|
||||||
|
message: `Ratify the ${selectedYear} budget? This will create the official budget for ${selectedYear} in Financials, overwriting any existing budget data for that year.`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Ratify Budget
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'ratified' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<IconArrowBack size={16} />}
|
||||||
|
onClick={() => setConfirmModal({
|
||||||
|
action: 'approved',
|
||||||
|
title: 'Revert from Ratified',
|
||||||
|
message: `Revert the ${selectedYear} budget from ratified to approved? This will remove the official budget for ${selectedYear} from Financials.`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Revert to Approved
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit/Save */}
|
||||||
|
{status !== 'ratified' && (
|
||||||
|
<>
|
||||||
|
{!isEditing ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconPencil size={16} />}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<IconX size={16} />}
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<Group>
|
||||||
|
<Card withBorder p="sm">
|
||||||
|
<Text size="xs" c="dimmed">Operating Income</Text>
|
||||||
|
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
|
||||||
|
</Card>
|
||||||
|
{totalReserveIncome > 0 && (
|
||||||
|
<Card withBorder p="sm">
|
||||||
|
<Text size="xs" c="dimmed">Reserve Income</Text>
|
||||||
|
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Card withBorder p="sm">
|
||||||
|
<Text size="xs" c="dimmed">Total Expenses</Text>
|
||||||
|
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="sm">
|
||||||
|
<Text size="xs" c="dimmed">Net (Operating)</Text>
|
||||||
|
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
||||||
|
{fmt(totalOperatingIncome - totalExpense)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
||||||
|
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
||||||
|
{monthLabels.map((m) => (
|
||||||
|
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||||
|
))}
|
||||||
|
<Table.Th ta="right" style={{ minWidth: 110 }}>Annual</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{lineData.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={15}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">No budget plan lines.</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{['income', 'expense'].map((type) => {
|
||||||
|
const lines = lineData.filter((b) => b.account_type === type);
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
|
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
|
||||||
|
const sectionTotal = lines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Table.Tr key={`header-${type}`} style={{ background: sectionBg }}>
|
||||||
|
<Table.Td
|
||||||
|
colSpan={2}
|
||||||
|
fw={700}
|
||||||
|
tt="capitalize"
|
||||||
|
style={{ position: 'sticky', left: 0, background: sectionBg, zIndex: 2 }}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</Table.Td>
|
||||||
|
{monthLabels.map((m) => <Table.Td key={m} />)}
|
||||||
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(sectionTotal)}</Table.Td>
|
||||||
|
</Table.Tr>,
|
||||||
|
...lines.map((line) => {
|
||||||
|
const idx = lineData.indexOf(line);
|
||||||
|
return (
|
||||||
|
<Table.Tr key={line.id || `${line.account_id}-${line.fund_type}`}>
|
||||||
|
<Table.Td
|
||||||
|
style={{
|
||||||
|
position: 'sticky', left: 0, background: stickyBg,
|
||||||
|
zIndex: 1, borderRight: `1px solid ${stickyBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td
|
||||||
|
style={{
|
||||||
|
position: 'sticky', left: 120, background: stickyBg,
|
||||||
|
zIndex: 1, borderRight: `1px solid ${stickyBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>{line.account_name}</Text>
|
||||||
|
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
|
||||||
|
{line.is_manually_adjusted && <Badge size="xs" color="orange" variant="dot">edited</Badge>}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
{monthKeys.map((m) => (
|
||||||
|
<Table.Td key={m} p={2}>
|
||||||
|
{cellsEditable ? (
|
||||||
|
<NumberInput
|
||||||
|
value={(line as any)[m] || 0}
|
||||||
|
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||||
|
size="xs"
|
||||||
|
hideControls
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" ta="right" ff="monospace">
|
||||||
|
{fmt((line as any)[m] || 0)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" fw={500} ff="monospace">
|
||||||
|
{fmt(line.annual_total || 0)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation modal */}
|
||||||
|
<Modal
|
||||||
|
opened={!!confirmModal}
|
||||||
|
onClose={() => setConfirmModal(null)}
|
||||||
|
title={confirmModal?.title || ''}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">{confirmModal?.message}</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color={confirmModal?.action === 'delete' ? 'red' : undefined}
|
||||||
|
loading={statusMutation.isPending || deleteMutation.isPending || inflationMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirmModal) return;
|
||||||
|
if (confirmModal.action === 'delete') {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
} else if (confirmModal.action === 'inflation') {
|
||||||
|
inflationMutation.mutate();
|
||||||
|
setConfirmModal(null);
|
||||||
|
} else {
|
||||||
|
statusMutation.mutate(confirmModal.action);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||||
|
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
|
import {
|
||||||
|
IconPlus, IconArrowLeft, IconTrash, IconEdit,
|
||||||
|
IconPlayerPlay, IconCoin, IconTrendingUp,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { InvestmentForm } from './components/InvestmentForm';
|
||||||
|
import { ProjectionChart } from './components/ProjectionChart';
|
||||||
|
import { InvestmentTimeline } from './components/InvestmentTimeline';
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
const fmtDec = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvestmentScenarioDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [editInv, setEditInv] = useState<any>(null);
|
||||||
|
const [executeInv, setExecuteInv] = useState<any>(null);
|
||||||
|
const [executionDate, setExecutionDate] = useState<Date | null>(new Date());
|
||||||
|
|
||||||
|
const { data: scenario, isLoading } = useQuery({
|
||||||
|
queryKey: ['board-planning-scenario', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/board-planning/scenarios/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: projection, isLoading: projLoading } = useQuery({
|
||||||
|
queryKey: ['board-planning-projection', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||||
|
setAddOpen(false);
|
||||||
|
notifications.show({ message: 'Investment added', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ invId, ...dto }: any) => api.put(`/board-planning/investments/${invId}`, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||||
|
setEditInv(null);
|
||||||
|
notifications.show({ message: 'Investment updated', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (invId: string) => api.delete(`/board-planning/investments/${invId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||||
|
notifications.show({ message: 'Investment removed', color: 'orange' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeMutation = useMutation({
|
||||||
|
mutationFn: ({ invId, executionDate }: { invId: string; executionDate: string }) =>
|
||||||
|
api.post(`/board-planning/investments/${invId}/execute`, { executionDate }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||||
|
setExecuteInv(null);
|
||||||
|
notifications.show({ message: 'Investment executed and recorded', color: 'green' });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Execution failed', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (status: string) => api.put(`/board-planning/scenarios/${id}`, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
|
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||||
|
|
||||||
|
const investments = scenario.investments || [];
|
||||||
|
const summary = projection?.summary;
|
||||||
|
|
||||||
|
// Build a lookup of per-investment interest from the projection
|
||||||
|
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
||||||
|
if (summary?.investment_interest_details) {
|
||||||
|
for (const d of summary.investment_interest_details) {
|
||||||
|
interestDetailMap[d.id] = { interest: d.interest, principal: d.principal };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Group>
|
||||||
|
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/investments')}>
|
||||||
|
<IconArrowLeft size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
<div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Title order={2}>{scenario.name}</Title>
|
||||||
|
<Badge color={statusColors[scenario.status]}>{scenario.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
{scenario.description && <Text c="dimmed" size="sm">{scenario.description}</Text>}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
value={scenario.status}
|
||||||
|
onChange={(v) => v && statusMutation.mutate(v)}
|
||||||
|
data={[
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'active', label: 'Active' },
|
||||||
|
{ value: 'approved', label: 'Approved' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
||||||
|
Add Investment
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summary && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Principal</Text>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">{fmt(summary.total_principal_invested || 0)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{investments.filter((i: any) => !i.executed_investment_id).length} planned investments</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Projected Interest Earned</Text>
|
||||||
|
<Text fw={700} size="xl" ff="monospace" c="green">
|
||||||
|
{summary.total_interest_earned > 0 ? `+${fmtDec(summary.total_interest_earned)}` : '$0.00'}
|
||||||
|
</Text>
|
||||||
|
{summary.total_interest_earned > 0 && (
|
||||||
|
<Text size="xs" c="dimmed">Over projection period</Text>
|
||||||
|
)}
|
||||||
|
{summary.total_interest_earned === 0 && investments.length > 0 && (
|
||||||
|
<Text size="xs" c="orange">Set purchase & maturity dates to calculate</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Return on Investment</Text>
|
||||||
|
<Text fw={700} size="xl" ff="monospace" c={summary.roi_percentage > 0 ? 'green' : undefined}>
|
||||||
|
{summary.roi_percentage > 0 ? `${summary.roi_percentage.toFixed(2)}%` : '-'}
|
||||||
|
</Text>
|
||||||
|
{summary.roi_percentage > 0 && (
|
||||||
|
<Text size="xs" c="dimmed">Interest / Principal</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">{fmt(summary.end_liquidity || 0)}</Text>
|
||||||
|
<Text size="xs" c={summary.period_change >= 0 ? 'green' : 'red'}>
|
||||||
|
{summary.period_change >= 0 ? '+' : ''}{fmt(summary.period_change || 0)} over period
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Investments Table */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Title order={4} mb="md">Planned Investments ({investments.length})</Title>
|
||||||
|
{investments.length > 0 ? (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Label</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Principal</Table.Th>
|
||||||
|
<Table.Th ta="right">Rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Est. Interest</Table.Th>
|
||||||
|
<Table.Th>Purchase</Table.Th>
|
||||||
|
<Table.Th>Maturity</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th w={100}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{investments.map((inv: any) => {
|
||||||
|
const detail = interestDetailMap[inv.id];
|
||||||
|
return (
|
||||||
|
<Table.Tr key={inv.id}>
|
||||||
|
<Table.Td fw={500}>{inv.label}</Table.Td>
|
||||||
|
<Table.Td><Badge size="sm" variant="light">{inv.investment_type || '-'}</Badge></Table.Td>
|
||||||
|
<Table.Td><Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}>{inv.fund_type}</Badge></Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">{fmt(parseFloat(inv.principal))}</Table.Td>
|
||||||
|
<Table.Td ta="right">{inv.interest_rate ? `${parseFloat(inv.interest_rate).toFixed(2)}%` : '-'}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace" c={detail?.interest ? 'green' : 'dimmed'}>
|
||||||
|
{detail?.interest ? `+${fmtDec(detail.interest)}` : '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : <Text size="sm" c="orange">-</Text>}</Table.Td>
|
||||||
|
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : <Text size="sm" c="orange">-</Text>}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{inv.executed_investment_id
|
||||||
|
? <Badge size="sm" color="green">Executed</Badge>
|
||||||
|
: <Badge size="sm" color="gray">Planned</Badge>}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Tooltip label="Edit">
|
||||||
|
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditInv(inv)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
{!inv.executed_investment_id && (
|
||||||
|
<Tooltip label="Execute">
|
||||||
|
<ActionIcon variant="subtle" color="green" size="sm" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
|
||||||
|
<IconPlayerPlay size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Remove">
|
||||||
|
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(inv.id)}>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No investments added yet. Click "Add Investment" to model an investment allocation.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Investment Timeline */}
|
||||||
|
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
|
||||||
|
|
||||||
|
{/* Projection Chart */}
|
||||||
|
{projection && (
|
||||||
|
<ProjectionChart
|
||||||
|
datapoints={projection.datapoints || []}
|
||||||
|
title="Scenario Projection"
|
||||||
|
summary={projection.summary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||||
|
|
||||||
|
{/* Add/Edit Investment Modal */}
|
||||||
|
<InvestmentForm
|
||||||
|
opened={addOpen || !!editInv}
|
||||||
|
onClose={() => { setAddOpen(false); setEditInv(null); }}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
if (editInv) {
|
||||||
|
updateMutation.mutate({ invId: editInv.id, ...data });
|
||||||
|
} else {
|
||||||
|
addMutation.mutate(data);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialData={editInv}
|
||||||
|
loading={addMutation.isPending || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Execute Confirmation Modal */}
|
||||||
|
<Modal opened={!!executeInv} onClose={() => setExecuteInv(null)} title="Execute Investment">
|
||||||
|
<Stack>
|
||||||
|
<Alert color="blue" variant="light">
|
||||||
|
This will create a real investment account record and post a journal entry transferring funds.
|
||||||
|
</Alert>
|
||||||
|
{executeInv && (
|
||||||
|
<>
|
||||||
|
<Text size="sm"><strong>Investment:</strong> {executeInv.label}</Text>
|
||||||
|
<Text size="sm"><strong>Amount:</strong> {fmt(parseFloat(executeInv.principal))}</Text>
|
||||||
|
<DateInput
|
||||||
|
label="Execution Date"
|
||||||
|
required
|
||||||
|
value={executionDate}
|
||||||
|
onChange={setExecutionDate}
|
||||||
|
description="The date the investment is actually purchased"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setExecuteInv(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
leftSection={<IconPlayerPlay size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
if (executeInv && executionDate) {
|
||||||
|
executeMutation.mutate({
|
||||||
|
invId: executeInv.id,
|
||||||
|
executionDate: executionDate.toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={executeMutation.isPending}
|
||||||
|
>
|
||||||
|
Execute Investment
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/pages/board-planning/InvestmentScenariosPage.tsx
Normal file
128
frontend/src/pages/board-planning/InvestmentScenariosPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Title, Text, Stack, Group, Button, SimpleGrid, Modal, TextInput, Textarea, Loader, Center } from '@mantine/core';
|
||||||
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { ScenarioCard } from './components/ScenarioCard';
|
||||||
|
|
||||||
|
export function InvestmentScenariosPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editScenario, setEditScenario] = useState<any>(null);
|
||||||
|
const [form, setForm] = useState({ name: '', description: '' });
|
||||||
|
|
||||||
|
const { data: scenarios, isLoading } = useQuery<any[]>({
|
||||||
|
queryKey: ['board-planning-scenarios', 'investment'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/board-planning/scenarios?type=investment');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (dto: any) => api.post('/board-planning/scenarios', dto),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({ name: '', description: '' });
|
||||||
|
notifications.show({ message: 'Scenario created', color: 'green' });
|
||||||
|
navigate(`/board-planning/investments/${res.data.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...dto }: any) => api.put(`/board-planning/scenarios/${id}`, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
setEditScenario(null);
|
||||||
|
notifications.show({ message: 'Scenario updated', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/board-planning/scenarios/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
notifications.show({ message: 'Scenario archived', color: 'orange' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Investment Scenarios</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Model different investment strategies and compare their impact on liquidity and income
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||||
|
New Scenario
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{scenarios && scenarios.length > 0 ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||||
|
{scenarios.map((s) => (
|
||||||
|
<ScenarioCard
|
||||||
|
key={s.id}
|
||||||
|
scenario={s}
|
||||||
|
onClick={() => navigate(`/board-planning/investments/${s.id}`)}
|
||||||
|
onEdit={() => { setEditScenario(s); setForm({ name: s.name, description: s.description || '' }); }}
|
||||||
|
onDelete={() => deleteMutation.mutate(s.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<Text c="dimmed">No investment scenarios yet</Text>
|
||||||
|
<Text size="sm" c="dimmed" maw={400} ta="center">
|
||||||
|
Create a scenario to model investment allocations, timing, and their impact on reserves and liquidity.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title="New Investment Scenario">
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Conservative CD Ladder" />
|
||||||
|
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe this investment strategy..." />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createMutation.mutate({ name: form.name, description: form.description, scenarioType: 'investment' })}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
disabled={!form.name}
|
||||||
|
>
|
||||||
|
Create Scenario
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal opened={!!editScenario} onClose={() => setEditScenario(null)} title="Edit Scenario">
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
<Textarea label="Description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setEditScenario(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => updateMutation.mutate({ id: editScenario.id, name: form.name, description: form.description })}
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
Normal file
210
frontend/src/pages/board-planning/ScenarioComparisonPage.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Stack, Group, Card, MultiSelect, Loader, Center, Badge,
|
||||||
|
SimpleGrid, Table,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const COLORS = ['#228be6', '#40c057', '#7950f2', '#fd7e14'];
|
||||||
|
|
||||||
|
export function ScenarioComparisonPage() {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Load all scenarios for the selector
|
||||||
|
const { data: allScenarios } = useQuery<any[]>({
|
||||||
|
queryKey: ['board-planning-scenarios-all'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/board-planning/scenarios');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load comparison data when scenarios are selected
|
||||||
|
const { data: comparison, isLoading: compLoading } = useQuery({
|
||||||
|
queryKey: ['board-planning-compare', selectedIds],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/board-planning/compare?ids=${selectedIds.join(',')}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: selectedIds.length >= 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectorData = (allScenarios || []).map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.name} (${s.scenario_type})`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Build merged chart data with all scenarios
|
||||||
|
const chartData = (() => {
|
||||||
|
if (!comparison?.scenarios?.length) return [];
|
||||||
|
const firstScenario = comparison.scenarios[0];
|
||||||
|
if (!firstScenario?.projection?.datapoints) return [];
|
||||||
|
|
||||||
|
return firstScenario.projection.datapoints.map((_: any, idx: number) => {
|
||||||
|
const point: any = { month: firstScenario.projection.datapoints[idx].month };
|
||||||
|
comparison.scenarios.forEach((s: any, sIdx: number) => {
|
||||||
|
const dp = s.projection?.datapoints?.[idx];
|
||||||
|
if (dp) {
|
||||||
|
point[`total_${sIdx}`] =
|
||||||
|
dp.operating_cash + dp.operating_investments + dp.reserve_cash + dp.reserve_investments;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Compare Scenarios</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Select up to 4 scenarios to compare their projected financial impact side-by-side
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
label="Select Scenarios"
|
||||||
|
placeholder="Choose scenarios to compare..."
|
||||||
|
data={selectorData}
|
||||||
|
value={selectedIds}
|
||||||
|
onChange={setSelectedIds}
|
||||||
|
maxValues={4}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{compLoading && <Center py="xl"><Loader size="lg" /></Center>}
|
||||||
|
|
||||||
|
{comparison?.scenarios?.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Overlaid Line Chart */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Title order={4} mb="md">Total Liquidity Projection</Title>
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v: number) => fmt(v)}
|
||||||
|
labelStyle={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{comparison.scenarios.map((s: any, idx: number) => (
|
||||||
|
<Line
|
||||||
|
key={s.id}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`total_${idx}`}
|
||||||
|
name={s.name}
|
||||||
|
stroke={COLORS[idx]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Summary Metrics Comparison */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Title order={4} mb="md">Summary Comparison</Title>
|
||||||
|
<Table striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Metric</Table.Th>
|
||||||
|
{comparison.scenarios.map((s: any, idx: number) => (
|
||||||
|
<Table.Th key={s.id} ta="right">
|
||||||
|
<Group gap={4} justify="flex-end">
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: 2, background: COLORS[idx] }} />
|
||||||
|
<Text size="sm" fw={600}>{s.name}</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td fw={500}>End Liquidity</Table.Td>
|
||||||
|
{comparison.scenarios.map((s: any) => (
|
||||||
|
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
|
||||||
|
{fmt(s.projection?.summary?.end_liquidity || 0)}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td fw={500}>Minimum Liquidity</Table.Td>
|
||||||
|
{comparison.scenarios.map((s: any) => (
|
||||||
|
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}
|
||||||
|
c={(s.projection?.summary?.min_liquidity || 0) < 0 ? 'red' : undefined}
|
||||||
|
>
|
||||||
|
{fmt(s.projection?.summary?.min_liquidity || 0)}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td fw={500}>Period Change</Table.Td>
|
||||||
|
{comparison.scenarios.map((s: any) => {
|
||||||
|
const change = s.projection?.summary?.period_change || 0;
|
||||||
|
return (
|
||||||
|
<Table.Td key={s.id} ta="right" ff="monospace" fw={600} c={change >= 0 ? 'green' : 'red'}>
|
||||||
|
{change >= 0 ? '+' : ''}{fmt(change)}
|
||||||
|
</Table.Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td fw={500}>Reserve Coverage</Table.Td>
|
||||||
|
{comparison.scenarios.map((s: any) => (
|
||||||
|
<Table.Td key={s.id} ta="right" ff="monospace" fw={600}>
|
||||||
|
{(s.projection?.summary?.reserve_coverage_months || 0).toFixed(1)} months
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td fw={500}>End Operating Cash</Table.Td>
|
||||||
|
{comparison.scenarios.map((s: any) => (
|
||||||
|
<Table.Td key={s.id} ta="right" ff="monospace">
|
||||||
|
{fmt(s.projection?.summary?.end_operating_cash || 0)}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td fw={500}>End Reserve Cash</Table.Td>
|
||||||
|
{comparison.scenarios.map((s: any) => (
|
||||||
|
<Table.Td key={s.id} ta="right" ff="monospace">
|
||||||
|
{fmt(s.projection?.summary?.end_reserve_cash || 0)}
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Risk Flags */}
|
||||||
|
{comparison.scenarios.some((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0) && (
|
||||||
|
<Card withBorder p="lg" bg="red.0">
|
||||||
|
<Title order={4} c="red" mb="sm">Liquidity Warnings</Title>
|
||||||
|
{comparison.scenarios.filter((s: any) => (s.projection?.summary?.min_liquidity || 0) < 0).map((s: any) => (
|
||||||
|
<Text key={s.id} size="sm" c="red">
|
||||||
|
{s.name}: projected negative liquidity of {fmt(s.projection.summary.min_liquidity)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedIds.length === 0 && (
|
||||||
|
<Center py="xl">
|
||||||
|
<Text c="dimmed">Select one or more scenarios above to compare their financial projections</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Text } from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialData?: any;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssessmentChangeForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
changeType: 'dues_increase' as string,
|
||||||
|
label: '',
|
||||||
|
targetFund: 'operating',
|
||||||
|
percentageChange: 0,
|
||||||
|
flatAmountChange: 0,
|
||||||
|
specialTotal: 0,
|
||||||
|
specialPerUnit: 0,
|
||||||
|
specialInstallments: 1,
|
||||||
|
effectiveDate: null as Date | null,
|
||||||
|
endDate: null as Date | null,
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setForm({
|
||||||
|
changeType: initialData.change_type || initialData.changeType || 'dues_increase',
|
||||||
|
label: initialData.label || '',
|
||||||
|
targetFund: initialData.target_fund || initialData.targetFund || 'operating',
|
||||||
|
percentageChange: parseFloat(initialData.percentage_change || initialData.percentageChange) || 0,
|
||||||
|
flatAmountChange: parseFloat(initialData.flat_amount_change || initialData.flatAmountChange) || 0,
|
||||||
|
specialTotal: parseFloat(initialData.special_total || initialData.specialTotal) || 0,
|
||||||
|
specialPerUnit: parseFloat(initialData.special_per_unit || initialData.specialPerUnit) || 0,
|
||||||
|
specialInstallments: initialData.special_installments || initialData.specialInstallments || 1,
|
||||||
|
effectiveDate: initialData.effective_date ? new Date(initialData.effective_date) : null,
|
||||||
|
endDate: initialData.end_date ? new Date(initialData.end_date) : null,
|
||||||
|
notes: initialData.notes || '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
changeType: 'dues_increase', label: '', targetFund: 'operating',
|
||||||
|
percentageChange: 0, flatAmountChange: 0, specialTotal: 0, specialPerUnit: 0,
|
||||||
|
specialInstallments: 1, effectiveDate: null, endDate: null, notes: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialData, opened]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSubmit({
|
||||||
|
...form,
|
||||||
|
effectiveDate: form.effectiveDate?.toISOString().split('T')[0] || null,
|
||||||
|
endDate: form.endDate?.toISOString().split('T')[0] || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSpecial = form.changeType === 'special_assessment';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Assessment Change' : 'Add Assessment Change'} size="lg">
|
||||||
|
<Stack>
|
||||||
|
<Select
|
||||||
|
label="Change Type"
|
||||||
|
value={form.changeType}
|
||||||
|
onChange={(v) => setForm({ ...form, changeType: v || 'dues_increase' })}
|
||||||
|
data={[
|
||||||
|
{ value: 'dues_increase', label: 'Dues Increase' },
|
||||||
|
{ value: 'dues_decrease', label: 'Dues Decrease' },
|
||||||
|
{ value: 'special_assessment', label: 'Special Assessment' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Label"
|
||||||
|
required
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||||
|
placeholder={isSpecial ? 'e.g. Roof Replacement Assessment' : 'e.g. 5% Annual Increase'}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Target Fund"
|
||||||
|
value={form.targetFund}
|
||||||
|
onChange={(v) => setForm({ ...form, targetFund: v || 'operating' })}
|
||||||
|
data={[
|
||||||
|
{ value: 'operating', label: 'Operating' },
|
||||||
|
{ value: 'reserve', label: 'Reserve' },
|
||||||
|
{ value: 'both', label: 'Both' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isSpecial && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" fw={500} c="dimmed">Set either a percentage or flat amount (not both):</Text>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Percentage Change (%)"
|
||||||
|
value={form.percentageChange}
|
||||||
|
onChange={(v) => setForm({ ...form, percentageChange: Number(v) || 0, flatAmountChange: 0 })}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
decimalScale={2}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Flat Amount Change ($/unit/mo)"
|
||||||
|
value={form.flatAmountChange}
|
||||||
|
onChange={(v) => setForm({ ...form, flatAmountChange: Number(v) || 0, percentageChange: 0 })}
|
||||||
|
min={0}
|
||||||
|
decimalScale={2}
|
||||||
|
prefix="$"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSpecial && (
|
||||||
|
<>
|
||||||
|
<NumberInput
|
||||||
|
label="Per Unit Amount"
|
||||||
|
description="Total amount each unit will be assessed"
|
||||||
|
value={form.specialPerUnit}
|
||||||
|
onChange={(v) => setForm({ ...form, specialPerUnit: Number(v) || 0 })}
|
||||||
|
min={0}
|
||||||
|
decimalScale={2}
|
||||||
|
thousandSeparator=","
|
||||||
|
prefix="$"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Duration"
|
||||||
|
description="How the assessment is collected"
|
||||||
|
value={String(form.specialInstallments)}
|
||||||
|
onChange={(v) => setForm({ ...form, specialInstallments: Number(v) || 1 })}
|
||||||
|
data={[
|
||||||
|
{ value: '1', label: 'One-time (lump sum)' },
|
||||||
|
{ value: '3', label: 'Quarterly (3 monthly payments)' },
|
||||||
|
{ value: '6', label: '6 months' },
|
||||||
|
{ value: '12', label: 'Annual (12 monthly payments)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<DateInput label="Effective Date" required value={form.effectiveDate} onChange={(v) => setForm({ ...form, effectiveDate: v })} />
|
||||||
|
<DateInput label="End Date (optional)" value={form.endDate} onChange={(v) => setForm({ ...form, endDate: v })} clearable />
|
||||||
|
</Group>
|
||||||
|
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.effectiveDate}>
|
||||||
|
{initialData ? 'Update' : 'Add Change'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/pages/board-planning/components/InvestmentForm.tsx
Normal file
110
frontend/src/pages/board-planning/components/InvestmentForm.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Switch } from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
initialData?: any;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvestmentForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
label: '',
|
||||||
|
investmentType: 'cd',
|
||||||
|
fundType: 'reserve',
|
||||||
|
principal: 0,
|
||||||
|
interestRate: 0,
|
||||||
|
termMonths: 12,
|
||||||
|
institution: '',
|
||||||
|
purchaseDate: null as Date | null,
|
||||||
|
maturityDate: null as Date | null,
|
||||||
|
autoRenew: false,
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setForm({
|
||||||
|
label: initialData.label || '',
|
||||||
|
investmentType: initialData.investment_type || initialData.investmentType || 'cd',
|
||||||
|
fundType: initialData.fund_type || initialData.fundType || 'reserve',
|
||||||
|
principal: parseFloat(initialData.principal) || 0,
|
||||||
|
interestRate: parseFloat(initialData.interest_rate || initialData.interestRate) || 0,
|
||||||
|
termMonths: initialData.term_months || initialData.termMonths || 12,
|
||||||
|
institution: initialData.institution || '',
|
||||||
|
purchaseDate: initialData.purchase_date ? new Date(initialData.purchase_date) : null,
|
||||||
|
maturityDate: initialData.maturity_date ? new Date(initialData.maturity_date) : null,
|
||||||
|
autoRenew: initialData.auto_renew || initialData.autoRenew || false,
|
||||||
|
notes: initialData.notes || '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
label: '', investmentType: 'cd', fundType: 'reserve', principal: 0,
|
||||||
|
interestRate: 0, termMonths: 12, institution: '', purchaseDate: null,
|
||||||
|
maturityDate: null, autoRenew: false, notes: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialData, opened]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSubmit({
|
||||||
|
...form,
|
||||||
|
purchaseDate: form.purchaseDate?.toISOString().split('T')[0] || null,
|
||||||
|
maturityDate: form.maturityDate?.toISOString().split('T')[0] || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Investment' : 'Add Investment'} size="lg">
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Label" required value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="e.g. 6-Month Treasury" />
|
||||||
|
<Group grow>
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
value={form.investmentType}
|
||||||
|
onChange={(v) => setForm({ ...form, investmentType: v || 'cd' })}
|
||||||
|
data={[
|
||||||
|
{ value: 'cd', label: 'CD' },
|
||||||
|
{ value: 'money_market', label: 'Money Market' },
|
||||||
|
{ value: 'treasury', label: 'Treasury' },
|
||||||
|
{ value: 'savings', label: 'Savings' },
|
||||||
|
{ value: 'other', label: 'Other' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Fund"
|
||||||
|
value={form.fundType}
|
||||||
|
onChange={(v) => setForm({ ...form, fundType: v || 'reserve' })}
|
||||||
|
data={[
|
||||||
|
{ value: 'operating', label: 'Operating' },
|
||||||
|
{ value: 'reserve', label: 'Reserve' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput label="Principal ($)" required value={form.principal} onChange={(v) => setForm({ ...form, principal: Number(v) || 0 })} min={0} decimalScale={2} thousandSeparator="," prefix="$" />
|
||||||
|
<NumberInput label="Interest Rate (%)" value={form.interestRate} onChange={(v) => setForm({ ...form, interestRate: Number(v) || 0 })} min={0} max={20} decimalScale={3} suffix="%" />
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput label="Term (months)" value={form.termMonths} onChange={(v) => setForm({ ...form, termMonths: Number(v) || 0 })} min={1} max={120} />
|
||||||
|
<TextInput label="Institution" value={form.institution} onChange={(e) => setForm({ ...form, institution: e.target.value })} placeholder="e.g. First National Bank" />
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<DateInput label="Purchase Date" value={form.purchaseDate} onChange={(v) => setForm({ ...form, purchaseDate: v })} clearable />
|
||||||
|
<DateInput label="Maturity Date" value={form.maturityDate} onChange={(v) => setForm({ ...form, maturityDate: v })} clearable />
|
||||||
|
</Group>
|
||||||
|
<Switch label="Auto-renew at maturity" checked={form.autoRenew} onChange={(e) => setForm({ ...form, autoRenew: e.currentTarget.checked })} />
|
||||||
|
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.principal}>
|
||||||
|
{initialData ? 'Update' : 'Add Investment'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Card, Title, Text, Group, Badge, Tooltip } from '@mantine/core';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
cd: '#228be6',
|
||||||
|
money_market: '#40c057',
|
||||||
|
treasury: '#7950f2',
|
||||||
|
savings: '#fd7e14',
|
||||||
|
other: '#868e96',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
investments: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvestmentTimeline({ investments }: Props) {
|
||||||
|
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const items = investments
|
||||||
|
.filter((inv: any) => inv.purchase_date || inv.maturity_date)
|
||||||
|
.map((inv: any) => ({
|
||||||
|
...inv,
|
||||||
|
start: inv.purchase_date ? new Date(inv.purchase_date) : now,
|
||||||
|
end: inv.maturity_date ? new Date(inv.maturity_date) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
||||||
|
|
||||||
|
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
||||||
|
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||||
|
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||||
|
const totalMonths = Math.max(
|
||||||
|
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items, startDate, endDate, totalMonths };
|
||||||
|
}, [investments]);
|
||||||
|
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
const getPercent = (date: Date) => {
|
||||||
|
const months = (date.getFullYear() - startDate.getFullYear()) * 12 + (date.getMonth() - startDate.getMonth());
|
||||||
|
return Math.max(0, Math.min(100, (months / totalMonths) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate year labels
|
||||||
|
const yearLabels: { year: number; percent: number }[] = [];
|
||||||
|
for (let y = startDate.getFullYear(); y <= endDate.getFullYear(); y++) {
|
||||||
|
const janDate = new Date(y, 0, 1);
|
||||||
|
if (janDate >= startDate && janDate <= endDate) {
|
||||||
|
yearLabels.push({ year: y, percent: getPercent(janDate) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Title order={4} mb="md">Investment Timeline</Title>
|
||||||
|
|
||||||
|
{/* Year markers */}
|
||||||
|
<div style={{ position: 'relative', height: 20, marginBottom: 8 }}>
|
||||||
|
{yearLabels.map((yl) => (
|
||||||
|
<Text
|
||||||
|
key={yl.year}
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
fw={700}
|
||||||
|
style={{ position: 'absolute', left: `${yl.percent}%`, transform: 'translateX(-50%)' }}
|
||||||
|
>
|
||||||
|
{yl.year}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline bars */}
|
||||||
|
<div style={{ position: 'relative', minHeight: items.length * 40 + 10 }}>
|
||||||
|
{/* Background grid */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderLeft: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
borderRight: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
}}>
|
||||||
|
{yearLabels.map((yl) => (
|
||||||
|
<div
|
||||||
|
key={yl.year}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', left: `${yl.percent}%`, top: 0, bottom: 0,
|
||||||
|
borderLeft: '1px dashed var(--mantine-color-gray-3)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.map((inv: any, idx: number) => {
|
||||||
|
const leftPct = getPercent(inv.start);
|
||||||
|
const rightPct = inv.end ? getPercent(inv.end) : leftPct + 2;
|
||||||
|
const widthPct = Math.max(rightPct - leftPct, 1);
|
||||||
|
const color = typeColors[inv.investment_type] || '#868e96';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={inv.id}
|
||||||
|
label={
|
||||||
|
<div>
|
||||||
|
<Text size="xs" fw={600}>{inv.label}</Text>
|
||||||
|
<Text size="xs">{fmt(parseFloat(inv.principal))} @ {parseFloat(inv.interest_rate || 0).toFixed(2)}%</Text>
|
||||||
|
{inv.purchase_date && <Text size="xs">Start: {new Date(inv.purchase_date).toLocaleDateString()}</Text>}
|
||||||
|
{inv.maturity_date && <Text size="xs">Maturity: {new Date(inv.maturity_date).toLocaleDateString()}</Text>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
position="top"
|
||||||
|
multiline
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${leftPct}%`,
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
top: idx * 40 + 4,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: color,
|
||||||
|
opacity: inv.executed_investment_id ? 0.5 : 0.85,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingLeft: 8,
|
||||||
|
paddingRight: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="white" fw={600} truncate style={{ lineHeight: 1 }}>
|
||||||
|
{inv.label} — {fmt(parseFloat(inv.principal))}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<Group gap="md" mt="md">
|
||||||
|
{Object.entries(typeColors).map(([type, color]) => (
|
||||||
|
<Group key={type} gap={4}>
|
||||||
|
<div style={{ width: 12, height: 12, borderRadius: 2, background: color }} />
|
||||||
|
<Text size="xs" c="dimmed">{type.replace('_', ' ')}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/src/pages/board-planning/components/ProjectionChart.tsx
Normal file
124
frontend/src/pages/board-planning/components/ProjectionChart.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Card, Title, Text, Group, Badge, SegmentedControl, Stack } from '@mantine/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||||
|
ResponsiveContainer, ReferenceLine,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
interface Datapoint {
|
||||||
|
month: string;
|
||||||
|
year: number;
|
||||||
|
monthNum: number;
|
||||||
|
is_forecast: boolean;
|
||||||
|
operating_cash: number;
|
||||||
|
operating_investments: number;
|
||||||
|
reserve_cash: number;
|
||||||
|
reserve_investments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
datapoints: Datapoint[];
|
||||||
|
title?: string;
|
||||||
|
summary?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
|
||||||
|
const [fundFilter, setFundFilter] = useState('all');
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return datapoints.map((d) => ({
|
||||||
|
...d,
|
||||||
|
label: `${d.month}`,
|
||||||
|
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||||
|
}));
|
||||||
|
}, [datapoints]);
|
||||||
|
|
||||||
|
// Find first forecast month for reference line
|
||||||
|
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" p="xs" withBorder style={{ background: 'var(--mantine-color-body)' }}>
|
||||||
|
<Text fw={600} size="sm" mb={4}>{label}</Text>
|
||||||
|
{payload.map((p: any) => (
|
||||||
|
<Group key={p.name} justify="space-between" gap="xl">
|
||||||
|
<Text size="xs" c={p.color}>{p.name}</Text>
|
||||||
|
<Text size="xs" fw={600} ff="monospace">{fmt(p.value)}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showOp = fundFilter === 'all' || fundFilter === 'operating';
|
||||||
|
const showRes = fundFilter === 'all' || fundFilter === 'reserve';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<div>
|
||||||
|
<Title order={4}>{title}</Title>
|
||||||
|
{summary && (
|
||||||
|
<Group gap="md" mt={4}>
|
||||||
|
<Badge variant="light" color="teal">End Liquidity: {fmt(summary.end_liquidity || 0)}</Badge>
|
||||||
|
<Badge variant="light" color="orange">Min Liquidity: {fmt(summary.min_liquidity || 0)}</Badge>
|
||||||
|
{summary.reserve_coverage_months != null && (
|
||||||
|
<Badge variant="light" color="violet">
|
||||||
|
Reserve Coverage: {summary.reserve_coverage_months.toFixed(1)} mo
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={fundFilter}
|
||||||
|
onChange={setFundFilter}
|
||||||
|
data={[
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Operating', value: 'operating' },
|
||||||
|
{ label: 'Reserve', value: 'reserve' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#228be6" stopOpacity={0.6} />
|
||||||
|
<stop offset="95%" stopColor="#228be6" stopOpacity={0.15} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
||||||
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
||||||
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
||||||
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend />
|
||||||
|
{forecastStart > 0 && (
|
||||||
|
<ReferenceLine x={chartData[forecastStart]?.month} stroke="#aaa" strokeDasharray="5 5" label="Forecast" />
|
||||||
|
)}
|
||||||
|
{showOp && <Area type="monotone" dataKey="operating_cash" name="Operating Cash" stroke="#228be6" fill="url(#opCash)" stackId="1" />}
|
||||||
|
{showOp && <Area type="monotone" dataKey="operating_investments" name="Operating Investments" stroke="#74c0fc" fill="url(#opInv)" stackId="1" />}
|
||||||
|
{showRes && <Area type="monotone" dataKey="reserve_cash" name="Reserve Cash" stroke="#7950f2" fill="url(#resCash)" stackId="1" />}
|
||||||
|
{showRes && <Area type="monotone" dataKey="reserve_investments" name="Reserve Investments" stroke="#b197fc" fill="url(#resInv)" stackId="1" />}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Card, Group, Text, Badge, ActionIcon, Menu } from '@mantine/core';
|
||||||
|
import { IconDots, IconTrash, IconEdit, IconPlayerPlay } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: 'gray',
|
||||||
|
active: 'blue',
|
||||||
|
approved: 'green',
|
||||||
|
archived: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
scenario: any;
|
||||||
|
onClick: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScenarioCard({ scenario, onClick, onEdit, onDelete }: Props) {
|
||||||
|
return (
|
||||||
|
<Card withBorder p="lg" style={{ cursor: 'pointer' }} onClick={onClick}>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={600}>{scenario.name}</Text>
|
||||||
|
<Badge size="xs" color={statusColors[scenario.status] || 'gray'}>
|
||||||
|
{scenario.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={(e: any) => e.stopPropagation()}>
|
||||||
|
<IconDots size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item leftSection={<IconEdit size={14} />} onClick={(e: any) => { e.stopPropagation(); onEdit(); }}>
|
||||||
|
Edit
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={(e: any) => { e.stopPropagation(); onDelete(); }}>
|
||||||
|
Archive
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
{scenario.description && (
|
||||||
|
<Text size="sm" c="dimmed" mb="sm" lineClamp={2}>{scenario.description}</Text>
|
||||||
|
)}
|
||||||
|
<Group gap="lg">
|
||||||
|
{scenario.scenario_type === 'investment' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">Investments</Text>
|
||||||
|
<Text fw={600}>{scenario.investment_count || 0}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">Total Principal</Text>
|
||||||
|
<Text fw={600} ff="monospace">{fmt(parseFloat(scenario.total_principal) || 0)}</Text>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{scenario.scenario_type === 'assessment' && (
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">Changes</Text>
|
||||||
|
<Text fw={600}>{scenario.assessment_count || 0}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" mt="sm">
|
||||||
|
Updated {new Date(scenario.updated_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Badge, Card, Alert,
|
Select, Loader, Center, Badge, Card, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -24,27 +26,6 @@ interface BudgetLine {
|
|||||||
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a currency-formatted value: "$48,065.21", "$(13,000.00)", " $- "
|
|
||||||
*/
|
|
||||||
function parseCurrencyValue(val: string): number {
|
|
||||||
if (!val) return 0;
|
|
||||||
let s = 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure all monthly values are numbers (PostgreSQL can return strings for NUMERIC columns)
|
|
||||||
* and compute annual_total as the sum of all monthly values.
|
|
||||||
*/
|
|
||||||
function hydrateBudgetLine(row: any): BudgetLine {
|
function hydrateBudgetLine(row: any): BudgetLine {
|
||||||
const line: any = { ...row };
|
const line: any = { ...row };
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
@@ -54,77 +35,64 @@ function hydrateBudgetLine(row: any): BudgetLine {
|
|||||||
return line as BudgetLine;
|
return line as BudgetLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCSV(text: string): Record<string, string>[] {
|
|
||||||
const lines = text.trim().split('\n');
|
|
||||||
if (lines.length < 2) return [];
|
|
||||||
|
|
||||||
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
|
||||||
const rows: Record<string, string>[] = [];
|
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
const line = lines[i].trim();
|
|
||||||
if (!line) continue;
|
|
||||||
|
|
||||||
// Handle quoted fields containing commas
|
|
||||||
const values: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
for (let j = 0; j < line.length; j++) {
|
|
||||||
const ch = line[j];
|
|
||||||
if (ch === '"') {
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
} else if (ch === ',' && !inQuotes) {
|
|
||||||
values.push(current.trim());
|
|
||||||
current = '';
|
|
||||||
} else {
|
|
||||||
current += ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
values.push(current.trim());
|
|
||||||
|
|
||||||
const row: Record<string, string> = {};
|
|
||||||
headers.forEach((h, idx) => {
|
|
||||||
row[h] = values[idx] || '';
|
|
||||||
});
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const navigate = useNavigate();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
|
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||||
|
|
||||||
const { isLoading } = useQuery<BudgetLine[]>({
|
// Query is the single source of truth for budget data
|
||||||
|
const { data: queryData, isLoading, isFetching } = useQuery<BudgetLine[]>({
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/budgets/${year}`);
|
const { data } = await api.get(`/budgets/${year}`);
|
||||||
// Hydrate each line: ensure numbers and compute annual_total
|
return (data as any[]).map(hydrateBudgetLine);
|
||||||
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
|
||||||
setBudgetData(hydrated);
|
|
||||||
return hydrated;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use edit data when editing, otherwise use query data
|
||||||
|
const isEditing = editData !== null;
|
||||||
|
const budgetData = isEditing ? editData : (queryData || []);
|
||||||
|
const hasBudget = budgetData.length > 0;
|
||||||
|
const cellsEditable = !isReadOnly && isEditing;
|
||||||
|
|
||||||
|
const handleStartEdit = () => {
|
||||||
|
setEditData(queryData ? [...queryData] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleYearChange = (v: string | null) => {
|
||||||
|
if (v) {
|
||||||
|
setYear(v);
|
||||||
|
setEditData(null); // Cancel any in-progress edit when switching years
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const lines = budgetData
|
const payload = budgetData
|
||||||
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
||||||
.map((b) => ({
|
.map((b) => ({
|
||||||
account_id: b.account_id,
|
accountId: b.account_id,
|
||||||
fund_type: b.fund_type,
|
fundType: b.fund_type,
|
||||||
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
||||||
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
||||||
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
|
sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
|
||||||
}));
|
}));
|
||||||
return api.put(`/budgets/${year}`, { lines });
|
return api.put(`/budgets/${year}`, payload);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
|
setEditData(null);
|
||||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -132,109 +100,22 @@ export function BudgetsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const importMutation = useMutation({
|
|
||||||
mutationFn: async (lines: Record<string, string>[]) => {
|
|
||||||
const parsed = lines.map((row) => ({
|
|
||||||
account_number: row.account_number || row.accountnumber || '',
|
|
||||||
account_name: row.account_name || row.accountname || '',
|
|
||||||
jan: parseCurrencyValue(row.jan),
|
|
||||||
feb: parseCurrencyValue(row.feb),
|
|
||||||
mar: parseCurrencyValue(row.mar),
|
|
||||||
apr: parseCurrencyValue(row.apr),
|
|
||||||
may: parseCurrencyValue(row.may),
|
|
||||||
jun: parseCurrencyValue(row.jun),
|
|
||||||
jul: parseCurrencyValue(row.jul),
|
|
||||||
aug: parseCurrencyValue(row.aug),
|
|
||||||
sep: parseCurrencyValue(row.sep),
|
|
||||||
oct: parseCurrencyValue(row.oct),
|
|
||||||
nov: parseCurrencyValue(row.nov),
|
|
||||||
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
|
|
||||||
}));
|
|
||||||
const { data } = await api.post(`/budgets/${year}/import`, parsed);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
|
||||||
let msg = `Imported ${data.imported} budget line(s)`;
|
|
||||||
if (data.created?.length) {
|
|
||||||
msg += `. Created ${data.created.length} new account(s)`;
|
|
||||||
}
|
|
||||||
if (data.errors?.length) {
|
|
||||||
msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
|
|
||||||
}
|
|
||||||
notifications.show({
|
|
||||||
message: msg,
|
|
||||||
color: data.errors?.length ? 'yellow' : 'green',
|
|
||||||
autoClose: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDownloadTemplate = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/budgets/${year}/template`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `budget_template_${year}.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (err: any) {
|
|
||||||
notifications.show({ message: 'Failed to download template', color: 'red' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportCSV = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const text = e.target?.result as string;
|
|
||||||
if (!text) {
|
|
||||||
notifications.show({ message: 'Could not read file', color: 'red' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rows = parseCSV(text);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
notifications.show({ message: 'No data rows found in CSV', color: 'red' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
importMutation.mutate(rows);
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
|
|
||||||
// Reset input so the same file can be re-selected
|
|
||||||
event.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCell = (idx: number, month: string, value: number) => {
|
const updateCell = (idx: number, month: string, value: number) => {
|
||||||
const updated = [...budgetData];
|
if (!editData) return;
|
||||||
|
const updated = [...editData];
|
||||||
(updated[idx] as any)[month] = value || 0;
|
(updated[idx] as any)[month] = value || 0;
|
||||||
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
|
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
|
||||||
setBudgetData(updated);
|
setEditData(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = useMemo(() => Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 1 + i;
|
const y = new Date().getFullYear() - 1 + i;
|
||||||
return { value: String(y), label: String(y) };
|
return { value: String(y), label: String(y) };
|
||||||
});
|
}), []);
|
||||||
|
|
||||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||||
|
|
||||||
|
// Show loader on initial load or when switching years with no cached data
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
||||||
@@ -243,7 +124,6 @@ export function BudgetsPage() {
|
|||||||
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
|
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
|
||||||
const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
const totalIncome = totalOperatingIncome + totalReserveIncome;
|
|
||||||
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -251,42 +131,59 @@ export function BudgetsPage() {
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Budget Manager</Title>
|
<Title order={2}>Budget Manager</Title>
|
||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
<Select data={yearOptions} value={year} onChange={handleYearChange} w={120} />
|
||||||
|
{isFetching && !isLoading && <Loader size="xs" />}
|
||||||
|
{!isReadOnly && hasBudget && (
|
||||||
|
<>
|
||||||
|
{!isEditing ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
leftSection={<IconDownload size={16} />}
|
leftSection={<IconPencil size={16} />}
|
||||||
onClick={handleDownloadTemplate}
|
onClick={handleStartEdit}
|
||||||
>
|
>
|
||||||
Download Template
|
Edit Budget
|
||||||
</Button>
|
</Button>
|
||||||
{!isReadOnly && (<>
|
) : (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
leftSection={<IconUpload size={16} />}
|
color="gray"
|
||||||
onClick={handleImportCSV}
|
leftSection={<IconX size={16} />}
|
||||||
loading={importMutation.isPending}
|
onClick={handleCancelEdit}
|
||||||
>
|
>
|
||||||
Import CSV
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<Button
|
||||||
type="file"
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
ref={fileInputRef}
|
onClick={() => saveMutation.mutate()}
|
||||||
style={{ display: 'none' }}
|
loading={saveMutation.isPending}
|
||||||
accept=".csv,.txt"
|
>
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
|
||||||
Save Budget
|
Save Budget
|
||||||
</Button>
|
</Button>
|
||||||
</>)}
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{budgetData.length === 0 && !isLoading && (
|
{!hasBudget && !isLoading && (
|
||||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
No budget data for {year}. Import a CSV to get started. Your CSV should have columns:{' '}
|
<Stack gap="sm">
|
||||||
<Text span ff="monospace" size="xs">account_number, account_name, jan, feb, ..., dec</Text>.
|
<Text>No budget data for {year}.</Text>
|
||||||
Accounts will be auto-created if they don't exist yet.
|
<Text size="sm">
|
||||||
|
To create or import a budget, use the <Text span fw={600}>Budget Planner</Text> to build,
|
||||||
|
review, and ratify a budget for this year. Once ratified, it will appear here.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconArrowRight size={16} />}
|
||||||
|
w="fit-content"
|
||||||
|
onClick={() => navigate('/board-planning/budgets')}
|
||||||
|
>
|
||||||
|
Go to Budget Planner
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -317,8 +214,8 @@ export function BudgetsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
||||||
{monthLabels.map((m) => (
|
{monthLabels.map((m) => (
|
||||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||||
))}
|
))}
|
||||||
@@ -329,7 +226,7 @@ export function BudgetsPage() {
|
|||||||
{budgetData.length === 0 && (
|
{budgetData.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={15}>
|
<Table.Td colSpan={15}>
|
||||||
<Text ta="center" c="dimmed" py="lg">No budget data. Import a CSV or add income/expense accounts to get started.</Text>
|
<Text ta="center" c="dimmed" py="lg">No budget data for this year.</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -337,7 +234,7 @@ export function BudgetsPage() {
|
|||||||
const lines = budgetData.filter((b) => b.account_type === type);
|
const lines = budgetData.filter((b) => b.account_type === type);
|
||||||
if (lines.length === 0) return null;
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8';
|
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
|
||||||
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -368,9 +265,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 0,
|
left: 0,
|
||||||
background: 'white',
|
background: stickyBg,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
@@ -379,9 +276,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 120,
|
left: 120,
|
||||||
background: 'white',
|
background: stickyBg,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -391,6 +288,7 @@ export function BudgetsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
{months.map((m) => (
|
{months.map((m) => (
|
||||||
<Table.Td key={m} p={2}>
|
<Table.Td key={m} p={2}>
|
||||||
|
{cellsEditable ? (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={(line as any)[m] || 0}
|
value={(line as any)[m] || 0}
|
||||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||||
@@ -398,9 +296,13 @@ export function BudgetsPage() {
|
|||||||
hideControls
|
hideControls
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
min={0}
|
min={0}
|
||||||
disabled={isReadOnly}
|
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" ta="right" ff="monospace">
|
||||||
|
{fmt((line as any)[m] || 0)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
))}
|
))}
|
||||||
<Table.Td ta="right" fw={500} ff="monospace">
|
<Table.Td ta="right" fw={500} ff="monospace">
|
||||||
|
|||||||
@@ -72,13 +72,14 @@ interface KanbanCardProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
onEdit: (p: Project) => void;
|
onEdit: (p: Project) => void;
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
|
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
|
||||||
const plannedLabel = formatPlannedDate(project.planned_date);
|
const plannedLabel = formatPlannedDate(project.planned_date);
|
||||||
// For projects in the Future bucket with a specific year, show the year
|
// For projects in the Future bucket with a specific year, show the year
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const isBeyondWindow = project.target_year > currentYear + 4 && project.target_year !== FUTURE_YEAR;
|
const isBeyondWindow = project.target_year !== null && project.target_year > currentYear + 4 && project.target_year !== FUTURE_YEAR;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
|||||||
padding="sm"
|
padding="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
draggable
|
draggable={!isReadOnly}
|
||||||
onDragStart={(e) => onDragStart(e, project)}
|
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
|
||||||
style={{ cursor: 'grab', userSelect: 'none' }}
|
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
|
||||||
mb="xs"
|
mb="xs"
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||||
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
|
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
|
{!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
|
||||||
<Text fw={600} size="sm" truncate>
|
<Text fw={600} size="sm" truncate>
|
||||||
{project.name}
|
{project.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{!isReadOnly && (
|
||||||
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
||||||
<IconEdit size={14} />
|
<IconEdit size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} mb={6}>
|
<Group gap={6} mb={6}>
|
||||||
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
|
|||||||
isDragOver: boolean;
|
isDragOver: boolean;
|
||||||
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
||||||
onDragLeave: () => void;
|
onDragLeave: () => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({
|
function KanbanColumn({
|
||||||
year, projects, onEdit, onDragStart, onDrop,
|
year, projects, onEdit, onDragStart, onDrop,
|
||||||
isDragOver, onDragOverHandler, onDragLeave,
|
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||||
const isFuture = year === FUTURE_YEAR;
|
const isFuture = year === FUTURE_YEAR;
|
||||||
@@ -178,9 +182,9 @@ function KanbanColumn({
|
|||||||
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
||||||
transition: 'background-color 150ms ease, border 150ms ease',
|
transition: 'background-color 150ms ease, border 150ms ease',
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => onDragOverHandler(e, year)}
|
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={!isReadOnly ? onDragLeave : undefined}
|
||||||
onDrop={(e) => onDrop(e, year)}
|
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Title order={5}>{yearLabel(year)}</Title>
|
<Title order={5}>{yearLabel(year)}</Title>
|
||||||
@@ -199,7 +203,7 @@ function KanbanColumn({
|
|||||||
<Box style={{ flex: 1, minHeight: 60 }}>
|
<Box style={{ flex: 1, minHeight: 60 }}>
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<Text size="xs" c="dimmed" ta="center" py="lg">
|
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||||
Drop projects here
|
{isReadOnly ? 'No projects' : 'Drop projects here'}
|
||||||
</Text>
|
</Text>
|
||||||
) : useWideLayout ? (
|
) : useWideLayout ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -208,12 +212,12 @@ function KanbanColumn({
|
|||||||
gap: 'var(--mantine-spacing-xs)',
|
gap: 'var(--mantine-spacing-xs)',
|
||||||
}}>
|
}}>
|
||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
projects.map((p) => (
|
projects.map((p) => (
|
||||||
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -430,13 +434,13 @@ export function CapitalProjectsPage() {
|
|||||||
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
|
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
|
||||||
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
|
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
|
||||||
const years = [
|
const years = [
|
||||||
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
|
|
||||||
...regularYears,
|
...regularYears,
|
||||||
...(hasFutureProjects ? [FUTURE_YEAR] : []),
|
...(hasFutureProjects ? [FUTURE_YEAR] : []),
|
||||||
|
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Kanban columns: Unscheduled + current..current+4 + Future
|
// Kanban columns: current..current+4 + Future + Unscheduled (rightmost)
|
||||||
const kanbanYears = [UNSCHEDULED, ...baseYears, FUTURE_YEAR];
|
const kanbanYears = [...baseYears, FUTURE_YEAR, UNSCHEDULED];
|
||||||
|
|
||||||
// ---- Loading state ----
|
// ---- Loading state ----
|
||||||
|
|
||||||
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
|
|||||||
isDragOver={dragOverYear === year}
|
isDragOver={dragOverYear === year}
|
||||||
onDragOverHandler={handleDragOver}
|
onDragOverHandler={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon,
|
Title, Text, Stack, Card, Group,
|
||||||
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCash, IconBuildingBank, IconChartAreaLine,
|
|
||||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
||||||
@@ -79,6 +79,7 @@ export function CashFlowForecastPage() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentYear = now.getFullYear();
|
const currentYear = now.getFullYear();
|
||||||
const currentMonth = now.getMonth() + 1;
|
const currentMonth = now.getMonth() + 1;
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
|
||||||
// Filter: All, Operating, Reserve
|
// Filter: All, Operating, Reserve
|
||||||
const [fundFilter, setFundFilter] = useState<string>('all');
|
const [fundFilter, setFundFilter] = useState<string>('all');
|
||||||
@@ -106,30 +107,6 @@ export function CashFlowForecastPage() {
|
|||||||
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
||||||
}, [datapoints, viewStartIndex]);
|
}, [datapoints, viewStartIndex]);
|
||||||
|
|
||||||
// Compute summary stats for the current view
|
|
||||||
const summaryStats = useMemo(() => {
|
|
||||||
if (!viewData.length) return null;
|
|
||||||
const last = viewData[viewData.length - 1];
|
|
||||||
const first = viewData[0];
|
|
||||||
|
|
||||||
const totalOperating = last.operating_cash + last.operating_investments;
|
|
||||||
const totalReserve = last.reserve_cash + last.reserve_investments;
|
|
||||||
const totalAll = totalOperating + totalReserve;
|
|
||||||
|
|
||||||
const firstTotal = first.operating_cash + first.operating_investments +
|
|
||||||
first.reserve_cash + first.reserve_investments;
|
|
||||||
const netChange = totalAll - firstTotal;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalOperating,
|
|
||||||
totalReserve,
|
|
||||||
totalAll,
|
|
||||||
netChange,
|
|
||||||
periodStart: first.month,
|
|
||||||
periodEnd: last.month,
|
|
||||||
};
|
|
||||||
}, [viewData]);
|
|
||||||
|
|
||||||
// Determine the first forecast month index within the view
|
// Determine the first forecast month index within the view
|
||||||
const forecastStartLabel = useMemo(() => {
|
const forecastStartLabel = useMemo(() => {
|
||||||
const idx = viewData.findIndex((d) => d.is_forecast);
|
const idx = viewData.findIndex((d) => d.is_forecast);
|
||||||
@@ -179,65 +156,6 @@ export function CashFlowForecastPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
|
||||||
{summaryStats && (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="blue" size="sm">
|
|
||||||
<IconCash size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalOperating)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="violet" size="sm">
|
|
||||||
<IconBuildingBank size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalReserve)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="teal" size="sm">
|
|
||||||
<IconChartAreaLine size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Combined Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalAll)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconCash size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
|
||||||
</Group>
|
|
||||||
<Text
|
|
||||||
fw={700}
|
|
||||||
size="xl"
|
|
||||||
ff="monospace"
|
|
||||||
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
|
||||||
>
|
|
||||||
{fmt(summaryStats.netChange)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chart Navigation */}
|
{/* Chart Navigation */}
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
@@ -285,20 +203,20 @@ export function CashFlowForecastPage() {
|
|||||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#339af0" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#339af0" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
||||||
@@ -418,10 +336,10 @@ export function CashFlowForecastPage() {
|
|||||||
<tr
|
<tr
|
||||||
key={d.month}
|
key={d.month}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`,
|
||||||
backgroundColor: d.is_forecast
|
backgroundColor: d.is_forecast
|
||||||
? 'var(--mantine-color-orange-0)'
|
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)')
|
||||||
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
|
: i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : 'var(--mantine-color-gray-0)'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useState, useCallback } from 'react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface HealthScore {
|
interface HealthScore {
|
||||||
@@ -39,6 +40,8 @@ interface HealthScore {
|
|||||||
interface HealthScoresData {
|
interface HealthScoresData {
|
||||||
operating: HealthScore | null;
|
operating: HealthScore | null;
|
||||||
reserve: HealthScore | null;
|
reserve: HealthScore | null;
|
||||||
|
operating_last_failed?: boolean;
|
||||||
|
reserve_last_failed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScoreColor(score: number): string {
|
function getScoreColor(score: number): string {
|
||||||
@@ -55,14 +58,37 @@ function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; title: string; icon: React.ReactNode }) {
|
function HealthScoreCard({
|
||||||
|
score,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isRefreshing,
|
||||||
|
onRefresh,
|
||||||
|
lastFailed,
|
||||||
|
}: {
|
||||||
|
score: HealthScore | null;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
lastFailed?: boolean;
|
||||||
|
}) {
|
||||||
|
// No score at all yet
|
||||||
if (!score) {
|
if (!score) {
|
||||||
return (
|
return (
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{icon}
|
{icon}
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
<Center h={100}>
|
<Center h={100}>
|
||||||
<Text c="dimmed" size="sm">No health score yet</Text>
|
<Text c="dimmed" size="sm">No health score yet</Text>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -70,6 +96,7 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pending — missing data, can't calculate
|
||||||
if (score.status === 'pending') {
|
if (score.status === 'pending') {
|
||||||
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
|
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
|
||||||
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
|
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
|
||||||
@@ -77,8 +104,16 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{icon}
|
{icon}
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
<Badge color="gray" variant="light" size="lg">Pending</Badge>
|
||||||
@@ -92,20 +127,38 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score.status === 'error') {
|
// For error status, we still render the score data (cached from the previous
|
||||||
|
// successful run) rather than blanking the card with "Error calculating score".
|
||||||
|
// A small watermark under the timestamp tells the user it's stale.
|
||||||
|
const showAsError = score.status === 'error' && score.score === 0 && !score.summary;
|
||||||
|
|
||||||
|
// Pure error with no cached data to fall back on
|
||||||
|
if (showAsError) {
|
||||||
return (
|
return (
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Retry ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Retry</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{icon}
|
{icon}
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
<Center h={100}>
|
<Center h={100}>
|
||||||
|
<Stack align="center" gap={4}>
|
||||||
<Badge color="red" variant="light">Error calculating score</Badge>
|
<Badge color="red" variant="light">Error calculating score</Badge>
|
||||||
|
<Text size="xs" c="dimmed">Click Retry to try again</Text>
|
||||||
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normal display — works for both 'complete' and 'error' (with cached data)
|
||||||
const color = getScoreColor(score.score);
|
const color = getScoreColor(score.score);
|
||||||
const factors = Array.isArray(score.factors) ? score.factors :
|
const factors = Array.isArray(score.factors) ? score.factors :
|
||||||
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
|
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
|
||||||
@@ -116,8 +169,16 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{icon}
|
{icon}
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
<Group align="flex-start" gap="lg">
|
<Group align="flex-start" gap="lg">
|
||||||
<RingProgress
|
<RingProgress
|
||||||
size={120}
|
size={120}
|
||||||
@@ -215,9 +276,16 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
{score.calculated_at && (
|
{score.calculated_at && (
|
||||||
<Text size="10px" c="dimmed" ta="right" mt={6} style={{ opacity: 0.7 }}>
|
<Stack gap={0} mt={6} align="flex-end">
|
||||||
|
<Text size="10px" c="dimmed" style={{ opacity: 0.7 }}>
|
||||||
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
</Text>
|
</Text>
|
||||||
|
{lastFailed && (
|
||||||
|
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
||||||
|
last analysis failed — showing cached data
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -238,13 +306,20 @@ interface DashboardData {
|
|||||||
reserve_investments: string;
|
reserve_investments: string;
|
||||||
est_monthly_interest: string;
|
est_monthly_interest: string;
|
||||||
interest_earned_ytd: string;
|
interest_earned_ytd: string;
|
||||||
|
interest_last_year: string;
|
||||||
|
interest_projected: string;
|
||||||
planned_capital_spend: string;
|
planned_capital_spend: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Track whether a refresh is in progress (per score type) for async polling
|
||||||
|
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
|
||||||
|
const [reserveRefreshing, setReserveRefreshing] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<DashboardData>({
|
const { data, isLoading } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
queryFn: async () => { const { data } = await api.get('/reports/dashboard'); return data; },
|
queryFn: async () => { const { data } = await api.get('/reports/dashboard'); return data; },
|
||||||
@@ -255,14 +330,66 @@ export function DashboardPage() {
|
|||||||
queryKey: ['health-scores'],
|
queryKey: ['health-scores'],
|
||||||
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||||
enabled: !!currentOrg,
|
enabled: !!currentOrg,
|
||||||
|
// Poll every 3 seconds while a refresh is in progress
|
||||||
|
refetchInterval: (operatingRefreshing || reserveRefreshing) ? 3000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const recalcMutation = useMutation({
|
// Async refresh handlers — trigger the backend and poll for results
|
||||||
mutationFn: () => api.post('/health-scores/calculate'),
|
const handleRefreshOperating = useCallback(async () => {
|
||||||
onSuccess: () => {
|
const prevId = healthScores?.operating?.id;
|
||||||
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
setOperatingRefreshing(true);
|
||||||
},
|
try {
|
||||||
});
|
await api.post('/health-scores/calculate/operating');
|
||||||
|
} catch {
|
||||||
|
// Trigger failed at network level — polling will pick up any backend-saved error
|
||||||
|
}
|
||||||
|
// Start polling — watch for the health score to change (new id or updated timestamp)
|
||||||
|
const pollUntilDone = () => {
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { data: latest } = await api.get('/health-scores/latest');
|
||||||
|
const newScore = latest?.operating;
|
||||||
|
if (newScore && newScore.id !== prevId) {
|
||||||
|
setOperatingRefreshing(false);
|
||||||
|
queryClient.setQueryData(['health-scores'], latest);
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
// Safety timeout — stop polling after 11 minutes
|
||||||
|
setTimeout(() => { clearInterval(checkInterval); setOperatingRefreshing(false); }, 660000);
|
||||||
|
};
|
||||||
|
pollUntilDone();
|
||||||
|
}, [healthScores?.operating?.id, queryClient]);
|
||||||
|
|
||||||
|
const handleRefreshReserve = useCallback(async () => {
|
||||||
|
const prevId = healthScores?.reserve?.id;
|
||||||
|
setReserveRefreshing(true);
|
||||||
|
try {
|
||||||
|
await api.post('/health-scores/calculate/reserve');
|
||||||
|
} catch {
|
||||||
|
// Trigger failed at network level
|
||||||
|
}
|
||||||
|
const pollUntilDone = () => {
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { data: latest } = await api.get('/health-scores/latest');
|
||||||
|
const newScore = latest?.reserve;
|
||||||
|
if (newScore && newScore.id !== prevId) {
|
||||||
|
setReserveRefreshing(false);
|
||||||
|
queryClient.setQueryData(['health-scores'], latest);
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
setTimeout(() => { clearInterval(checkInterval); setReserveRefreshing(false); }, 660000);
|
||||||
|
};
|
||||||
|
pollUntilDone();
|
||||||
|
}, [healthScores?.reserve?.id, queryClient]);
|
||||||
|
|
||||||
const fmt = (v: string | number) =>
|
const fmt = (v: string | number) =>
|
||||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
@@ -290,20 +417,6 @@ export function DashboardPage() {
|
|||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
|
||||||
<Tooltip label="Recalculate health scores now">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="compact-xs"
|
|
||||||
leftSection={<IconRefresh size={14} />}
|
|
||||||
loading={recalcMutation.isPending}
|
|
||||||
onClick={() => recalcMutation.mutate()}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.operating || null}
|
score={healthScores?.operating || null}
|
||||||
@@ -313,6 +426,9 @@ export function DashboardPage() {
|
|||||||
<IconHeartbeat size={20} />
|
<IconHeartbeat size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
|
isRefreshing={operatingRefreshing}
|
||||||
|
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
|
||||||
|
lastFailed={!!healthScores?.operating_last_failed}
|
||||||
/>
|
/>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.reserve || null}
|
score={healthScores?.reserve || null}
|
||||||
@@ -322,6 +438,9 @@ export function DashboardPage() {
|
|||||||
<IconHeartbeat size={20} />
|
<IconHeartbeat size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
|
isRefreshing={reserveRefreshing}
|
||||||
|
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
|
||||||
|
lastFailed={!!healthScores?.reserve_last_failed}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
@@ -375,6 +494,66 @@ export function DashboardPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Title order={4}>Quick Stats</Title>
|
||||||
|
<Stack mt="sm" gap="xs">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Operating Cash</Text>
|
||||||
|
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Reserve Cash</Text>
|
||||||
|
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider my={4} />
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
|
||||||
|
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
|
||||||
|
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Interest Earned YoY</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_projected || '0')}</Text>
|
||||||
|
<Text size="xs" c="dimmed">proj</Text>
|
||||||
|
<Text size="xs" c="dimmed">vs</Text>
|
||||||
|
<Text size="sm" fw={500} c="gray">{fmt(data?.interest_last_year || '0')}</Text>
|
||||||
|
<Text size="xs" c="dimmed">prev</Text>
|
||||||
|
{(() => {
|
||||||
|
const proj = parseFloat(data?.interest_projected || '0');
|
||||||
|
const prev = parseFloat(data?.interest_last_year || '0');
|
||||||
|
const diff = proj - prev;
|
||||||
|
if (prev === 0 && proj === 0) return null;
|
||||||
|
return (
|
||||||
|
<Badge size="xs" color={diff >= 0 ? 'green' : 'red'} variant="light">
|
||||||
|
{diff >= 0 ? '+' : ''}{prev > 0 ? ((diff / prev) * 100).toFixed(0) : '—'}%
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Divider my={4} />
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Capital Projects</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Planned Capital Spend {new Date().getFullYear()}</Text>
|
||||||
|
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider my={4} />
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
||||||
|
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
||||||
|
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
||||||
|
{data?.delinquent_units || 0}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Title order={4} mb="sm">Recent Transactions</Title>
|
<Title order={4} mb="sm">Recent Transactions</Title>
|
||||||
{(data?.recent_transactions || []).length === 0 ? (
|
{(data?.recent_transactions || []).length === 0 ? (
|
||||||
@@ -404,43 +583,6 @@ export function DashboardPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder padding="lg" radius="md">
|
|
||||||
<Title order={4}>Quick Stats</Title>
|
|
||||||
<Stack mt="sm" gap="xs">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Operating Cash</Text>
|
|
||||||
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Reserve Cash</Text>
|
|
||||||
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
|
|
||||||
</Group>
|
|
||||||
<Divider my={4} />
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
|
|
||||||
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
|
|
||||||
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Planned Capital Spend</Text>
|
|
||||||
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
|
||||||
</Group>
|
|
||||||
<Divider my={4} />
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
|
||||||
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
|
||||||
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
|
||||||
{data?.delinquent_units || 0}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Collapse,
|
Collapse,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
TextInput,
|
||||||
|
Progress,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBulb,
|
IconBulb,
|
||||||
@@ -32,10 +36,14 @@ import {
|
|||||||
IconPigMoney,
|
IconPigMoney,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
|
IconPlaylistAdd,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { DateInput } from '@mantine/dates';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -80,6 +88,15 @@ interface MarketRatesResponse {
|
|||||||
high_yield_savings: MarketRate[];
|
high_yield_savings: MarketRate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecommendationComponent {
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
term_months: number;
|
||||||
|
rate: number;
|
||||||
|
bank_name?: string;
|
||||||
|
investment_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Recommendation {
|
interface Recommendation {
|
||||||
type: string;
|
type: string;
|
||||||
priority: 'high' | 'medium' | 'low';
|
priority: 'high' | 'medium' | 'low';
|
||||||
@@ -92,6 +109,7 @@ interface Recommendation {
|
|||||||
suggested_rate?: number;
|
suggested_rate?: number;
|
||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
|
components?: RecommendationComponent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AIResponse {
|
interface AIResponse {
|
||||||
@@ -107,6 +125,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
@@ -181,14 +202,31 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean
|
|||||||
|
|
||||||
// ── Recommendations Display Component ──
|
// ── Recommendations Display Component ──
|
||||||
|
|
||||||
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
|
function RecommendationsDisplay({
|
||||||
|
aiResult,
|
||||||
|
lastUpdated,
|
||||||
|
lastFailed,
|
||||||
|
onAddToPlan,
|
||||||
|
}: {
|
||||||
|
aiResult: AIResponse;
|
||||||
|
lastUpdated?: string;
|
||||||
|
lastFailed?: boolean;
|
||||||
|
onAddToPlan?: (rec: Recommendation) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{/* Last Updated timestamp */}
|
{/* Last Updated timestamp + failure message */}
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
|
<Stack gap={0} align="flex-end">
|
||||||
<Text size="xs" c="dimmed" ta="right">
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
|
{lastFailed && (
|
||||||
|
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
||||||
|
last analysis failed — showing cached data
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overall Assessment */}
|
{/* Overall Assessment */}
|
||||||
@@ -309,6 +347,17 @@ function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIRespons
|
|||||||
<Alert variant="light" color="gray" title="Rationale">
|
<Alert variant="light" color="gray" title="Rationale">
|
||||||
<Text size="sm">{rec.rationale}</Text>
|
<Text size="sm">{rec.rationale}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{onAddToPlan && rec.type !== 'liquidity_warning' && rec.type !== 'general' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconPlaylistAdd size={16} />}
|
||||||
|
onClick={() => onAddToPlan(rec)}
|
||||||
|
>
|
||||||
|
Add to Investment Plan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
@@ -327,9 +376,93 @@ function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIRespons
|
|||||||
// ── Main Component ──
|
// ── Main Component ──
|
||||||
|
|
||||||
export function InvestmentPlanningPage() {
|
export function InvestmentPlanningPage() {
|
||||||
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
const navigate = useNavigate();
|
||||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
const queryClient = useQueryClient();
|
||||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||||
|
const [isTriggering, setIsTriggering] = useState(false);
|
||||||
|
const [planModalOpen, setPlanModalOpen] = useState(false);
|
||||||
|
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
|
||||||
|
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||||
|
const [newScenarioName, setNewScenarioName] = useState('');
|
||||||
|
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
|
// Load investment scenarios for the "Add to Plan" modal
|
||||||
|
const { data: investmentScenarios } = useQuery<any[]>({
|
||||||
|
queryKey: ['board-planning-scenarios', 'investment'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/board-planning/scenarios?type=investment');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToPlanMutation = useMutation({
|
||||||
|
mutationFn: async ({ scenarioId, rec }: { scenarioId: string; rec: Recommendation }) => {
|
||||||
|
await api.post(`/board-planning/scenarios/${scenarioId}/investments/from-recommendation`, {
|
||||||
|
title: rec.title,
|
||||||
|
investmentType: rec.type === 'cd_ladder' ? 'cd' : rec.type === 'new_investment' ? undefined : undefined,
|
||||||
|
fundType: rec.fund_type || 'reserve',
|
||||||
|
suggestedAmount: rec.suggested_amount,
|
||||||
|
suggestedRate: rec.suggested_rate,
|
||||||
|
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||||
|
bankName: rec.bank_name,
|
||||||
|
rationale: rec.rationale,
|
||||||
|
components: rec.components || undefined,
|
||||||
|
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
|
||||||
|
});
|
||||||
|
return scenarioId;
|
||||||
|
},
|
||||||
|
onSuccess: (scenarioId) => {
|
||||||
|
setPlanModalOpen(false);
|
||||||
|
setSelectedRec(null);
|
||||||
|
setTargetScenarioId(null);
|
||||||
|
notifications.show({
|
||||||
|
message: 'Recommendation added to investment scenario',
|
||||||
|
color: 'green',
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAndAddMutation = useMutation({
|
||||||
|
mutationFn: async ({ name, rec }: { name: string; rec: Recommendation }) => {
|
||||||
|
const { data: scenario } = await api.post('/board-planning/scenarios', {
|
||||||
|
name, scenarioType: 'investment',
|
||||||
|
});
|
||||||
|
await api.post(`/board-planning/scenarios/${scenario.id}/investments/from-recommendation`, {
|
||||||
|
title: rec.title,
|
||||||
|
investmentType: rec.type === 'cd_ladder' ? 'cd' : undefined,
|
||||||
|
fundType: rec.fund_type || 'reserve',
|
||||||
|
suggestedAmount: rec.suggested_amount,
|
||||||
|
suggestedRate: rec.suggested_rate,
|
||||||
|
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||||
|
bankName: rec.bank_name,
|
||||||
|
rationale: rec.rationale,
|
||||||
|
components: rec.components || undefined,
|
||||||
|
startDate: investmentStartDate ? investmentStartDate.toISOString().split('T')[0] : null,
|
||||||
|
});
|
||||||
|
return scenario.id;
|
||||||
|
},
|
||||||
|
onSuccess: (scenarioId) => {
|
||||||
|
setPlanModalOpen(false);
|
||||||
|
setSelectedRec(null);
|
||||||
|
setNewScenarioName('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||||
|
notifications.show({
|
||||||
|
message: 'New scenario created with recommendation',
|
||||||
|
color: 'green',
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddToPlan = (rec: Recommendation) => {
|
||||||
|
setSelectedRec(rec);
|
||||||
|
setTargetScenarioId(null);
|
||||||
|
setNewScenarioName('');
|
||||||
|
setInvestmentStartDate(new Date());
|
||||||
|
setPlanModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Load financial snapshot on mount
|
// Load financial snapshot on mount
|
||||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||||
@@ -349,50 +482,97 @@ export function InvestmentPlanningPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load saved recommendation on mount
|
// Load saved recommendation — polls every 3s when processing
|
||||||
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
||||||
queryKey: ['investment-planning-saved-recommendation'],
|
queryKey: ['investment-planning-saved-recommendation'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/investment-planning/saved-recommendation');
|
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const rec = query.state.data;
|
||||||
|
// Poll every 3 seconds while processing
|
||||||
|
if (rec?.status === 'processing') return 3000;
|
||||||
|
// Also poll if we just triggered (status may not be 'processing' yet)
|
||||||
|
if (isTriggering) return 3000;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate AI results from saved recommendation on load
|
// Derive display state from saved recommendation
|
||||||
|
const isProcessing = savedRec?.status === 'processing' || isTriggering;
|
||||||
|
const lastFailed = savedRec?.last_failed || false;
|
||||||
|
const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0;
|
||||||
|
const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length;
|
||||||
|
|
||||||
|
// Clear triggering flag once backend confirms processing or completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (savedRec && !aiResult) {
|
if (isTriggering && savedRec?.status === 'processing') {
|
||||||
setAiResult({
|
setIsTriggering(false);
|
||||||
recommendations: savedRec.recommendations,
|
|
||||||
overall_assessment: savedRec.overall_assessment,
|
|
||||||
risk_notes: savedRec.risk_notes,
|
|
||||||
});
|
|
||||||
setLastUpdated(savedRec.created_at);
|
|
||||||
}
|
}
|
||||||
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
|
if (isTriggering && savedRec?.status === 'complete') {
|
||||||
|
setIsTriggering(false);
|
||||||
|
}
|
||||||
|
}, [savedRec?.status, isTriggering]);
|
||||||
|
|
||||||
// AI recommendation (on-demand)
|
// Ref for scrolling to AI section on completion
|
||||||
const aiMutation = useMutation({
|
const aiSectionRef = useRef<HTMLDivElement>(null);
|
||||||
mutationFn: async () => {
|
|
||||||
const { data } = await api.post('/investment-planning/recommendations');
|
// Show notification when processing completes (transition from processing)
|
||||||
return data as AIResponse;
|
const prevStatusRef = useState<string | null>(null);
|
||||||
},
|
useEffect(() => {
|
||||||
onSuccess: (data) => {
|
const [prevStatus, setPrevStatus] = prevStatusRef;
|
||||||
setAiResult(data);
|
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
|
||||||
setLastUpdated(new Date().toISOString());
|
|
||||||
if (data.recommendations.length > 0) {
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: `Generated ${data.recommendations.length} investment recommendations`,
|
title: 'AI Analysis Complete',
|
||||||
|
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
|
||||||
color: 'green',
|
color: 'green',
|
||||||
|
autoClose: 8000,
|
||||||
|
});
|
||||||
|
// Scroll the AI section into view so user sees the new results
|
||||||
|
setTimeout(() => {
|
||||||
|
aiSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
if (prevStatus === 'processing' && savedRec?.status === 'error') {
|
||||||
|
notifications.show({
|
||||||
|
title: 'AI Analysis Failed',
|
||||||
|
message: savedRec.error_message || 'AI recommendation analysis failed',
|
||||||
|
color: 'red',
|
||||||
|
autoClose: 8000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
setPrevStatus(savedRec?.status || null);
|
||||||
onError: (err: any) => {
|
}, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Trigger AI recommendations (async — returns immediately)
|
||||||
|
const handleTriggerAI = useCallback(async () => {
|
||||||
|
setIsTriggering(true);
|
||||||
|
try {
|
||||||
|
await api.post('/investment-planning/recommendations');
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsTriggering(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: err.response?.data?.message || 'Failed to get AI recommendations',
|
message: err.response?.data?.message || 'Failed to start AI analysis',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
|
// Build AI result from saved recommendation for display
|
||||||
|
const aiResult: AIResponse | null = hasResults
|
||||||
|
? {
|
||||||
|
recommendations: savedRec!.recommendations,
|
||||||
|
overall_assessment: savedRec!.overall_assessment,
|
||||||
|
risk_notes: savedRec!.risk_notes,
|
||||||
|
}
|
||||||
|
: (lastFailed && savedRec?.recommendations?.length)
|
||||||
|
? {
|
||||||
|
recommendations: savedRec!.recommendations,
|
||||||
|
overall_assessment: savedRec!.overall_assessment,
|
||||||
|
risk_notes: savedRec!.risk_notes,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
if (snapshotLoading) {
|
if (snapshotLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -630,7 +810,7 @@ export function InvestmentPlanningPage() {
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* ── Section 4: AI Investment Recommendations ── */}
|
{/* ── Section 4: AI Investment Recommendations ── */}
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg" ref={aiSectionRef}>
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ThemeIcon variant="light" color="grape" size="md">
|
<ThemeIcon variant="light" color="grape" size="md">
|
||||||
@@ -643,39 +823,60 @@ export function InvestmentPlanningPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
{!isReadOnly && (
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconSparkles size={16} />}
|
leftSection={<IconSparkles size={16} />}
|
||||||
onClick={() => aiMutation.mutate()}
|
onClick={handleTriggerAI}
|
||||||
loading={aiMutation.isPending}
|
loading={isProcessing}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{ from: 'grape', to: 'violet' }}
|
gradient={{ from: 'grape', to: 'violet' }}
|
||||||
>
|
>
|
||||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Processing State - shown as banner when refreshing with existing results */}
|
||||||
{aiMutation.isPending && (
|
{isProcessing && (
|
||||||
<Center py="xl">
|
<Alert variant="light" color="grape" mb="md" styles={{ root: { overflow: 'visible' } }}>
|
||||||
<Stack align="center" gap="sm">
|
<Group gap="sm">
|
||||||
<Loader size="lg" type="dots" />
|
<Loader size="sm" color="grape" />
|
||||||
<Text c="dimmed" size="sm">
|
<div style={{ flex: 1 }}>
|
||||||
Analyzing your financial data and market rates...
|
<Text size="sm" fw={500}>
|
||||||
|
{aiResult ? 'Refreshing AI analysis...' : 'Running AI analysis...'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" size="xs">
|
<Text size="xs" c="dimmed">
|
||||||
This may take up to 30 seconds
|
Analyzing your financial data, accounts, budgets, and current market rates
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</div>
|
||||||
</Center>
|
</Group>
|
||||||
|
<Progress value={100} animated color="grape" size="xs" mt="xs" />
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Error State (no cached data) */}
|
||||||
{aiResult && !aiMutation.isPending && (
|
{hasError && !isProcessing && (
|
||||||
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
|
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
|
||||||
|
<Text size="sm">
|
||||||
|
{savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Results - keep visible even while refreshing (with optional failure watermark) */}
|
||||||
{!aiResult && !aiMutation.isPending && (
|
{aiResult && (
|
||||||
|
<div style={isProcessing ? { opacity: 0.5, pointerEvents: 'none' } : undefined}>
|
||||||
|
<RecommendationsDisplay
|
||||||
|
aiResult={aiResult}
|
||||||
|
lastUpdated={savedRec?.created_at || undefined}
|
||||||
|
lastFailed={lastFailed}
|
||||||
|
onAddToPlan={handleAddToPlan}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State - only when no results and not processing */}
|
||||||
|
{!aiResult && !isProcessing && !hasError && (
|
||||||
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
||||||
<IconSparkles size={28} />
|
<IconSparkles size={28} />
|
||||||
@@ -692,6 +893,77 @@ export function InvestmentPlanningPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Add to Investment Plan Modal */}
|
||||||
|
<Modal opened={planModalOpen} onClose={() => setPlanModalOpen(false)} title="Add to Investment Plan">
|
||||||
|
<Stack>
|
||||||
|
{selectedRec && (
|
||||||
|
<Alert variant="light" color="blue">
|
||||||
|
<Text size="sm" fw={600}>{selectedRec.title}</Text>
|
||||||
|
{selectedRec.suggested_amount != null && (
|
||||||
|
<Text size="sm">Amount: {fmt(selectedRec.suggested_amount)}</Text>
|
||||||
|
)}
|
||||||
|
{selectedRec.components && selectedRec.components.length > 0 && (
|
||||||
|
<Stack gap={2} mt={6}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>{selectedRec.components.length} investments will be created:</Text>
|
||||||
|
{selectedRec.components.map((c, i) => (
|
||||||
|
<Text key={i} size="xs" c="dimmed">
|
||||||
|
{c.label}: {fmt(c.amount)} @ {c.rate}% ({c.term_months} mo)
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label="Start Date"
|
||||||
|
description="Purchase date for the investment(s). Maturity dates are calculated automatically from term length."
|
||||||
|
value={investmentStartDate}
|
||||||
|
onChange={setInvestmentStartDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{investmentScenarios && investmentScenarios.length > 0 && (
|
||||||
|
<Select
|
||||||
|
label="Add to existing scenario"
|
||||||
|
placeholder="Select a scenario..."
|
||||||
|
data={investmentScenarios.map((s: any) => ({ value: s.id, label: s.name }))}
|
||||||
|
value={targetScenarioId}
|
||||||
|
onChange={setTargetScenarioId}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider label="or" labelPosition="center" />
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Create new scenario"
|
||||||
|
placeholder="e.g. Conservative Strategy"
|
||||||
|
value={newScenarioName}
|
||||||
|
onChange={(e) => { setNewScenarioName(e.target.value); setTargetScenarioId(null); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
|
||||||
|
{targetScenarioId && selectedRec && (
|
||||||
|
<Button
|
||||||
|
onClick={() => addToPlanMutation.mutate({ scenarioId: targetScenarioId, rec: selectedRec })}
|
||||||
|
loading={addToPlanMutation.isPending}
|
||||||
|
>
|
||||||
|
Add to Scenario
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{newScenarioName && !targetScenarioId && selectedRec && (
|
||||||
|
<Button
|
||||||
|
onClick={() => createAndAddMutation.mutate({ name: newScenarioName, rec: selectedRec })}
|
||||||
|
loading={createAndAddMutation.isPending}
|
||||||
|
>
|
||||||
|
Create & Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,71 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, Badge, Modal,
|
Title, Table, Group, Button, Stack, Text, Badge, Modal,
|
||||||
NumberInput, Select, Loader, Center, Card,
|
NumberInput, Select, Loader, Center, Card, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { DateInput } from '@mantine/dates';
|
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconFileInvoice, IconSend } from '@tabler/icons-react';
|
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
||||||
invoice_date: string; due_date: string; invoice_type: string;
|
invoice_date: string; due_date: string; invoice_type: string;
|
||||||
description: string; amount: string; amount_paid: string; balance_due: string;
|
description: string; amount: string; amount_paid: string; balance_due: string;
|
||||||
status: string;
|
status: string; period_start: string; period_end: string;
|
||||||
|
assessment_group_name: string; frequency: string; owner_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
frequency: string;
|
||||||
|
active_units: number;
|
||||||
|
regular_assessment: string;
|
||||||
|
special_assessment: string;
|
||||||
|
is_billing_month: boolean;
|
||||||
|
total_amount: number;
|
||||||
|
period_description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Preview {
|
||||||
|
month: number;
|
||||||
|
year: number;
|
||||||
|
month_name: string;
|
||||||
|
groups: PreviewGroup[];
|
||||||
|
summary: {
|
||||||
|
total_groups_billing: number;
|
||||||
|
total_invoices: number;
|
||||||
|
total_amount: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
|
draft: 'gray', pending: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
|
||||||
|
};
|
||||||
|
|
||||||
|
const frequencyColors: Record<string, string> = {
|
||||||
|
monthly: 'blue', quarterly: 'teal', annual: 'violet',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
/** Extract last name from "First Last" format */
|
||||||
|
const getLastName = (ownerName: string | null) => {
|
||||||
|
if (!ownerName) return '-';
|
||||||
|
const parts = ownerName.trim().split(/\s+/);
|
||||||
|
return parts.length > 1 ? parts[parts.length - 1] : ownerName;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvoicesPage() {
|
export function InvoicesPage() {
|
||||||
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
|
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
|
||||||
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||||
queryKey: ['invoices'],
|
queryKey: ['invoices'],
|
||||||
@@ -35,13 +76,36 @@ export function InvoicesPage() {
|
|||||||
initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() },
|
initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch preview when month/year changes
|
||||||
|
const fetchPreview = async (month: number, year: number) => {
|
||||||
|
setPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/invoices/generate-preview', { month, year });
|
||||||
|
setPreview(data);
|
||||||
|
} catch {
|
||||||
|
setPreview(null);
|
||||||
|
}
|
||||||
|
setPreviewLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bulkOpened) {
|
||||||
|
fetchPreview(bulkForm.values.month, bulkForm.values.year);
|
||||||
|
}
|
||||||
|
}, [bulkOpened, bulkForm.values.month, bulkForm.values.year]);
|
||||||
|
|
||||||
const bulkMutation = useMutation({
|
const bulkMutation = useMutation({
|
||||||
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
|
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
|
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
|
||||||
notifications.show({ message: `Generated ${res.data.created} invoices`, color: 'green' });
|
const groupInfo = res.data.groups?.map((g: any) => `${g.group_name}: ${g.invoices_created}`).join(', ') || '';
|
||||||
|
notifications.show({
|
||||||
|
message: `Generated ${res.data.created} invoices${groupInfo ? ` (${groupInfo})` : ''}`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
closeBulk();
|
closeBulk();
|
||||||
|
setPreview(null);
|
||||||
},
|
},
|
||||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||||
});
|
});
|
||||||
@@ -54,8 +118,6 @@ export function InvoicesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0);
|
const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0);
|
||||||
@@ -64,20 +126,24 @@ export function InvoicesPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Invoices</Title>
|
<Title order={2}>Invoices</Title>
|
||||||
|
{!isReadOnly && (
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Monthly Invoices</Button>
|
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
||||||
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(String(totalOutstanding))}</Text></Card>
|
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(totalOutstanding)}</Text></Card>
|
||||||
</Group>
|
</Group>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Date</Table.Th>
|
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Owner</Table.Th>
|
||||||
<Table.Th>Due</Table.Th><Table.Th>Type</Table.Th><Table.Th ta="right">Amount</Table.Th>
|
<Table.Th>Group</Table.Th><Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th>Due</Table.Th><Table.Th>Period</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
<Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th>
|
<Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@@ -86,27 +152,104 @@ export function InvoicesPage() {
|
|||||||
<Table.Tr key={i.id}>
|
<Table.Tr key={i.id}>
|
||||||
<Table.Td fw={500}>{i.invoice_number}</Table.Td>
|
<Table.Td fw={500}>{i.invoice_number}</Table.Td>
|
||||||
<Table.Td>{i.unit_number}</Table.Td>
|
<Table.Td>{i.unit_number}</Table.Td>
|
||||||
|
<Table.Td>{getLastName(i.owner_name)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{i.assessment_group_name ? (
|
||||||
|
<Badge size="sm" variant="light" color={frequencyColors[i.frequency] || 'gray'}>
|
||||||
|
{i.assessment_group_name}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" variant="light">{i.invoice_type}</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td>
|
<Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td>
|
||||||
<Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td>
|
<Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td>
|
||||||
<Table.Td><Badge size="sm" variant="light">{i.invoice_type}</Badge></Table.Td>
|
<Table.Td>
|
||||||
|
{i.period_start && i.period_end ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{new Date(i.period_start).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}
|
||||||
|
{i.period_start !== i.period_end && (
|
||||||
|
<> - {new Date(i.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed">-</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td>
|
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td>
|
||||||
<Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td>
|
<Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={9}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
|
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<Modal opened={bulkOpened} onClose={closeBulk} title="Generate Monthly Assessments">
|
|
||||||
|
<Modal opened={bulkOpened} onClose={() => { closeBulk(); setPreview(null); }} title="Generate Assessments" size="lg">
|
||||||
<form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}>
|
<form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} />
|
<Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} />
|
||||||
<NumberInput label="Year" {...bulkForm.getInputProps('year')} />
|
<NumberInput label="Year" {...bulkForm.getInputProps('year')} />
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" c="dimmed">This will generate invoices for all active units based on their monthly assessment amount.</Text>
|
|
||||||
<Button type="submit" loading={bulkMutation.isPending}>Generate Invoices</Button>
|
{previewLoading && <Center py="md"><Loader size="sm" /></Center>}
|
||||||
|
|
||||||
|
{preview && !previewLoading && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm" fw={600}>Billing Preview for {preview.month_name} {preview.year}</Text>
|
||||||
|
|
||||||
|
{preview.groups.map((g) => (
|
||||||
|
<Card key={g.id} withBorder p="xs" style={{ opacity: g.is_billing_month ? 1 : 0.5 }}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
{g.is_billing_month && g.active_units > 0
|
||||||
|
? <IconCheck size={16} color="green" />
|
||||||
|
: <IconX size={16} color="gray" />
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Text size="sm" fw={500}>{g.name}</Text>
|
||||||
|
<Badge size="xs" color={frequencyColors[g.frequency]} variant="light">
|
||||||
|
{g.frequency}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{g.is_billing_month
|
||||||
|
? `${g.active_units} units - ${g.period_description}`
|
||||||
|
: `Not a billing month for this group`
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{g.is_billing_month && (
|
||||||
|
<Text size="sm" fw={500} ff="monospace">{fmt(g.total_amount)}</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{preview.summary.total_invoices > 0 ? (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
|
Will generate {preview.summary.total_invoices} invoices across{' '}
|
||||||
|
{preview.summary.total_groups_billing} group(s) totaling {fmt(preview.summary.total_amount)}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">
|
||||||
|
No assessment groups have billing scheduled for {preview.month_name}. No invoices will be generated.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={bulkMutation.isPending}
|
||||||
|
disabled={!preview || preview.summary.total_invoices === 0}
|
||||||
|
>
|
||||||
|
Generate {preview?.summary.total_invoices || 0} Invoices
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Card, SimpleGrid, Badge, Alert,
|
Select, Loader, Center, Card, SimpleGrid, Badge, Alert, Modal,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth,
|
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconEdit,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
interface ActualLine {
|
interface ActualLine {
|
||||||
@@ -64,8 +66,15 @@ export function MonthlyActualsPage() {
|
|||||||
const [month, setMonth] = useState(defaults.month);
|
const [month, setMonth] = useState(defaults.month);
|
||||||
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
|
||||||
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
|
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -78,10 +87,15 @@ export function MonthlyActualsPage() {
|
|||||||
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
|
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
|
||||||
setEditedAmounts({});
|
setEditedAmounts({});
|
||||||
setSavedJEId(data.existing_journal_entry_id || null);
|
setSavedJEId(data.existing_journal_entry_id || null);
|
||||||
|
// Default to read mode if actuals already exist, edit mode if new
|
||||||
|
setIsEditing(!data.existing_journal_entry_id);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whether actuals have been previously saved (reconciled)
|
||||||
|
const hasExistingActuals = !!savedJEId;
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const lines = (grid?.lines || [])
|
const lines = (grid?.lines || [])
|
||||||
@@ -101,6 +115,8 @@ export function MonthlyActualsPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
|
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
|
||||||
setSavedJEId(data.journal_entry_id);
|
setSavedJEId(data.journal_entry_id);
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedAmounts({});
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: data.message || 'Actuals saved and reconciled',
|
message: data.message || 'Actuals saved and reconciled',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
@@ -125,6 +141,19 @@ export function MonthlyActualsPage() {
|
|||||||
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
|
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditClick = () => {
|
||||||
|
if (hasExistingActuals) {
|
||||||
|
openConfirm();
|
||||||
|
} else {
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmEdit = () => {
|
||||||
|
closeConfirm();
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
const lines = grid?.lines || [];
|
const lines = grid?.lines || [];
|
||||||
const incomeLines = lines.filter((l) => l.account_type === 'income');
|
const incomeLines = lines.filter((l) => l.account_type === 'income');
|
||||||
const expenseLines = lines.filter((l) => l.account_type === 'expense');
|
const expenseLines = lines.filter((l) => l.account_type === 'expense');
|
||||||
@@ -137,7 +166,6 @@ export function MonthlyActualsPage() {
|
|||||||
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
|
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
|
||||||
}, [lines, editedAmounts]);
|
}, [lines, editedAmounts]);
|
||||||
|
|
||||||
const hasChanges = Object.keys(editedAmounts).length > 0;
|
|
||||||
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
|
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
@@ -163,7 +191,7 @@ export function MonthlyActualsPage() {
|
|||||||
{title}
|
{title}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
|
||||||
<Table.Td />
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(actualTotal)}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace"
|
<Table.Td ta="right" fw={700} ff="monospace"
|
||||||
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
|
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
|
||||||
>
|
>
|
||||||
@@ -178,16 +206,16 @@ export function MonthlyActualsPage() {
|
|||||||
<Table.Tr key={line.account_id}>
|
<Table.Tr key={line.account_id}>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 0, background: 'white', zIndex: 1,
|
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 120, background: 'white', zIndex: 1,
|
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -198,7 +226,8 @@ export function MonthlyActualsPage() {
|
|||||||
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
|
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
|
||||||
{fmt(line.budget_amount)}
|
{fmt(line.budget_amount)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td p={2} style={{ minWidth: 130 }}>
|
<Table.Td p={isEditing ? 2 : undefined} style={{ minWidth: 130 }}>
|
||||||
|
{isEditing ? (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
||||||
@@ -209,6 +238,9 @@ export function MonthlyActualsPage() {
|
|||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" ff="monospace" ta="right">{fmt(amount)}</Text>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
ta="right" ff="monospace" style={{ minWidth: 110 }}
|
ta="right" ff="monospace" style={{ minWidth: 110 }}
|
||||||
@@ -232,14 +264,24 @@ export function MonthlyActualsPage() {
|
|||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||||
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
|
||||||
{!isReadOnly && (
|
{!isReadOnly && !isEditing && (
|
||||||
|
<Button
|
||||||
|
leftSection={<IconEdit size={16} />}
|
||||||
|
variant="light"
|
||||||
|
onClick={handleEditClick}
|
||||||
|
disabled={lines.length === 0}
|
||||||
|
>
|
||||||
|
Edit Actuals
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isReadOnly && isEditing && (
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
loading={saveMutation.isPending}
|
loading={saveMutation.isPending}
|
||||||
disabled={lines.length === 0}
|
disabled={lines.length === 0}
|
||||||
>
|
>
|
||||||
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
|
Save Actuals
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -276,7 +318,7 @@ export function MonthlyActualsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{savedJEId && (
|
{hasExistingActuals && !isEditing && (
|
||||||
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
|
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
@@ -292,10 +334,10 @@ export function MonthlyActualsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>
|
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
|
||||||
Acct #
|
Acct #
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>
|
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>
|
||||||
Account Name
|
Account Name
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
||||||
@@ -304,8 +346,8 @@ export function MonthlyActualsPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
|
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
|
||||||
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
|
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,6 +359,26 @@ export function MonthlyActualsPage() {
|
|||||||
<AttachmentPanel journalEntryId={savedJEId} />
|
<AttachmentPanel journalEntryId={savedJEId} />
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation modal for editing reconciled actuals */}
|
||||||
|
<Modal opened={confirmOpened} onClose={closeConfirm} title="Edit Reconciled Actuals" centered>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
Actuals for <Text span fw={700}>{monthLabel} {year}</Text> have already been
|
||||||
|
reconciled. Editing will void the existing journal entry and create a new one
|
||||||
|
when you save.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Press Edit to proceed, or Cancel to keep the current values.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={closeConfirm}>Cancel</Button>
|
||||||
|
<Button color="orange" leftSection={<IconEdit size={16} />} onClick={handleConfirmEdit}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
241
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
241
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Container, Title, Text, Stack, Card, Group, Button, TextInput,
|
||||||
|
Select, Stepper, ThemeIcon, Progress, Alert, Loader, Center, Anchor,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import {
|
||||||
|
IconUser, IconBuilding, IconUserPlus, IconListDetails,
|
||||||
|
IconCheck, IconPlayerPlay, IconConfetti,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ slug: 'profile', label: 'Complete Your Profile', icon: IconUser, description: 'Set up your name and contact' },
|
||||||
|
{ slug: 'workspace', label: 'Configure Your HOA', icon: IconBuilding, description: 'Organization name and settings' },
|
||||||
|
{ slug: 'invite_member', label: 'Invite a Team Member', icon: IconUserPlus, description: 'Add a board member or manager' },
|
||||||
|
{ slug: 'first_workflow', label: 'Set Up First Account', icon: IconListDetails, description: 'Create your chart of accounts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OnboardingPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
|
||||||
|
const { data: progress, isLoading } = useQuery({
|
||||||
|
queryKey: ['onboarding-progress'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/onboarding/progress');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const markStep = useMutation({
|
||||||
|
mutationFn: (step: string) => api.patch('/onboarding/progress', { step }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['onboarding-progress'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedSteps = progress?.completedSteps || [];
|
||||||
|
const completedCount = completedSteps.length;
|
||||||
|
const allDone = progress?.completedAt != null;
|
||||||
|
|
||||||
|
// Profile form
|
||||||
|
const profileForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
firstName: user?.firstName || '',
|
||||||
|
lastName: user?.lastName || '',
|
||||||
|
phone: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workspace form
|
||||||
|
const workspaceForm = useForm({
|
||||||
|
initialValues: { orgName: '', address: '', fiscalYearStart: '1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invite form
|
||||||
|
const inviteForm = useForm({
|
||||||
|
initialValues: { email: '', role: 'treasurer' },
|
||||||
|
validate: { email: (v) => (/\S+@\S+/.test(v) ? null : 'Valid email required') },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-advance to first incomplete step
|
||||||
|
const firstIncomplete = STEPS.findIndex((s) => !completedSteps.includes(s.slug));
|
||||||
|
if (firstIncomplete >= 0) setActiveStep(firstIncomplete);
|
||||||
|
}, [completedSteps]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allDone) {
|
||||||
|
return (
|
||||||
|
<Container size="sm" py={60}>
|
||||||
|
<Center>
|
||||||
|
<Stack align="center" gap="lg">
|
||||||
|
<ThemeIcon size={60} radius="xl" color="green" variant="light">
|
||||||
|
<IconConfetti size={30} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={2}>You're all set!</Title>
|
||||||
|
<Text c="dimmed" ta="center">
|
||||||
|
Your workspace is ready. Let's get to work.
|
||||||
|
</Text>
|
||||||
|
<Button size="lg" onClick={() => navigate('/dashboard')}>
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" py={40}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Welcome to HOA LedgerIQ</Title>
|
||||||
|
<Text c="dimmed" size="sm">Complete these steps to set up your workspace</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress value={(completedCount / STEPS.length) * 100} size="lg" color="teal" />
|
||||||
|
<Text size="sm" c="dimmed" ta="center">{completedCount} of {STEPS.length} steps complete</Text>
|
||||||
|
|
||||||
|
<Stepper
|
||||||
|
active={activeStep}
|
||||||
|
onStepClick={setActiveStep}
|
||||||
|
orientation="vertical"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{/* Step 1: Profile */}
|
||||||
|
<Stepper.Step
|
||||||
|
label={STEPS[0].label}
|
||||||
|
description={STEPS[0].description}
|
||||||
|
icon={completedSteps.includes('profile') ? <IconCheck size={16} /> : <IconUser size={16} />}
|
||||||
|
completedIcon={<IconCheck size={16} />}
|
||||||
|
color={completedSteps.includes('profile') ? 'green' : undefined}
|
||||||
|
>
|
||||||
|
<Card withBorder p="lg" mt="sm">
|
||||||
|
<form onSubmit={profileForm.onSubmit(() => markStep.mutate('profile'))}>
|
||||||
|
<Stack>
|
||||||
|
<Group grow>
|
||||||
|
<TextInput label="First Name" {...profileForm.getInputProps('firstName')} />
|
||||||
|
<TextInput label="Last Name" {...profileForm.getInputProps('lastName')} />
|
||||||
|
</Group>
|
||||||
|
<TextInput label="Phone (optional)" {...profileForm.getInputProps('phone')} />
|
||||||
|
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</Stepper.Step>
|
||||||
|
|
||||||
|
{/* Step 2: Workspace */}
|
||||||
|
<Stepper.Step
|
||||||
|
label={STEPS[1].label}
|
||||||
|
description={STEPS[1].description}
|
||||||
|
icon={completedSteps.includes('workspace') ? <IconCheck size={16} /> : <IconBuilding size={16} />}
|
||||||
|
completedIcon={<IconCheck size={16} />}
|
||||||
|
color={completedSteps.includes('workspace') ? 'green' : undefined}
|
||||||
|
>
|
||||||
|
<Card withBorder p="lg" mt="sm">
|
||||||
|
<form onSubmit={workspaceForm.onSubmit(() => markStep.mutate('workspace'))}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Organization Name" placeholder="Sunset Village HOA" {...workspaceForm.getInputProps('orgName')} />
|
||||||
|
<TextInput label="Address" placeholder="123 Main St" {...workspaceForm.getInputProps('address')} />
|
||||||
|
<Select
|
||||||
|
label="Fiscal Year Start Month"
|
||||||
|
data={[
|
||||||
|
{ value: '1', label: 'January' },
|
||||||
|
{ value: '4', label: 'April' },
|
||||||
|
{ value: '7', label: 'July' },
|
||||||
|
{ value: '10', label: 'October' },
|
||||||
|
]}
|
||||||
|
{...workspaceForm.getInputProps('fiscalYearStart')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</Stepper.Step>
|
||||||
|
|
||||||
|
{/* Step 3: Invite */}
|
||||||
|
<Stepper.Step
|
||||||
|
label={STEPS[2].label}
|
||||||
|
description={STEPS[2].description}
|
||||||
|
icon={completedSteps.includes('invite_member') ? <IconCheck size={16} /> : <IconUserPlus size={16} />}
|
||||||
|
completedIcon={<IconCheck size={16} />}
|
||||||
|
color={completedSteps.includes('invite_member') ? 'green' : undefined}
|
||||||
|
>
|
||||||
|
<Card withBorder p="lg" mt="sm">
|
||||||
|
<form onSubmit={inviteForm.onSubmit(() => markStep.mutate('invite_member'))}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Email Address" placeholder="teammate@example.com" {...inviteForm.getInputProps('email')} />
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
data={[
|
||||||
|
{ value: 'president', label: 'President' },
|
||||||
|
{ value: 'treasurer', label: 'Treasurer' },
|
||||||
|
{ value: 'secretary', label: 'Secretary' },
|
||||||
|
{ value: 'member_at_large', label: 'Member at Large' },
|
||||||
|
{ value: 'manager', label: 'Manager' },
|
||||||
|
{ value: 'viewer', label: 'Viewer' },
|
||||||
|
]}
|
||||||
|
{...inviteForm.getInputProps('role')}
|
||||||
|
/>
|
||||||
|
<Group>
|
||||||
|
<Button type="submit" loading={markStep.isPending}>Send Invite & Continue</Button>
|
||||||
|
<Button variant="subtle" onClick={() => markStep.mutate('invite_member')}>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</Stepper.Step>
|
||||||
|
|
||||||
|
{/* Step 4: First Account */}
|
||||||
|
<Stepper.Step
|
||||||
|
label={STEPS[3].label}
|
||||||
|
description={STEPS[3].description}
|
||||||
|
icon={completedSteps.includes('first_workflow') ? <IconCheck size={16} /> : <IconListDetails size={16} />}
|
||||||
|
completedIcon={<IconCheck size={16} />}
|
||||||
|
color={completedSteps.includes('first_workflow') ? 'green' : undefined}
|
||||||
|
>
|
||||||
|
<Card withBorder p="lg" mt="sm">
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
Your chart of accounts has been pre-configured with standard HOA accounts.
|
||||||
|
You can review and customize them now, or do it later.
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconListDetails size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
markStep.mutate('first_workflow');
|
||||||
|
navigate('/accounts');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Review Accounts
|
||||||
|
</Button>
|
||||||
|
<Button variant="subtle" onClick={() => markStep.mutate('first_workflow')}>
|
||||||
|
Use defaults & Continue
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stepper.Step>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<Group justify="center" mt="md">
|
||||||
|
<Button variant="subtle" color="gray" onClick={() => navigate('/dashboard')}>
|
||||||
|
Finish Later
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/pages/onboarding/OnboardingPendingPage.tsx
Normal file
82
frontend/src/pages/onboarding/OnboardingPendingPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Container, Center, Stack, Loader, Text, Title, Alert, Button } from '@mantine/core';
|
||||||
|
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
export function OnboardingPendingPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const sessionId = searchParams.get('session_id');
|
||||||
|
const [status, setStatus] = useState<string>('polling');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setError('No session ID provided');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/billing/status?session_id=${sessionId}`);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (data.status === 'active') {
|
||||||
|
setStatus('complete');
|
||||||
|
// Redirect to login page — user will get activation email
|
||||||
|
setTimeout(() => navigate('/login'), 3000);
|
||||||
|
} else if (data.status === 'not_configured') {
|
||||||
|
setError('Payment system is not configured. Please contact support.');
|
||||||
|
} else {
|
||||||
|
// Still provisioning — poll again
|
||||||
|
setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err.response?.data?.message || 'Failed to check status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [sessionId, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="sm" py={80}>
|
||||||
|
<Center>
|
||||||
|
<Stack align="center" gap="lg">
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
<Button variant="light" onClick={() => navigate('/pricing')}>
|
||||||
|
Back to Pricing
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : status === 'complete' ? (
|
||||||
|
<>
|
||||||
|
<IconCheck size={48} color="var(--mantine-color-green-6)" />
|
||||||
|
<Title order={2}>Your account is ready!</Title>
|
||||||
|
<Text c="dimmed" ta="center">
|
||||||
|
Check your email for an activation link to set your password and get started.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">Redirecting to login...</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader size="xl" />
|
||||||
|
<Title order={2}>Setting up your account...</Title>
|
||||||
|
<Text c="dimmed" ta="center" maw={400}>
|
||||||
|
We're creating your HOA workspace. This usually takes just a few seconds.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, Badge, Modal,
|
Title, Table, Group, Button, Stack, Text, Badge, Modal,
|
||||||
NumberInput, Select, TextInput, Loader, Center,
|
NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { DateInput } from '@mantine/dates';
|
import { DateInput } from '@mantine/dates';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconPlus } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
@@ -15,11 +15,13 @@ import { useIsReadOnly } from '../../stores/authStore';
|
|||||||
interface Payment {
|
interface Payment {
|
||||||
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
||||||
invoice_number: string; payment_date: string; amount: string;
|
invoice_number: string; payment_date: string; amount: string;
|
||||||
payment_method: string; reference_number: string; status: string;
|
payment_method: string; reference_number: string; status: string; notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentsPage() {
|
export function PaymentsPage() {
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [editing, setEditing] = useState<Payment | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
@@ -39,10 +41,18 @@ export function PaymentsPage() {
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
invoice_id: '', amount: 0, payment_method: 'check',
|
invoice_id: '', amount: 0, payment_method: 'check',
|
||||||
reference_number: '', payment_date: new Date(),
|
reference_number: '', payment_date: new Date(), notes: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invalidateAll = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
|
||||||
|
};
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (values: any) => {
|
mutationFn: (values: any) => {
|
||||||
const inv = invoices.find((i: any) => i.id === values.invoice_id);
|
const inv = invoices.find((i: any) => i.id === values.invoice_id);
|
||||||
@@ -53,22 +63,88 @@ export function PaymentsPage() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
invalidateAll();
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
|
||||||
notifications.show({ message: 'Payment recorded', color: 'green' });
|
notifications.show({ message: 'Payment recorded', color: 'green' });
|
||||||
close(); form.reset();
|
close(); setEditing(null); form.reset();
|
||||||
},
|
},
|
||||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (values: any) => {
|
||||||
|
return api.put(`/payments/${editing!.id}`, {
|
||||||
|
payment_date: values.payment_date.toISOString().split('T')[0],
|
||||||
|
amount: values.amount,
|
||||||
|
payment_method: values.payment_method,
|
||||||
|
reference_number: values.reference_number,
|
||||||
|
notes: values.notes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
notifications.show({ message: 'Payment updated', color: 'green' });
|
||||||
|
close(); setEditing(null); form.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/payments/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
notifications.show({ message: 'Payment deleted', color: 'orange' });
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
close(); setEditing(null); form.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (payment: Payment) => {
|
||||||
|
setEditing(payment);
|
||||||
|
form.setValues({
|
||||||
|
invoice_id: payment.invoice_id || '',
|
||||||
|
amount: parseFloat(payment.amount || '0'),
|
||||||
|
payment_method: payment.payment_method || 'check',
|
||||||
|
reference_number: payment.reference_number || '',
|
||||||
|
payment_date: new Date(payment.payment_date),
|
||||||
|
notes: payment.notes || '',
|
||||||
|
});
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNew = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.reset();
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (values: any) => {
|
||||||
|
if (editing) {
|
||||||
|
updateMutation.mutate(values);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
const invoiceOptions = invoices.map((i: any) => ({
|
const formatPeriod = (inv: any) => {
|
||||||
|
if (inv.period_start && inv.period_end) {
|
||||||
|
const start = new Date(inv.period_start).toLocaleDateString(undefined, { month: 'short' });
|
||||||
|
const end = new Date(inv.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
||||||
|
return inv.period_start === inv.period_end ? start : `${start}-${end}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoiceOptions = invoices.map((i: any) => {
|
||||||
|
const period = formatPeriod(i);
|
||||||
|
const periodStr = period ? ` - ${period}` : '';
|
||||||
|
return {
|
||||||
value: i.id,
|
value: i.id,
|
||||||
label: `${i.invoice_number} - ${i.unit_number || 'Unit'} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
|
label: `${i.invoice_number} - ${i.unit_number || 'Unit'}${periodStr} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
@@ -76,7 +152,7 @@ export function PaymentsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Payments</Title>
|
<Title order={2}>Payments</Title>
|
||||||
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>}
|
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Record Payment</Button>}
|
||||||
</Group>
|
</Group>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@@ -84,6 +160,7 @@ export function PaymentsPage() {
|
|||||||
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
|
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
|
||||||
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
|
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
|
||||||
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
|
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
|
||||||
|
{!isReadOnly && <Table.Th></Table.Th>}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -96,18 +173,34 @@ export function PaymentsPage() {
|
|||||||
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
|
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
|
||||||
<Table.Td>{p.reference_number}</Table.Td>
|
<Table.Td>{p.reference_number}</Table.Td>
|
||||||
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
|
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
|
<Table.Td>
|
||||||
|
<Tooltip label="Edit payment">
|
||||||
|
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
{payments.length === 0 && (
|
{payments.length === 0 && (
|
||||||
<Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
|
<Table.Tr><Table.Td colSpan={isReadOnly ? 7 : 8}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
|
||||||
)}
|
)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<Modal opened={opened} onClose={close} title="Record Payment">
|
|
||||||
<form onSubmit={form.onSubmit((v) => createMutation.mutate(v))}>
|
{/* Create / Edit Payment Modal */}
|
||||||
|
<Modal opened={opened} onClose={() => { close(); setEditing(null); form.reset(); }} title={editing ? 'Edit Payment' : 'Record Payment'}>
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
{!editing && (
|
||||||
<Select label="Invoice" required data={invoiceOptions} searchable
|
<Select label="Invoice" required data={invoiceOptions} searchable
|
||||||
{...form.getInputProps('invoice_id')} />
|
{...form.getInputProps('invoice_id')} />
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<TextInput label="Invoice" value={editing.invoice_number || 'N/A'} disabled />
|
||||||
|
)}
|
||||||
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
|
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
|
||||||
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
|
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
|
||||||
{...form.getInputProps('amount')} />
|
{...form.getInputProps('amount')} />
|
||||||
@@ -118,10 +211,60 @@ export function PaymentsPage() {
|
|||||||
]} {...form.getInputProps('payment_method')} />
|
]} {...form.getInputProps('payment_method')} />
|
||||||
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
|
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
|
||||||
{...form.getInputProps('reference_number')} />
|
{...form.getInputProps('reference_number')} />
|
||||||
<Button type="submit" loading={createMutation.isPending}>Record Payment</Button>
|
<TextInput label="Notes" placeholder="Optional notes"
|
||||||
|
{...form.getInputProps('notes')} />
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
onClick={() => setDeleteConfirm(editing)}
|
||||||
|
>
|
||||||
|
Delete Payment
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={updateMutation.isPending}>
|
||||||
|
Update Payment
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button type="submit" fullWidth loading={createMutation.isPending}>Record Payment</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={!!deleteConfirm}
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
title="Delete Payment"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
Are you sure you want to delete this payment of{' '}
|
||||||
|
<Text span fw={700}>{deleteConfirm ? fmt(deleteConfirm.amount) : ''}</Text>{' '}
|
||||||
|
for unit {deleteConfirm?.unit_number}?
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
This will also remove the associated journal entry and recalculate the invoice balance.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||||
|
>
|
||||||
|
Delete Payment
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
IconUser, IconPalette, IconClock, IconBell, IconEye,
|
IconUser, IconPalette, IconClock, IconBell, IconEye,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
export function UserPreferencesPage() {
|
export function UserPreferencesPage() {
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
|
const { colorScheme, toggleColorScheme, compactView, toggleCompactView } = usePreferencesStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -66,17 +68,20 @@ export function UserPreferencesPage() {
|
|||||||
<Text size="sm">Dark Mode</Text>
|
<Text size="sm">Dark Mode</Text>
|
||||||
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
|
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch disabled />
|
<Switch
|
||||||
|
checked={colorScheme === 'dark'}
|
||||||
|
onChange={toggleColorScheme}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm">Compact View</Text>
|
<Text size="sm">Compact View</Text>
|
||||||
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch disabled />
|
<Switch checked={compactView} onChange={toggleCompactView} />
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text>
|
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user