60 Commits

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:41:13 -04:00
170461c359 Merge branch 'claude/reverent-moore' - Resend email integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:33:17 -04:00
aacec1cce3 feat: integrate Resend for transactional email delivery
Replace the stubbed email service with Resend API integration.
Emails are sent with branded HTML templates including activation,
welcome, payment failed, member invite, and password reset flows.

- Install resend@6.9.4 in backend
- Rewrite EmailService with Resend SDK + graceful fallback to
  stub mode when API key is not configured
- Add branded HTML email template with CTA buttons, preheader
  text, and fallback URL for all email types
- Add reply-to support (sales@hoaledgeriq.com in production)
- Track send status (sent/failed) in shared.email_log metadata
- Add RESEND_API_KEY, RESEND_FROM_ADDRESS, RESEND_REPLY_TO env
  vars to both docker-compose.yml and docker-compose.prod.yml
- Add sendPasswordResetEmail() method for future use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:29:20 -04:00
6b12fcd7d7 Merge branch 'claude/reverent-moore' 2026-03-17 18:04:14 -04:00
8e58d04568 fix: add APP_URL and missing env vars to Docker Compose configs
APP_URL was never passed to the backend container, causing Stripe
checkout success_url to redirect to http://localhost instead of the
production domain. The prod overlay also completely replaced the base
environment block, dropping all Stripe, SSO, WebAuthn, and invite
token variables.

- Add APP_URL to base docker-compose.yml (default: http://localhost)
- Add all missing vars to docker-compose.prod.yml with production
  defaults (app.hoaledgeriq.com)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:51:34 -04:00
c2e52bee64 Merge pull request 'feat: enterprise pricing shows "Request Quote" linking to interest form' (#8) from claude/reverent-moore into main
Reviewed-on: #8
2026-03-17 07:53:16 -04:00
9cd641923d feat: enterprise pricing shows "Request Quote" linking to interest form
Enterprise plan no longer displays a fixed price. Instead it shows
"Request Quote" and the CTA opens the interest form on hoaledgeriq.com
in a new tab to capture leads for custom quotes.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 07:46:11 -04:00
e62f3e7b07 Merge pull request 'claude/reverent-moore' (#6) from claude/reverent-moore into main
Reviewed-on: #6
2026-03-17 06:55:45 -04:00
af68304692 feat: sidebar reorg, compact view preference, and UI polish
- Remove redundant Settings link from sidebar (accessible via user menu)
- Move Transactions section below Board Reference for better grouping
- Promote Investment Scenarios to its own top-level sidebar item
- Add Compact View preference with tighter spacing theme
- Wire compact theme into MantineProvider with dynamic switching
- Enable Compact View toggle in both Preferences and Settings pages
- Install missing @simplewebauthn/browser package (lock file update)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:39:41 -04:00
20438b7ef5 docs: add payment, onboarding, and auth reference guide
Covers Stripe billing flow, provisioning pipeline, activation magic links,
onboarding checklist, refresh tokens, MFA, SSO, passkeys, env var reference,
manual intervention checklist, and full API endpoint reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:24:59 -04:00
e3022f20c5 Merge pull request 'claude/SSOMFASTRIPE' (#5) from claude/reverent-moore into main
Reviewed-on: #5
2026-03-16 21:22:34 -04:00
e9738420ea fix: swap Quick Stats and Recent Transactions on dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:21:15 -04:00
dfcd172ef3 feat: SaaS onboarding, Stripe billing, MFA, SSO, passkeys, refresh tokens
Complete SaaS self-service onboarding sprint:

- Stripe-powered signup flow: pricing page → checkout → provisioning → activation
- Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh
- TOTP MFA with QR setup, recovery codes, and login challenge flow
- Google + Azure AD SSO (conditional on env vars) with account linking
- WebAuthn passkey registration and passwordless login
- Guided onboarding checklist with server-side progress tracking
- Stubbed email service (console + DB logging, ready for real provider)
- Settings page with tabbed security settings (MFA, passkeys, linked accounts)
- Login page enhanced with MFA verification, SSO buttons, passkey login
- Database migration 015 with all new tables and columns
- Version bump to 2026.03.17

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:28:45 -04:00
267d92933e chore: reorganize sidebar navigation and bump version to 2026.03.16
Remove the Planning section. Move Projects and Capital Planning (as
sub-item) into Board Planning. Move Investment Planning with Investment
Scenarios as sub-item into Board Planning. Move Vendors into new Board
Reference section. Board Planning order: Budget Planning, Projects >
Capital Planning, Assessment Scenarios, Investment Planning > Investment
Scenarios, Compare Scenarios. Sidebar now supports parent items with
their own route plus nested children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:21:58 -04:00
159c59734e feat: investment scenario UX improvements and interest calculations
- Refresh Recommendations now shows inline processing banner with
  animated progress bar while keeping existing results visible (dimmed).
  Auto-scrolls to AI section and shows titled notification on completion.
- Investment recommendations now auto-calculate purchase and maturity
  dates from a configurable start date (defaults to today) in the
  "Add to Plan" modal, so scenarios build projections immediately.
- Projection engine computes per-investment and total interest earned,
  ROI percentage, and total principal invested. Summary cards on the
  Investment Scenario detail page display these metrics prominently.
- Replaced dropdown action menu with inline Edit/Execute/Remove
  icon buttons matching the assessment scenarios pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:18:40 -04:00
7ba5c414b1 fix: handle multi-component investment recommendations (CD ladders)
When adding a multi-stage investment strategy (e.g. CD ladder) from AI
recommendations to a board planning scenario, all component investments
are now created as separate rows instead of collapsing into a single
investment. The AI prompt now instructs inclusion of a components array,
the backend loops through components to create individual scenario
investments, and the frontend passes and displays component details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:59:56 -04:00
a98a7192bb fix: assessment scenarios UX tweaks and projection improvements
- Reorder sidebar: Assessment Scenarios now directly under Budget Planning
- Simplify special assessment form: remove Total Amount, keep Per Unit only
- Replace Duration field from free-text installments to dropdown (one-time/quarterly/6mo/annual)
- Update Change column display to show total per-unit with duration label
- Fix Reserve Coverage to use planned capital project costs instead of budget expenses
- Include capital_projects table in projection engine alongside projects table
- Replace actions dropdown menu with inline Edit/Remove icon buttons
- Remove Refresh Projection button (projection auto-refreshes on changes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:28:33 -04:00
1d1073cba1 style: add white glow outline to logo in dark mode
Use CSS drop-shadow filter on the logo img in dark mode to create a
subtle white outline that helps the transparent-background logo stand
out against the dark header and login page backgrounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:02:04 -04:00
cf061c1505 fix: Budget Manager shows stale data when switching years
The budgetData was stored in a separate useState and updated inside
queryFn. When switching years, React Query served cached data with
isLoading=false but the local state still held the previous year's
data, causing the "no budget" empty state to flash intermittently.

Fix: Use query data directly as source of truth. Local state (editData)
is only used when actively editing. Added a small spinner indicator
when refetching in the background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:59:46 -04:00
5ebfc4f3aa chore: replace SVG logo with transparent-background PNG for dark theme support
- Add new logo.png (2090x512) with transparent background
- Update AppLayout and LoginPage imports from .svg to .png
- Old SVG had opaque background that clashed with dark theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:52:46 -04:00
f20f54b128 fix: compound inflation, budget planner CSV import, simplify budget manager
- Fix compound inflation: use Math.pow(1 + rate/100, yearsGap) instead of
  flat rate so multi-year gaps (e.g., 2026→2029) compound annually
- Budget Planner: add CSV import flow + Download Template button; show proper
  empty state when no base budget exists with Create/Import options
- Budget Manager: remove CSV import, Download Template, and Save buttons;
  redirect users to Budget Planner when no budget exists for selected year
- Fix getAvailableYears to return null latestBudgetYear when no budgets exist
  and include current year in year selector for fresh tenants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:39:31 -04:00
f2b0b57535 fix: use correct column name for accounts table in budget planning query
The accounts table uses 'name' not 'account_name'. Alias it correctly
in the getPlan JOIN query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:37:47 -04:00
e6fe2314de feat: add Future Year Budget Planning with inflation-adjusted projections
Adds budget planning capability under Board Planning, allowing HOA boards
to model future year budgets with configurable per-year inflation rates.

Backend:
- New budget_plans + budget_plan_lines tables (migration 014)
- BudgetPlanningService: CRUD, inflation generation (per-month preservation),
  status workflow (planning → approved → ratified), ratify-to-official copy
- 8 new API endpoints on board-planning controller
- Projection engine (both board-planning and reports) now falls back to
  planned budgets via UNION ALL query when no official budget exists
- Extended year range from 3 to dynamic based on projection months

Frontend:
- BudgetPlanningPage with monthly grid table (mirrors BudgetsPage pattern)
- Year selector, inflation rate control, status progression buttons
- Inline editing with save, confirmation modals for status changes
- Manual edit tracking with visual indicator
- Summary cards for income/expense totals

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:24:18 -04:00
c8d77aaa48 feat: add Board Planning module with investment/assessment scenario modeling
Implements Phase 11 Forecasting Tools - a "what-if" scenario planning system
for HOA boards to model financial decisions before committing.

Backend:
- 3 new tenant-scoped tables: board_scenarios, scenario_investments, scenario_assessments
- Migration script (013) for existing tenants
- Full CRUD service for scenarios, investments, and assessments
- Projection engine adapted from cash flow forecast with investment/assessment deltas
- Scenario comparison endpoint (up to 4 scenarios)
- Investment execution flow: converts planned → real investment_accounts + journal entry

Frontend:
- New "Board Planning" sidebar section with 3 pages
- Investment Scenarios: list, create, detail with investments table + timeline
- Assessment Scenarios: list, create, detail with changes table
- Scenario Comparison: multi-select overlay chart + summary metrics
- Shared components: ProjectionChart, InvestmentTimeline, ScenarioCard, forms
- AI Recommendation → Investment Plan integration (Story 1A)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:52:10 -04:00
b13fbfe8c7 Merge branch 'claude/ecstatic-elgamal' 2026-03-13 14:52:59 -04:00
280a5996f6 fix: use rate-based estimate for interest YoY projection
The projected interest was extrapolating from sparse YTD journal entries,
producing inaccurate results early in the year. Now uses the same
rate-based est_monthly_interest calculation (from account balances and
investment rates) for remaining months, consistent with the dashboard KPI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:52:54 -04:00
9a082d2950 Merge branch 'claude/ecstatic-elgamal' 2026-03-13 14:41:20 -04:00
82433955bd feat: dashboard quick stats enhancements and monthly actuals read/edit mode
Dashboard Quick Stats:
- Create Capital Projects section with "Planned Capital Spend 2026"
- Fix Interest Earned YTD to pull from actual journal entries on
  interest income accounts instead of unrealized investment gains
- Add Interest Earned YoY showing projected current year vs last year
  actuals with percentage change badge

Monthly Actuals:
- Default to read-only view when actuals are already reconciled
- Show "Edit Actuals" button instead of "Save Actuals" for reconciled months
- Add confirmation modal warning that editing will void existing journal
  entry before allowing edits
- New months without actuals open directly in edit mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:41:14 -04:00
8e2456dcae Merge branch 'claude/ecstatic-elgamal' 2026-03-11 15:51:12 -04:00
1acd8c3bff fix: check reserve-funded projects instead of unused reserve_components table
The missing-data warning was checking the reserve_components table,
which users never populate. All reserve data lives in the projects
table. Now only warns when no reserve-funded projects exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:51:12 -04:00
2de0cde94c Merge branch 'claude/ecstatic-elgamal' 2026-03-11 15:47:02 -04:00
94c7c90b91 fix: use project estimated_cost for reserve funded ratio calculation
The health score funded ratio was only reading from the reserve_components
table (replacement_cost), but users enter their reserve data on the
Projects page using estimated_cost. When reserve_components is empty,
the funded ratio now falls back to reserve-funded projects for:
- Total replacement cost (estimated_cost)
- Component funding status (current_fund_balance)
- Urgent components due within 5 years (remaining_life_years)
- AI prompt component detail lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:46:56 -04:00
f47fbfcf93 Merge branch 'claude/ecstatic-elgamal' 2026-03-11 15:42:24 -04:00
04771f370c fix: clarify reserve health score when no components are entered
- Add missing-data warning when reserve_components table is empty so
  users see "No reserve components found" on the dashboard
- Change AI prompt to show "N/A" instead of "0.0%" for funded ratio
  when no components exist, preventing misleading "0% funded" reports
- Instruct AI not to report 0% funded when data is simply missing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:42:15 -04:00
208c1dd7bc security: address assessment findings and bump to v2026.3.11
- C1: Disable Swagger UI in production (env gate)
- M1+M2: Add Helmet.js for security headers (CSP, X-Frame-Options,
  X-Content-Type-Options, Referrer-Policy) and remove X-Powered-By
- H2: Add @nestjs/throttler rate limiting (5 req/min on login/register)
- M4: Remove orgSchema from JWT payload and client-side storage;
  tenant middleware now resolves schema from orgId via cached DB lookup
- L1: Fix Chatwoot user identification (read from auth store on ready)
- Remove schemaName from frontend Organization type and UI displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:32:51 -04:00
61a4f27af4 security: address assessment findings and bump to v2026.3.11
- C1: Disable Swagger UI in production (env gate)
- M1+M2: Add Helmet.js for security headers (CSP, X-Frame-Options,
  X-Content-Type-Options, Referrer-Policy) and remove X-Powered-By
- H2: Add @nestjs/throttler rate limiting (5 req/min on login/register)
- M4: Remove orgSchema from JWT payload and client-side storage;
  tenant middleware now resolves schema from orgId via cached DB lookup
- L1: Fix Chatwoot user identification (read from auth store on ready)
- Remove schemaName from frontend Organization type and UI displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:22:58 -04:00
a047144922 Added userID and URL to Chatwoot Script 2026-03-10 14:49:50 -04:00
508a86d16c fix: resolve Vite parse5 HTML error in index.html
Fix malformed Chatwoot chat widget script that caused Vite's parse5
HTML parser to throw "eof-in-element-that-can-contain-only-text".
Also fix broken URL (https// -> https://) for the chat widget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:32:35 -04:00
16e1ada261 fix: budget save error and add read-only view mode (v2026.03.10)
Fix budget save 500 error caused by three data mismatches between
frontend and backend: wrapped payload ({lines:[...]}) vs expected
raw array, snake_case vs camelCase field names (account_id vs
accountId), and dec_amt vs dec for December values.

Add read-only budget view as default for existing budgets with an
"Edit Budget" button to enter edit mode, and Cancel to discard
changes - reducing accidental edits.

Bump version to 2026.03.10 across all packages and settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:28:09 -04:00
6bd080f8c4 Merge branch 'claude/practical-rhodes' 2026-03-10 14:22:14 -04:00
be3a5191c5 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:22:08 -04:00
7d4df25d16 Update frontend/index.html 2026-03-09 14:17:04 -04:00
538828b91a Merge pull request 'fix: dark mode styling across 5 pages' (#4) from fix/dark-mode-styling into main 2026-03-09 14:04:50 -04:00
14160854b9 fix: resolve hardcoded light backgrounds breaking dark mode across 5 pages
Replace hardcoded light colors (#e6f9e6, #fde8e8, white, #e9ecef) with
theme-aware alternatives using usePreferencesStore. Affected pages:
- CashFlowForecastPage: forecast row and striped row backgrounds
- MonthlyActualsPage: sticky column backgrounds, borders, section headers
- BudgetsPage: sticky column backgrounds, borders, section headers
- BudgetVsActualPage: income/expense section header backgrounds
- QuarterlyReportPage: income/expense and total row backgrounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:02:46 -04:00
36d486d78c Add Chat Widget for support
added support chat widget to index.html
2026-03-09 13:31:17 -04:00
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

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

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

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

View File

@@ -13,6 +13,30 @@ AI_MODEL=qwen/qwen3.5-397b-a17b
# Set to 'true' to enable detailed AI prompt/response logging
AI_DEBUG=false
# Stripe Billing
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Stripe Price IDs (Monthly)
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=price_professional_monthly
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
# Stripe Price IDs (Annual — 25% discount)
STRIPE_STARTER_ANNUAL_PRICE_ID=price_starter_annual
STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=price_professional_annual
STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=price_enterprise_annual
# Trial configuration
REQUIRE_PAYMENT_METHOD_FOR_TRIAL=false
# Email (Resend)
RESEND_API_KEY=re_your_resend_api_key
# Application
APP_URL=http://localhost
INVITE_TOKEN_SECRET=dev-invite-secret
# New Relic APM — set ENABLED=true and provide your license key to activate
NEW_RELIC_ENABLED=false
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here

587
ONBOARDING-AND-AUTH.md Normal file
View File

@@ -0,0 +1,587 @@
# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide
> **Version:** 2026.03.18
> **Last updated:** March 18, 2026
> **Migrations:** `db/migrations/015-saas-onboarding-auth.sql`, `db/migrations/017-billing-enhancements.sql`
---
## Table of Contents
1. [High-Level Flow](#1-high-level-flow)
2. [Stripe Billing & Checkout](#2-stripe-billing--checkout)
3. [14-Day Free Trial](#3-14-day-free-trial)
4. [Monthly / Annual Billing](#4-monthly--annual-billing)
5. [Provisioning Pipeline](#5-provisioning-pipeline)
6. [Account Activation (Magic Link)](#6-account-activation-magic-link)
7. [Guided Onboarding Checklist](#7-guided-onboarding-checklist)
8. [Subscription Management & Upgrade/Downgrade](#8-subscription-management--upgradedowngrade)
9. [ACH / Invoice Billing](#9-ach--invoice-billing)
10. [Access Control & Grace Periods](#10-access-control--grace-periods)
11. [Authentication & Sessions](#11-authentication--sessions)
12. [Multi-Factor Authentication (TOTP)](#12-multi-factor-authentication-totp)
13. [Single Sign-On (SSO)](#13-single-sign-on-sso)
14. [Passkeys (WebAuthn)](#14-passkeys-webauthn)
15. [Environment Variables Reference](#15-environment-variables-reference)
16. [Manual Intervention & Ops Tasks](#16-manual-intervention--ops-tasks)
17. [What's Stubbed vs. Production-Ready](#17-whats-stubbed-vs-production-ready)
18. [API Endpoint Reference](#18-api-endpoint-reference)
---
## 1. High-Level Flow
```
Visitor hits /pricing
|
v
Selects plan (Starter / Professional / Enterprise)
Chooses billing frequency (Monthly / Annual — 25% discount)
Enters email + business name
|
v
POST /api/billing/start-trial (no card required)
|
v
Backend creates Stripe customer + subscription with trial_period_days=14
Backend provisions: org -> schema -> user -> invite token -> email
|
v
Frontend navigates to /onboarding/pending?session_id=xxx
(polls GET /api/billing/status every 3s)
|
v
Status returns "active" -> user is redirected to /login
|
v
User clicks activation link from email
|
v
GET /activate?token=xxx -> validates token
POST /activate -> sets password + name, issues session
|
v
Redirect to /onboarding (4-step guided wizard)
|
v
Dashboard (14-day trial active)
|
v
Day 11: Stripe fires customer.subscription.trial_will_end webhook
Backend sends trial-ending reminder email
|
v
User adds payment method via Stripe Portal (Settings > Manage Billing)
|
v
Trial ends -> Stripe charges card -> subscription becomes 'active'
OR: No card -> subscription cancelled -> org archived
```
---
## 2. Stripe Billing & Checkout
### Plans & Pricing
| Plan | Monthly | Annual (25% off) | Unit Limit |
|------|---------|-------------------|------------|
| Starter | $29/mo | $261/yr ($21.75/mo) | 50 units |
| Professional | $79/mo | $711/yr ($59.25/mo) | 200 units |
| Enterprise | Custom | Custom | Unlimited |
### Stripe Products & Prices
Each plan has **two Stripe Prices** (monthly and annual):
| Env Variable | Description |
|-------------|-------------|
| `STRIPE_STARTER_MONTHLY_PRICE_ID` | Starter monthly recurring price |
| `STRIPE_STARTER_ANNUAL_PRICE_ID` | Starter annual recurring price |
| `STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID` | Professional monthly recurring price |
| `STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID` | Professional annual recurring price |
| `STRIPE_ENTERPRISE_MONTHLY_PRICE_ID` | Enterprise monthly recurring price |
| `STRIPE_ENTERPRISE_ANNUAL_PRICE_ID` | Enterprise annual recurring price |
Backward compatibility: `STRIPE_STARTER_PRICE_ID` (old single var) maps to monthly if the new `_MONTHLY_` var is not set.
### Two Billing Paths
| Path | Audience | Payment | Trial |
|------|----------|---------|-------|
| **Path A: Self-serve (Card)** | Starter & Professional | Automatic card charge | 14-day no-card trial |
| **Path B: Invoice / ACH** | Enterprise (admin-set) | Invoice with Net-30 terms | Admin configures |
### Webhook Events Handled
| Event | Action |
|-------|--------|
| `checkout.session.completed` | Triggers full provisioning pipeline (card-required flow) |
| `invoice.payment_succeeded` | Sets org status to `active` (reactivation after trial/past_due) |
| `invoice.payment_failed` | Sets org to `past_due`, sends payment-failed email |
| `customer.subscription.deleted` | Sets org status to `archived` |
| `customer.subscription.trial_will_end` | Sends trial-ending reminder email (3 days before) |
| `customer.subscription.updated` | Syncs plan, interval, status, and collection_method to DB |
All webhook events are deduplicated via the `shared.stripe_events` table (idempotency by Stripe event ID).
---
## 3. 14-Day Free Trial
### How It Works
1. User visits `/pricing`, selects a plan and billing frequency
2. User enters email + business name (required)
3. Clicks "Start Free Trial"
4. Backend creates Stripe customer (no payment method)
5. Backend creates subscription with `trial_period_days: 14`
6. Backend provisions org with `status = 'trial'` immediately
7. User receives activation email, sets password, starts using the app
### Trial Configuration
| Setting | Description |
|---------|-------------|
| `REQUIRE_PAYMENT_METHOD_FOR_TRIAL` | `false` (default): no-card trial. `true`: uses Stripe Checkout (card required upfront). |
### Trial Lifecycle
| Day | Event |
|-----|-------|
| 0 | Trial starts, full access granted |
| 11 | `customer.subscription.trial_will_end` webhook fires |
| 11 | Trial-ending email sent ("Your trial ends in 3 days") |
| 14 | Trial ends |
| 14 | If card on file: Stripe charges, subscription becomes `active` |
| 14 | If no card: subscription cancelled, org set to `archived` |
### Trial Behavior by Plan Frequency
- **Monthly trial**: Trial ends, charge monthly price
- **Annual trial**: Trial ends, charge full annual amount
### Trial End Behavior
Configured in Stripe subscription: `trial_settings.end_behavior.missing_payment_method: 'cancel'`
When trial ends without a payment method, the subscription is cancelled and the org is archived. Users can resubscribe at any time.
---
## 4. Monthly / Annual Billing
### Pricing Page Toggle
The pricing page (`PricingPage.tsx`) features a segmented control toggle:
- **Monthly**: Shows monthly prices ($29/mo, $79/mo)
- **Annual (Save 25%)**: Shows effective monthly rate + annual total ($21.75/mo billed annually at $261/yr)
The selected billing frequency is passed to the backend when starting a trial or creating a checkout session.
### Annual Discount
Annual pricing = Monthly price x 12 x 0.75 (25% discount):
- Starter: $29 x 12 x 0.75 = **$261/yr**
- Professional: $79 x 12 x 0.75 = **$711/yr**
---
## 5. Provisioning Pipeline
When a trial starts or `checkout.session.completed` fires, the backend runs **inline provisioning**:
1. **Create organization** in `shared.organizations` with:
- `name` = business name from signup
- `schema_name` = `tenant_{random_12_chars}`
- `status` = `trial` (for trial) or `active` (for card checkout)
- `plan_level` = selected plan
- `billing_interval` = `month` or `year`
- `stripe_customer_id` + `stripe_subscription_id`
- `trial_ends_at` (if trial)
- Uses `ON CONFLICT (stripe_customer_id)` for idempotency
2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`
3. **Create or find user** in `shared.users` by email
4. **Create membership** in `shared.user_organizations` (role: `president`)
5. **Generate invite token** (JWT, 72-hour expiry)
6. **Send activation email** with link to set password
7. **Initialize onboarding** progress row
### Provisioning Status Polling
`GET /api/billing/status?session_id=xxx` (no auth required)
Accepts both Stripe checkout session IDs and subscription IDs. Returns: `{ status }` where status is:
- `not_configured` -- Stripe not set up
- `pending` -- no customer ID yet
- `provisioning` -- org exists but not ready
- `active` -- ready (includes `trial` status)
---
## 6. Account Activation (Magic Link)
### Validate Token
`GET /api/auth/activate?token=xxx` -- returns `{ valid, email, orgName, orgId, userId }`
### Activate Account
`POST /api/auth/activate` -- body `{ token, password, fullName }` -- sets password, issues session
---
## 7. Guided Onboarding Checklist
| Step Key | UI Label | Description |
|----------|----------|-------------|
| `profile` | Profile | Set up user profile |
| `workspace` | Workspace | Configure organization settings |
| `invite_member` | Invite Member | Invite at least one team member |
| `first_workflow` | First Account | Create the first chart-of-accounts entry |
---
## 8. Subscription Management & Upgrade/Downgrade
### Stripe Customer Portal
Users manage their subscription through the **Stripe Customer Portal**, accessed via:
- Settings page > Billing card > "Manage Billing" button
- Calls `POST /api/billing/portal` which creates a portal session and returns the URL
### What Users Can Do in the Portal
- **Switch plans**: Change between Starter and Professional
- **Switch billing frequency**: Monthly to Annual (and vice versa)
- **Update payment method**: Add/change credit card
- **Cancel subscription**: Cancels at end of current period
- **View invoices**: See billing history
### Upgrade/Downgrade Behavior
| Change | Behavior |
|--------|----------|
| Monthly to Annual | Immediate. Prorate remaining monthly time, start annual cycle now. |
| Annual to Monthly | Scheduled at end of current annual period. |
| Starter to Professional | Immediate. Prorate price difference. |
| Professional to Starter | Scheduled at end of current period. |
Stripe handles proration automatically when configured with `proration_behavior: 'create_prorations'`.
### Subscription Info Endpoint
`GET /api/billing/subscription` (auth required) returns:
```json
{
"plan": "professional",
"planName": "Professional",
"billingInterval": "month",
"status": "active",
"collectionMethod": "charge_automatically",
"trialEndsAt": null,
"currentPeriodEnd": "2026-04-18T00:00:00.000Z",
"cancelAtPeriodEnd": false
}
```
---
## 9. ACH / Invoice Billing
### Overview
For enterprise customers who need to pay via ACH bank transfer or wire, an admin can switch the subscription's collection method from automatic card charge to invoice billing.
### How It Works
1. **Admin** calls `PUT /api/admin/organizations/:id/billing` with:
```json
{ "collectionMethod": "send_invoice", "daysUntilDue": 30 }
```
2. Stripe subscription is updated: `collection_method = 'send_invoice'`, `days_until_due = 30`
3. At each billing cycle, Stripe generates an invoice and emails it to the customer
4. Customer pays via ACH / wire / bank transfer
5. When payment is received, Stripe marks invoice paid and org remains active
### Access Rules for Invoice Customers
| Stage | Access |
|-------|--------|
| Trial | Full |
| Invoice issued | Full |
| Due date passed | Read-only (past_due) |
| 15+ days overdue | Admin may archive |
### Switching Back
To switch back to automatic card billing:
```json
{ "collectionMethod": "charge_automatically" }
```
---
## 10. Access Control & Grace Periods
### Organization Status Access Rules
| Status | Access | Description |
|--------|--------|-------------|
| `trial` | **Full** | 14-day trial, all features available |
| `active` | **Full** | Paid subscription, all features available |
| `past_due` | **Read-only** | Payment failed or invoice overdue. Users can view data but cannot create/edit/delete. |
| `suspended` | **Blocked** | Admin suspended. 403 on all org-scoped endpoints. |
| `archived` | **Blocked** | Subscription cancelled. 403 on all org-scoped endpoints. Data preserved. |
### Implementation
- **Tenant Middleware** (`tenant.middleware.ts`): Blocks `suspended` and `archived` with 403. Sets `req.orgPastDue = true` for `past_due`.
- **WriteAccessGuard** (`write-access.guard.ts`): Blocks POST/PUT/PATCH/DELETE for `past_due` orgs with message: "Your subscription is past due. Please update your payment method."
---
## 11. Authentication & Sessions
### Token Architecture
| Token | Type | Lifetime | Storage |
|-------|------|----------|---------|
| Access token | JWT | 1 hour | Frontend Zustand store |
| Refresh token | Opaque (64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) |
| MFA challenge | JWT | 5 minutes | Frontend state |
| Invite/activation | JWT | 72 hours | URL query parameter |
### Session Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/login` | No | Email + password login |
| `POST` | `/api/auth/register` | No | Create account |
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
| `POST` | `/api/auth/logout` | Cookie | Revoke current session |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
---
## 12. Multi-Factor Authentication (TOTP)
### MFA Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR code + secret |
| `POST` | `/api/auth/mfa/enable` | JWT | Enable MFA with TOTP code |
| `POST` | `/api/auth/mfa/verify` | mfaToken | Verify during login |
| `POST` | `/api/auth/mfa/disable` | JWT | Disable (requires password) |
| `GET` | `/api/auth/mfa/status` | JWT | Check MFA status |
---
## 13. Single Sign-On (SSO)
| Provider | Env Vars Required |
|----------|-------------------|
| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` |
| Microsoft/Azure AD | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_CALLBACK_URL` |
SSO providers are conditionally loaded based on env vars.
---
## 14. Passkeys (WebAuthn)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/passkeys/register-options` | JWT | Get registration options |
| `POST` | `/api/auth/passkeys/register` | JWT | Complete registration |
| `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options |
| `POST` | `/api/auth/passkeys/login` | No | Authenticate with passkey |
| `GET` | `/api/auth/passkeys` | JWT | List user's passkeys |
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
---
## 15. Environment Variables Reference
### Stripe (Required for billing)
| Variable | Description |
|----------|-------------|
| `STRIPE_SECRET_KEY` | Stripe secret key. Must NOT contain "placeholder" to activate. |
| `STRIPE_WEBHOOK_SECRET` | Webhook endpoint signing secret |
| `STRIPE_STARTER_MONTHLY_PRICE_ID` | Stripe Price ID for Starter monthly |
| `STRIPE_STARTER_ANNUAL_PRICE_ID` | Stripe Price ID for Starter annual |
| `STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID` | Stripe Price ID for Professional monthly |
| `STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID` | Stripe Price ID for Professional annual |
| `STRIPE_ENTERPRISE_MONTHLY_PRICE_ID` | Stripe Price ID for Enterprise monthly |
| `STRIPE_ENTERPRISE_ANNUAL_PRICE_ID` | Stripe Price ID for Enterprise annual |
Legacy single-price vars (`STRIPE_STARTER_PRICE_ID`, etc.) are still supported as fallback for monthly prices.
### Trial Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `REQUIRE_PAYMENT_METHOD_FOR_TRIAL` | `false` | Set to `true` to require card upfront via Stripe Checkout |
### SSO (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_CALLBACK_URL` | OAuth redirect URI |
| `AZURE_CLIENT_ID` | Azure AD application (client) ID |
| `AZURE_CLIENT_SECRET` | Azure AD client secret |
| `AZURE_TENANT_ID` | Azure AD tenant ID |
| `AZURE_CALLBACK_URL` | OAuth redirect URI |
### WebAuthn / Passkeys
| Variable | Default | Description |
|----------|---------|-------------|
| `WEBAUTHN_RP_ID` | `localhost` | Relying party identifier |
| `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin |
### Other
| Variable | Default | Description |
|----------|---------|-------------|
| `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for invite/activation JWTs |
| `APP_URL` | `http://localhost` | Base URL for generated links |
| `RESEND_API_KEY` | -- | Resend email provider API key |
---
## 16. Manual Intervention & Ops Tasks
### Stripe Dashboard Setup
1. **Create Products and Prices** for each plan:
- Starter: monthly ($29/mo recurring) + annual ($261/yr recurring)
- Professional: monthly ($79/mo recurring) + annual ($711/yr recurring)
- Enterprise: monthly + annual (custom pricing)
- Copy all Price IDs to env vars
2. **Configure Stripe Webhook** endpoint:
- URL: `https://yourdomain.com/api/webhooks/stripe`
- Events: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`, `customer.subscription.trial_will_end`, `customer.subscription.updated`
3. **Configure Stripe Customer Portal**:
- Enable plan switching (allow switching between monthly and annual prices)
- Enable payment method updates
- Enable cancellation
- Enable invoice history
4. **Set production secrets**: `INVITE_TOKEN_SECRET`, `JWT_SECRET`, `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_ORIGIN`
5. **Configure SSO providers** (optional)
### Ongoing Ops
- **Refresh token cleanup**: Schedule `RefreshTokenService.cleanupExpired()` periodically
- **Monitor `shared.email_log`**: Check for failed email deliveries
- **ACH/Invoice customers**: Admin sets up via `PUT /api/admin/organizations/:id/billing`
### Finding activation URLs (dev/testing)
```sql
SELECT to_email, metadata->>'activationUrl' AS url, sent_at
FROM shared.email_log
WHERE template = 'activation'
ORDER BY sent_at DESC
LIMIT 10;
```
---
## 17. What's Stubbed vs. Production-Ready
| Component | Status | Notes |
|-----------|--------|-------|
| Stripe Checkout (card-required flow) | **Ready** (test mode) | Switch to live keys for production |
| Stripe Trial (no-card flow) | **Ready** (test mode) | Creates customer + subscription server-side |
| Stripe Webhooks | **Ready** | All 6 events handled with idempotency |
| Stripe Customer Portal | **Ready** | Full org-context customer ID lookup implemented |
| Monthly/Annual Pricing | **Ready** | Toggle on pricing page, 6 Stripe Price IDs |
| ACH/Invoice Billing | **Ready** | Admin endpoint switches collection method |
| Provisioning | **Ready** | Inline, supports both trial and active status |
| Email service | **Ready** (with Resend) | Falls back to stub logging if not configured |
| Trial emails | **Ready** | Trial-ending and trial-expired templates |
| Access control (past_due) | **Ready** | Read-only grace period for failed payments |
| Activation (magic link) | **Ready** | Full end-to-end flow |
| Onboarding checklist | **Ready** | Server-side progress tracking |
| Refresh tokens | **Ready** | Needs scheduled cleanup |
| TOTP MFA | **Ready** | Full setup, enable, verify, recovery |
| SSO (Google/Azure) | **Ready** (needs keys) | Conditional loading |
| Passkeys (WebAuthn) | **Ready** | Registration, authentication, removal |
---
## 18. API Endpoint Reference
### Billing
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/billing/start-trial` | No | Start 14-day no-card trial |
| `POST` | `/api/billing/create-checkout-session` | No | Create Stripe Checkout (card-required flow) |
| `POST` | `/api/webhooks/stripe` | Stripe sig | Webhook receiver |
| `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status |
| `GET` | `/api/billing/subscription` | JWT | Get current subscription info |
| `POST` | `/api/billing/portal` | JWT | Create Stripe Customer Portal session |
| `PUT` | `/api/admin/organizations/:id/billing` | JWT (superadmin) | Switch billing method (card/invoice) |
### Auth
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/register` | No | Register new user |
| `POST` | `/api/auth/login` | No | Login (may return MFA challenge) |
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
| `POST` | `/api/auth/logout` | Cookie | Logout current session |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
| `GET` | `/api/auth/activate?token=` | No | Validate activation token |
| `POST` | `/api/auth/activate` | No | Set password + activate |
| `POST` | `/api/auth/resend-activation` | No | Resend activation email |
| `GET` | `/api/auth/profile` | JWT | Get user profile |
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
### Onboarding
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress |
| `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete |
---
## Database Tables & Columns
### Tables Added (Migration 015)
| Table | Purpose |
|-------|---------|
| `shared.refresh_tokens` | Hashed refresh tokens with expiry/revocation |
| `shared.stripe_events` | Idempotency ledger for Stripe webhooks |
| `shared.invite_tokens` | Activation/invite magic links |
| `shared.onboarding_progress` | Per-org onboarding step completion |
| `shared.user_passkeys` | WebAuthn credentials |
| `shared.email_log` | Email audit trail |
### Columns Added to `shared.organizations`
| Column | Type | Migration | Description |
|--------|------|-----------|-------------|
| `stripe_customer_id` | VARCHAR(255) UNIQUE | 015 | Stripe customer ID |
| `stripe_subscription_id` | VARCHAR(255) UNIQUE | 015 | Stripe subscription ID |
| `trial_ends_at` | TIMESTAMPTZ | 015 | Trial expiration date |
| `billing_interval` | VARCHAR(20) | 017 | `month` or `year` |
| `collection_method` | VARCHAR(20) | 017 | `charge_automatically` or `send_invoice` |
### Organization Status Values
`active`, `trial`, `past_due`, `suspended`, `archived`

22
PARKING-LOT.md Normal file
View File

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

1212
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.7-beta",
"version": "2026.3.19",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {
@@ -25,19 +25,30 @@
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"@simplewebauthn/server": "^13.3.0",
"bcryptjs": "^3.0.3",
"bullmq": "^5.71.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"ioredis": "^5.4.2",
"newrelic": "latest",
"otplib": "^13.3.0",
"passport": "^0.7.0",
"passport-azure-ad": "^4.3.5",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.13.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"resend": "^6.9.4",
"rxjs": "^7.8.1",
"stripe": "^20.4.1",
"typeorm": "^0.3.20",
"newrelic": "latest",
"uuid": "^9.0.1"
},
"devDependencies": {
@@ -45,12 +56,15 @@
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^20.17.12",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.8",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",

View File

@@ -1,11 +1,13 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module';
@@ -27,6 +29,10 @@ import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.
import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
import { 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';
@Module({
@@ -52,6 +58,10 @@ import { ScheduleModule } from '@nestjs/schedule';
},
}),
}),
ThrottlerModule.forRoot([{
ttl: 60000, // 1-minute window
limit: 100, // 100 requests per minute (global default)
}]),
DatabaseModule,
AuthModule,
OrganizationsModule,
@@ -74,6 +84,10 @@ import { ScheduleModule } from '@nestjs/schedule';
AttachmentsModule,
InvestmentPlanningModule,
HealthScoresModule,
BoardPlanningModule,
BillingModule,
EmailModule,
OnboardingModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],
@@ -82,6 +96,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,
},
],
})
export class AppModule implements NestModule {

View File

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

View File

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

View File

@@ -366,6 +366,99 @@ export class TenantSchemaService {
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
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
@@ -378,6 +471,12 @@ export class TenantSchemaService {
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
`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)`,
];
}

View File

@@ -9,12 +9,13 @@ export interface TenantRequest extends Request {
orgId?: string;
userId?: string;
userRole?: string;
orgPastDue?: boolean;
}
@Injectable()
export class TenantMiddleware implements NestMiddleware {
// In-memory cache for org status to avoid DB hit per request
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
// In-memory cache for org info to avoid DB hit per request
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
private static readonly CACHE_TTL = 60_000; // 60 seconds
constructor(
@@ -30,23 +31,29 @@ export class TenantMiddleware implements NestMiddleware {
const token = authHeader.substring(7);
const secret = this.configService.get<string>('JWT_SECRET');
const decoded = jwt.verify(token, secret!) as any;
if (decoded?.orgSchema) {
// Check if the org is still active (catches post-JWT suspension)
if (decoded.orgId) {
const status = await this.getOrgStatus(decoded.orgId);
if (status && ['suspended', 'archived'].includes(status)) {
if (decoded?.orgId) {
// Look up org info (status + schema) from orgId with caching
const orgInfo = await this.getOrgInfo(decoded.orgId);
if (orgInfo) {
if (['suspended', 'archived'].includes(orgInfo.status)) {
res.status(403).json({
statusCode: 403,
message: `This organization has been ${status}. Please contact your administrator.`,
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
});
return;
}
// past_due: allow through with read-only flag (WriteAccessGuard enforces)
if (orgInfo.status === 'past_due') {
req.orgPastDue = true;
}
req.tenantSchema = orgInfo.schemaName;
}
req.tenantSchema = decoded.orgSchema;
req.orgId = decoded.orgId;
req.userId = decoded.sub;
req.userRole = decoded.role;
} else if (decoded?.sub) {
// Superadmin or user without org — still set userId
req.userId = decoded.sub;
}
} catch {
// Token invalid or expired - let Passport handle the auth error
@@ -55,19 +62,23 @@ export class TenantMiddleware implements NestMiddleware {
next();
}
private async getOrgStatus(orgId: string): Promise<string | null> {
const cached = this.orgStatusCache.get(orgId);
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
const cached = this.orgCache.get(orgId);
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
return cached.status;
return { status: cached.status, schemaName: cached.schemaName };
}
try {
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],
);
if (result.length > 0) {
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
return result[0].status;
this.orgCache.set(orgId, {
status: result[0].status,
schemaName: result[0].schemaName,
cachedAt: Date.now(),
});
return { status: result[0].status, schemaName: result[0].schemaName };
}
} catch {
// Non-critical — don't block requests on cache miss errors

View File

@@ -3,6 +3,8 @@ import * as os from 'node:os';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
@@ -37,10 +39,33 @@ if (WORKERS > 1 && cluster.isPrimary) {
async function bootstrap() {
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');
// 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) => {
@@ -63,15 +88,17 @@ async function bootstrap() {
credentials: true,
});
// Swagger docs — available in all environments
// Swagger docs — disabled in production to avoid exposing API surface
if (!isProduction) {
const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.7')
.setVersion('2026.3.11')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
await app.listen(3000);
console.log(`Backend worker ${process.pid} listening on port 3000`);

View File

@@ -74,9 +74,9 @@ export class AccountsService {
// Create opening balance journal entry if initialBalance is provided and non-zero
if (dto.initialBalance && dto.initialBalance !== 0) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date();
const year = balanceDate.getFullYear();
const month = balanceDate.getMonth() + 1;
// Find the current fiscal period
const periods = await this.tenant.query(
@@ -111,12 +111,14 @@ export class AccountsService {
);
}
// Create the journal entry
// Create the journal entry (use provided balance date or today)
const entryDate = dto.initialBalanceDate || new Date().toISOString().split('T')[0];
const jeInsert = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
VALUES ($1::date, $2, 'opening_balance', $3, true, NOW(), $4)
RETURNING id`,
[
entryDate,
`Opening balance for ${dto.name}`,
fiscalPeriodId,
'00000000-0000-0000-0000-000000000000',

View File

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

View File

@@ -6,9 +6,16 @@ import {
UseGuards,
Request,
Get,
Res,
Query,
HttpCode,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.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 { 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')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
@ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
if (!isOpenRegistration) {
throw new ForbiddenException(
'Open registration is disabled. Please use an invitation link to create your account.',
);
}
const result = await this.authService.register(dto);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken, ...response } = result;
return response;
}
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
@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 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')
@@ -59,9 +145,99 @@ export class AuthController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@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 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.' };
}
}

View File

@@ -4,8 +4,15 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.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 { 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 { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
@@ -21,12 +28,27 @@ import { OrganizationsModule } from '../organizations/organizations.module';
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '24h' },
signOptions: { expiresIn: '1h' },
}),
}),
],
controllers: [AuthController, AdminController],
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
exports: [AuthService],
controllers: [
AuthController,
AdminController,
MfaController,
SsoController,
PasskeyController,
],
providers: [
AuthService,
AdminAnalyticsService,
RefreshTokenService,
MfaService,
SsoService,
PasskeyService,
JwtStrategy,
LocalStrategy,
],
exports: [AuthService, RefreshTokenService, JwtModule],
})
export class AuthModule {}

View File

@@ -4,21 +4,37 @@ import {
ConflictException,
ForbiddenException,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { randomBytes, createHash } from 'crypto';
import { UsersService } from '../users/users.service';
import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly inviteSecret: string;
private readonly appUrl: string;
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private configService: ConfigService,
private dataSource: DataSource,
) {}
private refreshTokenService: RefreshTokenService,
private emailService: EmailService,
) {
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
}
async register(dto: RegisterDto) {
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)
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);
}
/**
* 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) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
@@ -85,6 +119,7 @@ export class AuthService {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
mfaEnabled: user.mfaEnabled || false,
organizations: user.userOrganizations?.map((uo) => ({
id: uo.organization.id,
name: uo.organization.name,
@@ -118,15 +153,18 @@ export class AuthService {
sub: user.id,
email: user.email,
orgId: membership.organizationId,
orgSchema: membership.organization.schemaName,
role: membership.role,
};
// Record org switch in login history
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
// Generate new refresh token for org switch
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
organization: {
id: membership.organization.id,
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> {
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(
userId: string,
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 || [];
// Filter out suspended/archived organizations
const orgs = allOrgs.filter(
@@ -177,12 +449,15 @@ export class AuthService {
if (defaultOrg) {
payload.orgId = defaultOrg.organizationId;
payload.orgSchema = defaultOrg.organization?.schemaName;
payload.role = defaultOrg.role;
}
// Create refresh token
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
user: {
id: user.id,
email: user.email,
@@ -191,11 +466,11 @@ export class AuthService {
isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
mfaEnabled: user.mfaEnabled || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
schemaName: uo.organization?.schemaName,
status: uo.organization?.status,
role: uo.role,
})),

View 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);
}
}

View 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,
};
}
}

View 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 };
}
}

View 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],
);
}
}

View 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');
}
}

View 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 };
}
}

View 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),
};
}
}

View File

@@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
sub: payload.sub,
email: payload.email,
orgId: payload.orgId,
orgSchema: payload.orgSchema,
role: payload.role,
isSuperadmin: payload.isSuperadmin || false,
impersonatedBy: payload.impersonatedBy || null,

View File

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

View 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 {}

View File

@@ -0,0 +1,678 @@
import { Injectable, Logger, BadRequestException, RawBodyRequest } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import Stripe from 'stripe';
import { v4 as uuid } from 'uuid';
import * as bcrypt from 'bcryptjs';
import { TenantSchemaService } from '../../database/tenant-schema.service';
import { AuthService } from '../auth/auth.service';
import { EmailService } from '../email/email.service';
const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
starter: { name: 'Starter', unitLimit: 50 },
professional: { name: 'Professional', unitLimit: 200 },
enterprise: { name: 'Enterprise', unitLimit: 999999 },
};
type BillingInterval = 'month' | 'year';
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
private stripe: Stripe | null = null;
private webhookSecret: string;
private priceMap: Record<string, { monthly: string; annual: string }>;
private requirePaymentForTrial: boolean;
constructor(
private configService: ConfigService,
private dataSource: DataSource,
private tenantSchemaService: TenantSchemaService,
private authService: AuthService,
private emailService: EmailService,
) {
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
if (secretKey && !secretKey.includes('placeholder')) {
this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' as any });
this.logger.log('Stripe initialized');
} else {
this.logger.warn('Stripe not configured — billing endpoints will return stubs');
}
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
this.requirePaymentForTrial =
this.configService.get<string>('REQUIRE_PAYMENT_METHOD_FOR_TRIAL') === 'true';
// Build price map with backward-compat: new monthly vars fall back to old single vars
this.priceMap = {
starter: {
monthly: this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID')
|| this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
annual: this.configService.get<string>('STRIPE_STARTER_ANNUAL_PRICE_ID') || '',
},
professional: {
monthly: this.configService.get<string>('STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID')
|| this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
annual: this.configService.get<string>('STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID') || '',
},
enterprise: {
monthly: this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID')
|| this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
annual: this.configService.get<string>('STRIPE_ENTERPRISE_ANNUAL_PRICE_ID') || '',
},
};
}
// ─── Price Resolution ────────────────────────────────────────
private getPriceId(planId: string, interval: BillingInterval): string {
const plan = this.priceMap[planId];
if (!plan) throw new BadRequestException(`Invalid plan: ${planId}`);
const priceId = interval === 'year' ? plan.annual : plan.monthly;
if (!priceId || priceId.includes('placeholder')) {
throw new BadRequestException(`Price not configured for ${planId} (${interval})`);
}
return priceId;
}
// ─── Trial Signup (No Card Required) ────────────────────────
/**
* Start a free trial without collecting payment.
* Creates a Stripe customer + subscription with trial_period_days,
* then provisions the organization immediately.
*/
async startTrial(
planId: string,
billingInterval: BillingInterval,
email: string,
businessName: string,
): Promise<{ success: boolean; subscriptionId: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
if (!email) throw new BadRequestException('Email is required');
if (!businessName) throw new BadRequestException('Business name is required');
const priceId = this.getPriceId(planId, billingInterval);
// 1. Create Stripe customer
const customer = await this.stripe.customers.create({
email,
metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval },
});
// 2. Create subscription with 14-day trial (no payment method)
const subscription = await this.stripe.subscriptions.create({
customer: customer.id,
items: [{ price: priceId }],
trial_period_days: 14,
payment_settings: {
save_default_payment_method: 'on_subscription',
},
trial_settings: {
end_behavior: { missing_payment_method: 'cancel' },
},
metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval },
});
const trialEnd = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
// 3. Provision organization immediately with trial status
await this.provisionOrganization(
customer.id,
subscription.id,
email,
planId,
businessName,
'trial',
billingInterval,
trialEnd,
);
this.logger.log(`Trial started for ${email}, plan=${planId}, interval=${billingInterval}`);
return { success: true, subscriptionId: subscription.id };
}
// ─── Checkout Session (Card-required flow / post-trial) ─────
/**
* Create a Stripe Checkout Session for a new subscription.
* Used when REQUIRE_PAYMENT_METHOD_FOR_TRIAL=true, or for
* post-trial conversion where the user adds a payment method.
*/
async createCheckoutSession(
planId: string,
billingInterval: BillingInterval = 'month',
email?: string,
businessName?: string,
): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const priceId = this.getPriceId(planId, billingInterval);
const sessionConfig: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${this.getAppUrl()}/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${this.getAppUrl()}/pricing`,
customer_email: email || undefined,
metadata: {
plan_id: planId,
business_name: businessName || '',
billing_interval: billingInterval,
},
};
// If trial is card-required, add trial period to checkout
if (this.requirePaymentForTrial) {
sessionConfig.subscription_data = {
trial_period_days: 14,
metadata: {
plan_id: planId,
business_name: businessName || '',
billing_interval: billingInterval,
},
};
}
const session = await this.stripe.checkout.sessions.create(sessionConfig);
return { url: session.url! };
}
// ─── Webhook Handling ───────────────────────────────────────
/**
* Handle a Stripe webhook event.
*/
async handleWebhook(rawBody: Buffer, signature: string): Promise<void> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
} catch (err: any) {
this.logger.error(`Webhook signature verification failed: ${err.message}`);
throw new BadRequestException('Invalid webhook signature');
}
// Idempotency check
const existing = await this.dataSource.query(
`SELECT id FROM shared.stripe_events WHERE id = $1`,
[event.id],
);
if (existing.length > 0) {
this.logger.log(`Duplicate Stripe event ${event.id}, skipping`);
return;
}
// Record event
await this.dataSource.query(
`INSERT INTO shared.stripe_events (id, type, payload) VALUES ($1, $2, $3)`,
[event.id, event.type, JSON.stringify(event.data)],
);
// Dispatch
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'invoice.payment_succeeded':
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.trial_will_end':
await this.handleTrialWillEnd(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
default:
this.logger.log(`Unhandled Stripe event: ${event.type}`);
}
}
// ─── Provisioning Status ────────────────────────────────────
/**
* Get provisioning status for a checkout session OR subscription ID.
*/
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
if (!this.stripe) return { status: 'not_configured' };
// Try as checkout session first
let customerId: string | null = null;
try {
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
customerId = session.customer as string;
} catch {
// Not a checkout session — try looking up by subscription ID
try {
const subscription = await this.stripe.subscriptions.retrieve(sessionId);
customerId = subscription.customer as string;
} catch {
return { status: 'pending' };
}
}
if (!customerId) return { status: 'pending' };
const rows = await this.dataSource.query(
`SELECT id, status FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
if (rows.length === 0) return { status: 'provisioning' };
if (['active', 'trial'].includes(rows[0].status)) return { status: 'active' };
return { status: 'provisioning' };
}
// ─── Stripe Customer Portal ─────────────────────────────────
/**
* Create a Stripe Customer Portal session for managing subscription.
*/
async createPortalSession(orgId: string): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe is not configured');
const rows = await this.dataSource.query(
`SELECT stripe_customer_id, stripe_subscription_id, status
FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (rows.length === 0) {
throw new BadRequestException('Organization not found');
}
let customerId = rows[0].stripe_customer_id;
// Fallback: if customer ID is missing but subscription exists, retrieve customer from subscription
if (!customerId && rows[0].stripe_subscription_id) {
try {
const sub = await this.stripe.subscriptions.retrieve(rows[0].stripe_subscription_id) as Stripe.Subscription;
customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
if (customerId) {
// Backfill the customer ID for future calls
await this.dataSource.query(
`UPDATE shared.organizations SET stripe_customer_id = $1 WHERE id = $2`,
[customerId, orgId],
);
this.logger.log(`Backfilled stripe_customer_id=${customerId} for org=${orgId}`);
}
} catch (err) {
this.logger.warn(`Failed to retrieve customer from subscription: ${(err as Error).message}`);
}
}
if (!customerId) {
const status = rows[0].status;
if (status === 'trial') {
throw new BadRequestException(
'Billing portal is not available during your free trial. Add a payment method when your trial ends to manage your subscription.',
);
}
throw new BadRequestException('No Stripe customer found for this organization. Please contact support.');
}
const session = await this.stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${this.getAppUrl()}/settings`,
});
return { url: session.url };
}
// ─── Subscription Info ──────────────────────────────────────
/**
* Get current subscription details for the Settings billing tab.
*/
async getSubscriptionInfo(orgId: string): Promise<{
plan: string;
planName: string;
billingInterval: string;
status: string;
collectionMethod: string;
trialEndsAt: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeCustomer: boolean;
}> {
const rows = await this.dataSource.query(
`SELECT plan_level, billing_interval, status, collection_method,
trial_ends_at, stripe_subscription_id, stripe_customer_id
FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (rows.length === 0) throw new BadRequestException('Organization not found');
const org = rows[0];
let currentPeriodEnd: string | null = null;
let cancelAtPeriodEnd = false;
// Fetch live data from Stripe if available
if (this.stripe && org.stripe_subscription_id) {
try {
const sub = await this.stripe.subscriptions.retrieve(org.stripe_subscription_id, {
expand: ['items.data'],
}) as Stripe.Subscription;
// current_period_end is on the subscription item in newer Stripe API versions
const firstItem = sub.items?.data?.[0];
if (firstItem?.current_period_end) {
currentPeriodEnd = new Date(firstItem.current_period_end * 1000).toISOString();
}
cancelAtPeriodEnd = sub.cancel_at_period_end;
} catch {
// Non-critical — use DB data only
}
}
return {
plan: org.plan_level || 'starter',
planName: PLAN_FEATURES[org.plan_level]?.name || org.plan_level || 'Starter',
billingInterval: org.billing_interval || 'month',
status: org.status || 'active',
collectionMethod: org.collection_method || 'charge_automatically',
trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null,
currentPeriodEnd,
cancelAtPeriodEnd,
hasStripeCustomer: !!org.stripe_customer_id,
};
}
// ─── Invoice / ACH Billing (Admin) ──────────────────────────
/**
* Switch a customer's subscription to invoice collection (ACH/wire).
* Admin-only operation for enterprise customers.
*/
async switchToInvoiceBilling(
orgId: string,
collectionMethod: 'charge_automatically' | 'send_invoice',
daysUntilDue: number = 30,
): Promise<void> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const rows = await this.dataSource.query(
`SELECT stripe_subscription_id, stripe_customer_id FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (rows.length === 0 || !rows[0].stripe_subscription_id) {
throw new BadRequestException('No Stripe subscription found for this organization');
}
const updateParams: Stripe.SubscriptionUpdateParams = {
collection_method: collectionMethod,
};
if (collectionMethod === 'send_invoice') {
updateParams.days_until_due = daysUntilDue;
}
await this.stripe.subscriptions.update(rows[0].stripe_subscription_id, updateParams);
// Update DB
await this.dataSource.query(
`UPDATE shared.organizations SET collection_method = $1, updated_at = NOW() WHERE id = $2`,
[collectionMethod, orgId],
);
this.logger.log(`Billing method updated for org ${orgId}: ${collectionMethod}`);
}
// ─── Webhook Handlers ──────────────────────────────────────
private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const email = session.customer_email || session.customer_details?.email || '';
const planId = session.metadata?.plan_id || 'starter';
const businessName = session.metadata?.business_name || 'My HOA';
const billingInterval = (session.metadata?.billing_interval || 'month') as BillingInterval;
this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
try {
// Determine if this is a trial checkout (card required for trial)
let status: 'active' | 'trial' = 'active';
let trialEnd: Date | undefined;
if (this.stripe && subscriptionId) {
const sub = await this.stripe.subscriptions.retrieve(subscriptionId);
if (sub.status === 'trialing' && sub.trial_end) {
status = 'trial';
trialEnd = new Date(sub.trial_end * 1000);
}
}
await this.provisionOrganization(
customerId, subscriptionId, email, planId, businessName,
status, billingInterval, trialEnd,
);
} catch (err: any) {
this.logger.error(`Provisioning failed: ${err.message}`, err.stack);
}
}
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
// Activate tenant if it was pending/trial
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'active', updated_at = NOW()
WHERE stripe_customer_id = $1 AND status IN ('trial', 'past_due')`,
[customerId],
);
}
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
const rows = await this.dataSource.query(
`SELECT email, name FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
// Set org to past_due for grace period (read-only access)
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'past_due', updated_at = NOW()
WHERE stripe_customer_id = $1 AND status = 'active'`,
[customerId],
);
if (rows.length > 0 && rows[0].email) {
await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization');
}
this.logger.warn(`Payment failed for customer ${customerId}`);
}
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'archived', updated_at = NOW()
WHERE stripe_customer_id = $1`,
[customerId],
);
this.logger.log(`Subscription cancelled for customer ${customerId}`);
}
private async handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
const rows = await this.dataSource.query(
`SELECT id, email, name FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
if (rows.length === 0) return;
const org = rows[0];
const daysRemaining = 3; // This webhook fires 3 days before trial end
const settingsUrl = `${this.getAppUrl()}/settings`;
if (org.email) {
await this.emailService.sendTrialEndingEmail(
org.email,
org.name || 'Your organization',
daysRemaining,
settingsUrl,
);
}
this.logger.log(`Trial ending soon for customer ${customerId}, org ${org.id}`);
}
private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
// Determine new status
let newStatus: string;
switch (subscription.status) {
case 'trialing':
newStatus = 'trial';
break;
case 'active':
newStatus = 'active';
break;
case 'past_due':
newStatus = 'past_due';
break;
case 'canceled':
case 'unpaid':
newStatus = 'archived';
break;
default:
return; // Don't update for other statuses
}
// Determine billing interval from the subscription items
let billingInterval: BillingInterval = 'month';
if (subscription.items?.data?.[0]?.price?.recurring?.interval === 'year') {
billingInterval = 'year';
}
// Determine plan from price metadata or existing mapping
let planId: string | null = null;
const activePriceId = subscription.items?.data?.[0]?.price?.id;
if (activePriceId) {
for (const [plan, prices] of Object.entries(this.priceMap)) {
if (prices.monthly === activePriceId || prices.annual === activePriceId) {
planId = plan;
break;
}
}
}
// Build update query dynamically
const updates: string[] = [`status = '${newStatus}'`, `billing_interval = '${billingInterval}'`, `updated_at = NOW()`];
if (planId) {
updates.push(`plan_level = '${planId}'`);
}
if (subscription.collection_method) {
updates.push(`collection_method = '${subscription.collection_method}'`);
}
await this.dataSource.query(
`UPDATE shared.organizations SET ${updates.join(', ')} WHERE stripe_customer_id = $1`,
[customerId],
);
this.logger.log(`Subscription updated for customer ${customerId}: status=${newStatus}, interval=${billingInterval}`);
}
// ─── Provisioning ──────────────────────────────────────────
/**
* Full provisioning flow: create org, schema, user, invite token, email.
*/
async provisionOrganization(
customerId: string,
subscriptionId: string,
email: string,
planId: string,
businessName: string,
status: 'active' | 'trial' = 'active',
billingInterval: BillingInterval = 'month',
trialEndsAt?: Date,
): Promise<void> {
// 1. Create or upsert organization
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
const orgRows = await this.dataSource.query(
`INSERT INTO shared.organizations
(name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email, billing_interval, trial_ends_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (stripe_customer_id) DO UPDATE SET
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
plan_level = EXCLUDED.plan_level,
status = EXCLUDED.status,
billing_interval = EXCLUDED.billing_interval,
trial_ends_at = EXCLUDED.trial_ends_at,
updated_at = NOW()
RETURNING id, schema_name`,
[businessName, schemaName, status, planId, customerId, subscriptionId, email, billingInterval, trialEndsAt || null],
);
const orgId = orgRows[0].id;
const actualSchema = orgRows[0].schema_name;
// 2. Create tenant schema
try {
await this.tenantSchemaService.createTenantSchema(actualSchema);
this.logger.log(`Created tenant schema: ${actualSchema}`);
} catch (err: any) {
if (err.message?.includes('already exists')) {
this.logger.log(`Schema ${actualSchema} already exists, skipping creation`);
} else {
throw err;
}
}
// 3. Create or find user
let userRows = await this.dataSource.query(
`SELECT id FROM shared.users WHERE email = $1`,
[email],
);
let userId: string;
if (userRows.length === 0) {
const newUser = await this.dataSource.query(
`INSERT INTO shared.users (email, is_email_verified)
VALUES ($1, false)
RETURNING id`,
[email],
);
userId = newUser[0].id;
} else {
userId = userRows[0].id;
}
// 4. Create membership (president role)
await this.dataSource.query(
`INSERT INTO shared.user_organizations (user_id, organization_id, role)
VALUES ($1, $2, 'president')
ON CONFLICT (user_id, organization_id) DO NOTHING`,
[userId, orgId],
);
// 5. Generate invite token and "send" activation email
const inviteToken = await this.authService.generateInviteToken(userId, orgId, email);
const activationUrl = `${this.getAppUrl()}/activate?token=${inviteToken}`;
await this.emailService.sendActivationEmail(email, businessName, activationUrl);
// 6. Initialize onboarding progress
await this.dataSource.query(
`INSERT INTO shared.onboarding_progress (organization_id) VALUES ($1) ON CONFLICT DO NOTHING`,
[orgId],
);
this.logger.log(`Provisioning complete for org=${orgId}, user=${userId}, status=${status}`);
}
private getAppUrl(): string {
return this.configService.get<string>('APP_URL') || 'http://localhost';
}
}

View File

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

View 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));
}
}

View 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 {}

View 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],
);
}
}

View 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 };
}
}

View 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 {}

View File

@@ -0,0 +1,348 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { Resend } from 'resend';
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private resend: Resend | null = null;
private fromAddress: string;
private replyToAddress: string;
constructor(
private configService: ConfigService,
private dataSource: DataSource,
) {
const apiKey = this.configService.get<string>('RESEND_API_KEY');
if (apiKey && !apiKey.includes('placeholder')) {
this.resend = new Resend(apiKey);
this.logger.log('Resend email service initialized');
} else {
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
}
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
}
// ─── Public API ──────────────────────────────────────────────
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: 'Your HOA LedgerIQ account is ready to activate.',
heading: 'Welcome to HOA LedgerIQ!',
body: `
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
<p>Click the button below to set your password and activate your account:</p>
`,
ctaText: 'Activate My Account',
ctaUrl: activationUrl,
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
});
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
}
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
const html = this.buildTemplate({
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
heading: `You're all set!`,
body: `
<p>Your account for <strong>${this.esc(businessName)}</strong> is now active.</p>
<p>Log in to start managing your HOA's finances, assessments, and investments — all in one place.</p>
`,
ctaText: 'Go to Dashboard',
ctaUrl: `${appUrl}/dashboard`,
footer: 'If you have any questions, just reply to this email and we\'ll help you get started.',
});
await this.send(email, subject, html, 'welcome', { businessName });
}
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
const subject = `Action required: Payment failed for ${businessName}`;
const html = this.buildTemplate({
preheader: 'We were unable to process your payment.',
heading: 'Payment Failed',
body: `
<p>We were unable to process the latest payment for <strong>${this.esc(businessName)}</strong>.</p>
<p>Please update your payment method to avoid any interruption to your service.</p>
`,
ctaText: 'Update Payment Method',
ctaUrl: `${this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`,
footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.',
});
await this.send(email, subject, html, 'payment_failed', { businessName });
}
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: `Join ${orgName} on HOA LedgerIQ.`,
heading: 'You\'re Invited!',
body: `
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
<p>Click below to accept the invitation and set up your account:</p>
`,
ctaText: 'Accept Invitation',
ctaUrl: inviteUrl,
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
});
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
}
async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise<void> {
const subject = `Your free trial ends in ${daysRemaining} days — ${businessName}`;
const html = this.buildTemplate({
preheader: `Your HOA LedgerIQ trial for ${businessName} is ending soon.`,
heading: `Your Trial Ends in ${daysRemaining} Days`,
body: `
<p>Your free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ ends in <strong>${daysRemaining} days</strong>.</p>
<p>To continue using all features without interruption, add a payment method before your trial expires.</p>
<p>If you don't add a payment method, your account will become read-only and you won't be able to make changes to your data.</p>
`,
ctaText: 'Add Payment Method',
ctaUrl: settingsUrl,
footer: 'If you have any questions about plans or pricing, just reply to this email.',
});
await this.send(email, subject, html, 'trial_ending', { businessName, daysRemaining, settingsUrl });
}
async sendTrialExpiredEmail(email: string, businessName: string): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Your free trial has ended — ${businessName}`;
const html = this.buildTemplate({
preheader: `Your HOA LedgerIQ trial for ${businessName} has ended.`,
heading: 'Your Trial Has Ended',
body: `
<p>The free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ has ended.</p>
<p>Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.</p>
`,
ctaText: 'Choose a Plan',
ctaUrl: `${appUrl}/pricing`,
footer: 'Your data will be preserved. You can reactivate your account at any time by subscribing to a plan.',
});
await this.send(email, subject, html, 'trial_expired', { businessName });
}
async sendNewMemberWelcomeEmail(
email: string,
firstName: string,
orgName: string,
): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Welcome to ${orgName} on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: `Your account for ${orgName} on HOA LedgerIQ is ready.`,
heading: `Welcome, ${this.esc(firstName)}!`,
body: `
<p>You've been added as a member of <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
<p>Your account is ready to use. Log in with your email address and the temporary password provided by your administrator. You'll be able to change your password after logging in.</p>
<p>HOA LedgerIQ gives you access to your community's financial dashboard, budgets, reports, and more.</p>
`,
ctaText: 'Log In Now',
ctaUrl: `${appUrl}/login`,
footer: 'If you were not expecting this email, please contact your HOA administrator.',
});
await this.send(email, subject, html, 'new_member_welcome', { orgName, firstName });
}
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password';
const html = this.buildTemplate({
preheader: 'Password reset requested for your HOA LedgerIQ account.',
heading: 'Password Reset',
body: `
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
`,
ctaText: 'Reset Password',
ctaUrl: resetUrl,
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
});
await this.send(email, subject, html, 'password_reset', { resetUrl });
}
// ─── Core send logic ────────────────────────────────────────
private async send(
toEmail: string,
subject: string,
html: string,
template: string,
metadata: Record<string, any>,
): Promise<void> {
// Always log to the database
await this.log(toEmail, subject, html, template, metadata);
if (!this.resend) {
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
return;
}
try {
const result = await this.resend.emails.send({
from: this.fromAddress,
to: [toEmail],
replyTo: this.replyToAddress || undefined,
subject,
html,
});
if (result.error) {
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
} else {
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
}
} catch (err: any) {
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
await this.updateLogStatus(toEmail, template, 'failed', err.message);
}
}
// ─── Database logging ───────────────────────────────────────
private async log(
toEmail: string,
subject: string,
body: string,
template: string,
metadata: Record<string, any>,
): Promise<void> {
try {
await this.dataSource.query(
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
VALUES ($1, $2, $3, $4, $5)`,
[toEmail, subject, body, template, JSON.stringify(metadata)],
);
} catch (err) {
this.logger.warn(`Failed to log email: ${err}`);
}
}
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
try {
await this.dataSource.query(
`UPDATE shared.email_log
SET metadata = metadata || $1::jsonb
WHERE to_email = $2 AND template = $3
AND created_at = (
SELECT MAX(created_at) FROM shared.email_log
WHERE to_email = $2 AND template = $3
)`,
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
);
} catch {
// Best effort — don't block the flow
}
}
// ─── HTML email template ────────────────────────────────────
private esc(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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;">
&copy; ${new Date().getFullYear()} HOA LedgerIQ &mdash; Smart Financial Management for HOAs
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
}

View File

@@ -16,7 +16,7 @@ export class HealthScoresController {
@Get('latest')
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
getLatest(@Req() req: any) {
const schema = req.user?.orgSchema;
const schema = req.tenantSchema;
return this.service.getLatestScores(schema);
}
@@ -24,7 +24,7 @@ export class HealthScoresController {
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.user?.orgSchema;
const schema = req.tenantSchema;
// Fire-and-forget — background processing saves results to DB
Promise.all([
@@ -44,7 +44,7 @@ export class HealthScoresController {
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
@AllowViewer()
async calculateOperating(@Req() req: any) {
const schema = req.user?.orgSchema;
const schema = req.tenantSchema;
// Fire-and-forget
this.service.calculateScore(schema, 'operating').catch((err) => {
@@ -61,7 +61,7 @@ export class HealthScoresController {
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
@AllowViewer()
async calculateReserve(@Req() req: any) {
const schema = req.user?.orgSchema;
const schema = req.tenantSchema;
// Fire-and-forget
this.service.calculateScore(schema, 'reserve').catch((err) => {

View File

@@ -220,12 +220,12 @@ export class HealthScoresService {
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(
`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) {
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.');
}
}
@@ -558,10 +558,12 @@ export class HealthScoresService {
FROM reserve_components
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(`
SELECT name, estimated_cost, target_year, target_month, fund_source,
status, priority, current_fund_balance, funded_percentage
SELECT name, estimated_cost, actual_cost, target_year, target_month, fund_source,
status, priority, current_fund_balance, funded_percentage,
category, useful_life_years, remaining_life_years, condition_rating,
annual_contribution
FROM projects
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
ORDER BY target_year, target_month NULLS LAST
@@ -596,11 +598,19 @@ export class HealthScoresService {
const totalReserveFund = reserveCash + totalInvestments;
const totalReplacementCost = reserveComponents
.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0);
// Use reserve_components for funded ratio when available; fall back to
// 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
.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0);
const totalReplacementCost = useComponentsTable
? 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;
@@ -615,9 +625,13 @@ export class HealthScoresService {
.filter((b: any) => b.account_type === 'expense')
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
// Components needing replacement within 5 years
const urgentComponents = reserveComponents.filter(
// Components needing replacement within 5 years — use whichever source has data
const urgentComponents = useComponentsTable
? reserveComponents.filter(
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
)
: reserveProjects.filter(
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
);
// ── Build 12-month forward reserve cash flow projection ──
@@ -749,6 +763,7 @@ export class HealthScoresService {
accounts,
investments,
reserveComponents,
reserveProjects,
projects,
budgets,
assessments,
@@ -959,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'}`,
).join('\n');
const componentLines = data.reserveComponents.length === 0
? 'No reserve components tracked.'
: data.reserveComponents.map((c: any) => {
const cost = parseFloat(c.replacement_cost || '0');
// Build component lines from reserve_components if available, otherwise from reserve-funded projects
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
const componentLines = componentSource.length === 0
? 'No reserve components or reserve projects tracked.'
: componentSource.map((c: any) => {
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const 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');
const projectLines = data.projects.length === 0
@@ -981,7 +998,7 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
const urgentLines = data.urgentComponents.length === 0
? 'None — no components due within 5 years.'
: 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 gap = cost - funded;
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
@@ -997,8 +1014,8 @@ Reserve Cash (bank accounts): $${data.reserveCash.toFixed(2)}
Reserve Investments: $${data.totalInvestments.toFixed(2)}
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)}
Percent Funded: ${data.percentFunded.toFixed(1)}%
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.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 Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}

View File

@@ -38,6 +38,15 @@ export interface MarketRate {
fetched_at: string;
}
export interface RecommendationComponent {
label: string;
amount: number;
term_months: number;
rate: number;
bank_name?: string;
investment_type?: string;
}
export interface Recommendation {
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
priority: 'high' | 'medium' | 'low';
@@ -50,6 +59,7 @@ export interface Recommendation {
suggested_rate?: number;
bank_name?: string;
rationale: string;
components?: RecommendationComponent[];
}
export interface AIResponse {
@@ -904,13 +914,28 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
"suggested_term": "12 months",
"suggested_rate": 4.50,
"bank_name": "Bank name from market rates (if applicable)",
"rationale": "Financial reasoning for why this makes sense"
"rationale": "Financial reasoning for why this makes sense",
"components": [
{
"label": "Component label (e.g. '6-Month CD at Marcus')",
"amount": 6600.00,
"term_months": 6,
"rate": 4.05,
"bank_name": "Marcus",
"investment_type": "cd"
}
]
}
],
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
"risk_notes": ["Array of risk items or concerns to flag for the board"]
}
IMPORTANT ABOUT COMPONENTS:
- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts.
- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array.
- For simple single-investment recommendations, omit the "components" field entirely.
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
// Build the data context for the user prompt

View 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);
}
}

View 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 {}

View 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);
}
}

View File

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

View File

@@ -716,14 +716,38 @@ export class ReportsService {
`);
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(`
SELECT COALESCE(SUM(current_value - principal), 0) as total
FROM investment_accounts WHERE is_active = true AND current_value > principal
`);
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]);
// 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
const currentYear = new Date().getFullYear();
const capitalSpend = await this.tenant.query(`
SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
@@ -749,7 +773,9 @@ export class ReportsService {
operating_investments: operatingInvestments.toFixed(2),
reserve_investments: reserveInvestments.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',
};
}
@@ -838,8 +864,29 @@ export class ReportsService {
// We need budgets for startYear and startYear+1 to cover 24 months
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
for (const yr of [startYear, startYear + 1, startYear + 2]) {
const budgetRows = await this.tenant.query(
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 {
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
@@ -847,6 +894,7 @@ export class ReportsService {
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 };
@@ -973,11 +1021,24 @@ export class ReportsService {
let runOpInv = opInv;
let runResInv = resInv;
// Determine which months have actual journal entries
// A month is "actual" only if it's not in the future AND has real journal entry data
const monthsWithActuals = new Set<string>();
for (const key of Object.keys(histIndex)) {
// histIndex keys are "year-month-fund_type", extract year-month
const parts = key.split('-');
const ym = `${parts[0]}-${parts[1]}`;
monthsWithActuals.add(ym);
}
for (let i = 0; i < months; i++) {
const year = startYear + Math.floor(i / 12);
const month = (i % 12) + 1;
const key = `${year}-${month}`;
const isHistorical = year < currentYear || (year === currentYear && month <= currentMonth);
// A month is historical (actual) only if it's in the past AND has journal entries
const isPastMonth = year < currentYear || (year === currentYear && month < currentMonth);
const hasActuals = monthsWithActuals.has(key);
const isHistorical = isPastMonth && hasActuals;
const label = `${monthLabels[month - 1]} ${year}`;
if (isHistorical) {

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

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

View 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()
);

View File

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

View File

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

View File

@@ -40,6 +40,32 @@ services:
- 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:-}
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}
- 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:

View File

@@ -29,6 +29,32 @@ services:
- 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:-}
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}
- 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:
- ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json

View File

@@ -9,5 +9,34 @@
<body>
<div id="root"></div>
<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>
</html>

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.7-beta",
"version": "2026.3.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.7-beta",
"version": "2026.3.17",
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3",
@@ -14,6 +14,7 @@
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@simplewebauthn/browser": "^13.3.0",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",
@@ -1289,6 +1290,12 @@
"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": {
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.7-beta",
"version": "2026.3.19",
"private": true,
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@simplewebauthn/browser": "^13.3.0",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",

View File

@@ -4,6 +4,7 @@ import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/auth/LoginPage';
import { RegisterPage } from './pages/auth/RegisterPage';
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
import { ActivatePage } from './pages/auth/ActivatePage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { AccountsPage } from './pages/accounts/AccountsPage';
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 { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
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 }) {
const token = useAuthStore((s) => s.token);
@@ -71,6 +81,12 @@ function AuthRoute({ children }: { children: React.ReactNode }) {
export function App() {
return (
<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
path="/login"
element={
@@ -95,6 +111,18 @@ export function App() {
</ProtectedRoute>
}
/>
{/* Onboarding (requires auth but not org selection) */}
<Route
path="/onboarding"
element={
<ProtectedRoute>
<OnboardingPage />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="/admin"
element={
@@ -105,6 +133,8 @@ export function App() {
>
<Route index element={<AdminPage />} />
</Route>
{/* Main app routes (require auth + org) */}
<Route
path="/*"
element={
@@ -137,6 +167,12 @@ export function App() {
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} />
<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="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -18,7 +18,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import logoSrc from '../../assets/logo.svg';
import logoSrc from '../../assets/logo.png';
export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure();
@@ -106,7 +106,16 @@ export function AppLayout() {
<Group h={60} px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
<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>
{currentOrg && (

View File

@@ -17,7 +17,9 @@ import {
IconChartAreaLine,
IconClipboardCheck,
IconSparkles,
IconHeartRateMonitor,
IconCalculator,
IconGitCompare,
IconScale,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
@@ -44,22 +46,38 @@ const navSections = [
],
},
{
label: 'Transactions',
label: 'Board Planning',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
{
label: 'Projects', icon: IconShieldCheck, path: '/projects',
children: [
{ label: 'Capital Planning', path: '/capital-projects' },
],
},
{
label: 'Planning',
items: [
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
{ label: 'Capital Planning', icon: IconBuildingBank, 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',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
// { label: 'Payments', icon: IconCash, path: '/payments' },
],
},
{
label: 'Reports',
items: [
@@ -141,7 +159,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
</>
)}
{section.items.map((item: any) =>
item.children ? (
item.children && !item.path ? (
// Collapsible group without a parent route (e.g. Reports)
<NavLink
key={item.label}
label={item.label}
@@ -160,6 +179,29 @@ export function Sidebar({ onNavigate }: SidebarProps) {
/>
))}
</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
key={item.path}

View File

@@ -1,15 +1,17 @@
import { useState } from 'react';
import {
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
Select, Stack, Text, Title, Alert, ActionIcon, Table,
Card, ThemeIcon, Divider, Badge, SimpleGrid, Box,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import {
IconBuildingBank, IconUsers, IconFileSpreadsheet,
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
IconAlertCircle,
IconBuildingBank, IconUsers,
IconPlus, IconTrash, IconCheck, IconRocket,
IconAlertCircle, IconFileSpreadsheet, IconPigMoney, IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
@@ -24,56 +26,40 @@ interface UnitRow {
ownerEmail: string;
}
// ── CSV Parsing (reused from BudgetsPage pattern) ──
function parseCSV(text: string): Record<string, string>[] {
const lines = text.split('\n').filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
return lines.slice(1).map((line) => {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes; }
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += char; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, i) => { row[h] = values[i] || ''; });
return row;
});
}
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
const navigate = useNavigate();
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
// ── Step 1: Account State ──
// ── Step 1: Operating Account State ──
const [accountCreated, setAccountCreated] = useState(false);
const [accountName, setAccountName] = useState('Operating Checking');
const [accountNumber, setAccountNumber] = useState('1000');
const [accountDescription, setAccountDescription] = useState('');
const [initialBalance, setInitialBalance] = useState<number | string>(0);
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
// ── Step 2: Assessment Group State ──
// ── Step 2: Reserve Account State ──
const [reserveCreated, setReserveCreated] = useState(false);
const [reserveSkipped, setReserveSkipped] = useState(false);
const [reserveName, setReserveName] = useState('Reserve Savings');
const [reserveNumber, setReserveNumber] = useState('2000');
const [reserveDescription, setReserveDescription] = useState('');
const [reserveBalance, setReserveBalance] = useState<number | string>(0);
const [reserveBalanceDate, setReserveBalanceDate] = useState<Date | null>(new Date());
// ── Step 3: Assessment Group State ──
const [groupCreated, setGroupCreated] = useState(false);
const [groupName, setGroupName] = useState('Standard Assessment');
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
const [frequency, setFrequency] = useState('monthly');
const [unitCount, setUnitCount] = useState<number | string>(0);
const [units, setUnits] = useState<UnitRow[]>([]);
const [unitsCreated, setUnitsCreated] = useState(false);
// ── Step 3: Budget State ──
const [budgetFile, setBudgetFile] = useState<File | null>(null);
const [budgetUploaded, setBudgetUploaded] = useState(false);
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
const currentYear = new Date().getFullYear();
// ── Step 1: Create Account ──
// ── Step 1: Create Operating Account ──
const handleCreateAccount = async () => {
if (!accountName.trim()) {
setError('Account name is required');
@@ -99,6 +85,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
accountType: 'asset',
fundType: 'operating',
initialBalance: balance,
initialBalanceDate: balanceDate ? balanceDate.toISOString().split('T')[0] : undefined,
});
setAccountCreated(true);
notifications.show({
@@ -114,7 +101,53 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
}
};
// ── Step 2: Create Assessment Group ──
// ── Step 2: Create Reserve Account ──
const handleCreateReserve = async () => {
if (!reserveName.trim()) {
setError('Account name is required');
return;
}
if (!reserveNumber.trim()) {
setError('Account number is required');
return;
}
const balance = typeof reserveBalance === 'string' ? parseFloat(reserveBalance) : reserveBalance;
if (isNaN(balance)) {
setError('Initial balance must be a valid number');
return;
}
setLoading(true);
setError(null);
try {
await api.post('/accounts', {
accountNumber: reserveNumber.trim(),
name: reserveName.trim(),
description: reserveDescription.trim(),
accountType: 'asset',
fundType: 'reserve',
initialBalance: balance,
initialBalanceDate: reserveBalanceDate ? reserveBalanceDate.toISOString().split('T')[0] : undefined,
});
setReserveCreated(true);
notifications.show({
title: 'Reserve Account Created',
message: `${reserveName} has been created with an initial balance of $${balance.toLocaleString()}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create reserve account';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
const handleSkipReserve = () => {
setReserveSkipped(true);
};
// ── Step 3: Create Assessment Group ──
const handleCreateGroup = async () => {
if (!groupName.trim()) {
setError('Group name is required');
@@ -126,6 +159,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
return;
}
const count = typeof unitCount === 'string' ? parseInt(unitCount) : unitCount;
setLoading(true);
setError(null);
try {
@@ -133,6 +168,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
name: groupName.trim(),
regularAssessment: assessment,
frequency,
unitCount: isNaN(count) ? 0 : count,
isDefault: true,
});
setGroupCreated(true);
@@ -175,72 +211,19 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
}
};
// ── Step 3: Budget Import ──
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${currentYear}/template`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `budget_template_${currentYear}.csv`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch {
notifications.show({
title: 'Error',
message: 'Failed to download template',
color: 'red',
});
}
};
const handleUploadBudget = async () => {
if (!budgetFile) {
setError('Please select a CSV file');
return;
}
setLoading(true);
setError(null);
try {
const text = await budgetFile.text();
const rows = parseCSV(text);
if (rows.length === 0) {
setError('CSV file appears to be empty or invalid');
setLoading(false);
return;
}
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
setBudgetUploaded(true);
setBudgetImportResult(data);
notifications.show({
title: 'Budget Imported',
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to import budget';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Finish Wizard ──
// ── Finish Wizard → Navigate to Budget Planning ──
const handleFinish = async () => {
setLoading(true);
try {
await api.patch('/organizations/settings', { onboardingComplete: true });
setOrgSettings({ onboardingComplete: true });
onComplete();
// Navigate to Budget Planning so user can set up their budget immediately
navigate('/board-planning/budgets');
} catch {
// Even if API fails, close the wizard — onboarding data is already created
onComplete();
navigate('/board-planning/budgets');
} finally {
setLoading(false);
}
@@ -264,8 +247,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
// ── Navigation ──
const canGoNext = () => {
if (active === 0) return accountCreated;
if (active === 1) return groupCreated;
if (active === 2) return true; // Budget is optional
if (active === 1) return reserveCreated || reserveSkipped;
if (active === 2) return groupCreated;
return false;
};
@@ -305,22 +288,22 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Stepper active={active} size="sm" mb="xl">
<Stepper.Step
label="Operating Account"
description="Set up your primary bank account"
description="Primary bank account"
icon={<IconBuildingBank size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Reserve Account"
description={reserveSkipped ? 'Skipped' : 'Savings account'}
icon={<IconPigMoney size={18} />}
completedIcon={reserveSkipped ? <IconX size={18} /> : <IconCheck size={18} />}
/>
<Stepper.Step
label="Assessment Group"
description="Define homeowner assessments"
icon={<IconUsers size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Budget"
description="Import your annual budget"
icon={<IconFileSpreadsheet size={18} />}
completedIcon={<IconCheck size={18} />}
/>
</Stepper>
{error && (
@@ -343,6 +326,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Text fw={500}>{accountName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
{balanceDate && ` as of ${balanceDate.toLocaleDateString()}`}
</Text>
</Alert>
) : (
@@ -372,6 +356,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
autosize
minRows={2}
/>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Current Balance"
description="Enter the current balance of this bank account"
@@ -381,8 +366,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
thousandSeparator=","
prefix="$"
decimalScale={2}
mb="md"
/>
<DateInput
label="Balance As-Of Date"
description="Date this balance was accurate (e.g. last statement date)"
value={balanceDate}
onChange={setBalanceDate}
maxDate={new Date()}
clearable={false}
/>
</SimpleGrid>
<Button
onClick={handleCreateAccount}
loading={loading}
@@ -396,8 +389,103 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Stack>
)}
{/* ── Step 2: Assessment Group + Units ── */}
{/* ── Step 2: Reserve Account ── */}
{active === 1 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Set Up a Reserve Savings Account</Text>
<Text size="sm" c="dimmed" mb="md">
Most HOAs maintain a reserve fund for long-term capital projects like roof replacements,
paving, and major repairs. Setting this up now gives you a more complete financial picture
from the start.
</Text>
{reserveCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{reserveName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof reserveBalance === 'number' ? reserveBalance : parseFloat(reserveBalance as string) || 0).toLocaleString()}
{reserveBalanceDate && ` as of ${reserveBalanceDate.toLocaleDateString()}`}
</Text>
</Alert>
) : reserveSkipped ? (
<Alert icon={<IconX size={16} />} color="gray" variant="light">
<Text fw={500}>Reserve account skipped</Text>
<Text size="sm" c="dimmed">
You can always add a reserve account later from the Accounts page.
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={2} mb="md">
<TextInput
label="Account Name"
placeholder="e.g. Reserve Savings"
value={reserveName}
onChange={(e) => setReserveName(e.currentTarget.value)}
required
/>
<TextInput
label="Account Number"
placeholder="e.g. 2000"
value={reserveNumber}
onChange={(e) => setReserveNumber(e.currentTarget.value)}
required
/>
</SimpleGrid>
<Textarea
label="Description"
placeholder="Optional description"
value={reserveDescription}
onChange={(e) => setReserveDescription(e.currentTarget.value)}
mb="md"
autosize
minRows={2}
/>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Current Balance"
description="Enter the current balance of this reserve account"
placeholder="0.00"
value={reserveBalance}
onChange={setReserveBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
/>
<DateInput
label="Balance As-Of Date"
description="Date this balance was accurate"
value={reserveBalanceDate}
onChange={setReserveBalanceDate}
maxDate={new Date()}
clearable={false}
/>
</SimpleGrid>
<Group>
<Button
onClick={handleCreateReserve}
loading={loading}
leftSection={<IconPigMoney size={16} />}
>
Create Reserve Account
</Button>
<Button
variant="subtle"
color="gray"
onClick={handleSkipReserve}
>
No Reserve Account
</Button>
</Group>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 3: Assessment Group + Units ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create an Assessment Group</Text>
@@ -415,7 +503,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Alert>
) : (
<>
<SimpleGrid cols={3} mb="md">
<SimpleGrid cols={2} mb="md">
<TextInput
label="Group Name"
placeholder="e.g. Standard Assessment"
@@ -423,6 +511,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
onChange={(e) => setGroupName(e.currentTarget.value)}
required
/>
<NumberInput
label="Total Unit Count"
description="How many units/lots does your community have?"
placeholder="e.g. 50"
value={unitCount}
onChange={setUnitCount}
min={0}
required
/>
</SimpleGrid>
<SimpleGrid cols={2} mb="md">
<NumberInput
label="Assessment Amount"
placeholder="0.00"
@@ -520,61 +619,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Stack>
)}
{/* ── Step 3: Budget Upload ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
<Text size="sm" c="dimmed" mb="md">
Upload a CSV file with your annual budget. If you don&apos;t have one ready, you can download a template
or skip this step and set it up later from the Budgets page.
</Text>
{budgetUploaded ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>Budget imported successfully!</Text>
{budgetImportResult && (
<Text size="sm" c="dimmed">
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
</Text>
)}
</Alert>
) : (
<>
<Group mb="md">
<Button
variant="light"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download CSV Template
</Button>
</Group>
<FileInput
label="Upload Budget CSV"
placeholder="Click to select a .csv file"
accept=".csv"
value={budgetFile}
onChange={setBudgetFile}
mb="md"
leftSection={<IconFileSpreadsheet size={16} />}
/>
<Button
onClick={handleUploadBudget}
loading={loading}
leftSection={<IconFileSpreadsheet size={16} />}
disabled={!budgetFile}
>
Import Budget
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Completion Screen ── */}
{active === 3 && (
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
@@ -583,16 +627,25 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</ThemeIcon>
<Title order={3} mb="xs">You&apos;re All Set!</Title>
<Text c="dimmed" mb="lg" maw={400} mx="auto">
Your organization is configured and ready to go. You can always update your accounts,
assessment groups, and budgets from the sidebar navigation.
Your organization is configured and ready to go. The next step is to set up your annual
budget we&apos;ll take you straight to Budget Planning.
</Text>
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
<SimpleGrid cols={4} mb="xl" maw={600} mx="auto">
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconBuildingBank size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Account</Text>
<Text size="xs" mt={4}>Operating</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="violet" variant="light" radius="xl" mx="auto" mb={4}>
<IconPigMoney size={16} />
</ThemeIcon>
<Badge color={reserveSkipped ? 'gray' : 'green'} size="sm">
{reserveSkipped ? 'Skipped' : 'Done'}
</Badge>
<Text size="xs" mt={4}>Reserve</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
@@ -602,24 +655,30 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Text size="xs" mt={4}>Assessments</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<ThemeIcon size={32} color="cyan" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} />
</ThemeIcon>
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
{budgetUploaded ? 'Done' : 'Skipped'}
</Badge>
<Badge color="cyan" size="sm">Up Next</Badge>
<Text size="xs" mt={4}>Budget</Text>
</Card>
</SimpleGrid>
<Alert icon={<IconFileSpreadsheet size={16} />} color="blue" variant="light" mb="lg" ta="left">
<Text size="sm" fw={500} mb={4}>Set Up Your Budget</Text>
<Text size="sm" c="dimmed">
Your budget is critical for accurate financial health scores, cash flow forecasting,
and investment planning. Click below to go directly to Budget Planning where you can
download a CSV template, fill in your monthly amounts, and upload your budget.
</Text>
</Alert>
<Button
size="lg"
onClick={handleFinish}
loading={loading}
leftSection={<IconRocket size={18} />}
leftSection={<IconFileSpreadsheet size={18} />}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Start Using LedgerIQ
Set Up My Budget
</Button>
</Card>
)}
@@ -627,16 +686,11 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
{/* ── Navigation Buttons ── */}
{active < 3 && (
<Group justify="flex-end" mt="xl">
{active === 2 && !budgetUploaded && (
<Button variant="subtle" onClick={nextStep}>
Skip for now
</Button>
)}
<Button
onClick={nextStep}
disabled={!canGoNext()}
>
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
Next Step
</Button>
</Group>
)}

View File

@@ -9,7 +9,7 @@ import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import { App } from './App';
import { theme } from './theme/theme';
import { defaultTheme, compactTheme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({
@@ -24,9 +24,11 @@ const queryClient = new QueryClient({
function Root() {
const colorScheme = usePreferencesStore((s) => s.colorScheme);
const compactView = usePreferencesStore((s) => s.compactView);
const activeTheme = compactView ? compactTheme : defaultTheme;
return (
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
<MantineProvider theme={activeTheme} forceColorScheme={colorScheme}>
<Notifications position="top-right" />
<ModalsProvider>
<QueryClientProvider client={queryClient}>

View File

@@ -587,7 +587,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && (
<>
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -605,7 +605,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && (
<>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -623,7 +623,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && (
<>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -1087,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({
investments,
onEdit,
isReadOnly = false,
}: {
investments: Investment[];
onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce(
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -1182,6 +1184,7 @@ function InvestmentMiniTable({
'-'
)}
</Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
@@ -1189,6 +1192,7 @@ function InvestmentMiniTable({
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>

View 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>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Center,
Container,
@@ -10,19 +10,44 @@ import {
Anchor,
Stack,
Alert,
Divider,
Group,
PinInput,
} from '@mantine/core';
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 { startAuthentication } from '@simplewebauthn/browser';
import api from '../../services/api';
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() {
const [loading, setLoading] = useState(false);
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 setAuth = useAuthStore((s) => s.setAuth);
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const form = useForm({
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) => {
setLoading(true);
setError('');
try {
const { data } = await api.post('/auth/login', values);
setAuth(data.accessToken, data.user, data.organizations);
// Platform owner / superadmin with no orgs → admin panel
if (data.user?.isSuperadmin && data.organizations.length === 0) {
navigate('/admin');
} else if (data.organizations.length >= 1) {
// Always go through org selection to ensure correct JWT with orgSchema
navigate('/select-org');
if (data.mfaRequired) {
setMfaToken(data.mfaToken);
setLoginState('mfa');
} else {
navigate('/');
handleLoginSuccess(data);
}
} catch (err: any) {
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 (
<Container size={420} my={80}>
<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>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Don&apos;t have an account?{' '}
@@ -91,6 +325,53 @@ export function LoginPage() {
</Button>
</Stack>
</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>
</Container>
);

View File

@@ -120,11 +120,6 @@ export function SelectOrgPage() {
<Text fw={500}>{org.name}</Text>
<Group gap={4}>
<Badge size="sm" variant="light">{org.role}</Badge>
{org.schemaName && (
<Badge size="xs" variant="dot" color="gray">
{org.schemaName}
</Badge>
)}
</Group>
</div>
</Group>

View File

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

View 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>
);
}

View File

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

View File

@@ -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 &quot;Add Investment&quot; 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View 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>
);
}

View File

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

View 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>
);
}

View File

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

View File

@@ -1,13 +1,15 @@
import { useState, useRef } from 'react';
import { useState, useMemo } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card, Alert,
} from '@mantine/core';
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 { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetLine {
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 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 {
const line: any = { ...row };
for (const m of months) {
@@ -54,77 +35,64 @@ function hydrateBudgetLine(row: any): 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() {
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 fileInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
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],
queryFn: async () => {
const { data } = await api.get(`/budgets/${year}`);
// Hydrate each line: ensure numbers and compute annual_total
const hydrated = (data as any[]).map(hydrateBudgetLine);
setBudgetData(hydrated);
return hydrated;
return (data as any[]).map(hydrateBudgetLine);
},
});
// 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({
mutationFn: async () => {
const lines = budgetData
const payload = budgetData
.filter((b) => months.some((m) => (b as any)[m] > 0))
.map((b) => ({
account_id: b.account_id,
fund_type: b.fund_type,
accountId: b.account_id,
fundType: b.fund_type,
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
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: () => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
setEditData(null);
notifications.show({ message: 'Budget saved', color: 'green' });
},
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 updated = [...budgetData];
if (!editData) return;
const updated = [...editData];
(updated[idx] as any)[month] = value || 0;
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
setBudgetData(updated);
setEditData(updated);
};
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const yearOptions = useMemo(() => Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 1 + i;
return { value: String(y), label: String(y) };
});
}), []);
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
// Show loader on initial load or when switching years with no cached data
if (isLoading) return <Center h={300}><Loader /></Center>;
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 totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalIncome = totalOperatingIncome + totalReserveIncome;
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return (
@@ -251,42 +131,59 @@ export function BudgetsPage() {
<Group justify="space-between">
<Title order={2}>Budget Manager</Title>
<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
variant="outline"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
leftSection={<IconPencil size={16} />}
onClick={handleStartEdit}
>
Download Template
Edit Budget
</Button>
{!isReadOnly && (<>
) : (
<>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
color="gray"
leftSection={<IconX size={16} />}
onClick={handleCancelEdit}
>
Import CSV
Cancel
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
>
Save Budget
</Button>
</>)}
</>
)}
</>
)}
</Group>
</Group>
{budgetData.length === 0 && !isLoading && (
{!hasBudget && !isLoading && (
<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:{' '}
<Text span ff="monospace" size="xs">account_number, account_name, jan, feb, ..., dec</Text>.
Accounts will be auto-created if they don&apos;t exist yet.
<Stack gap="sm">
<Text>No budget data for {year}.</Text>
<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>
)}
@@ -317,8 +214,8 @@ export function BudgetsPage() {
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', 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: 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>
))}
@@ -329,7 +226,7 @@ export function BudgetsPage() {
{budgetData.length === 0 && (
<Table.Tr>
<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.Tr>
)}
@@ -337,7 +234,7 @@ export function BudgetsPage() {
const lines = budgetData.filter((b) => b.account_type === type);
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);
return [
@@ -368,9 +265,9 @@ export function BudgetsPage() {
style={{
position: 'sticky',
left: 0,
background: 'white',
background: stickyBg,
zIndex: 1,
borderRight: '1px solid #e9ecef',
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
@@ -379,9 +276,9 @@ export function BudgetsPage() {
style={{
position: 'sticky',
left: 120,
background: 'white',
background: stickyBg,
zIndex: 1,
borderRight: '1px solid #e9ecef',
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
@@ -391,6 +288,7 @@ export function BudgetsPage() {
</Table.Td>
{months.map((m) => (
<Table.Td key={m} p={2}>
{cellsEditable ? (
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
@@ -398,9 +296,13 @@ export function BudgetsPage() {
hideControls
decimalScale={2}
min={0}
disabled={isReadOnly}
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">

View File

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

View File

@@ -1,13 +1,13 @@
import { useState, useMemo } from 'react';
import {
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon,
Title, Text, Stack, Card, Group,
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
} from '@mantine/core';
import {
IconCash, IconBuildingBank, IconChartAreaLine,
IconArrowLeft, IconArrowRight, IconCalendar,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { usePreferencesStore } from '../../stores/preferencesStore';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
@@ -79,6 +79,7 @@ export function CashFlowForecastPage() {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
// Filter: All, Operating, Reserve
const [fundFilter, setFundFilter] = useState<string>('all');
@@ -106,30 +107,6 @@ export function CashFlowForecastPage() {
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
}, [datapoints, viewStartIndex]);
// Compute summary stats for the current view
const summaryStats = useMemo(() => {
if (!viewData.length) return null;
const last = viewData[viewData.length - 1];
const first = viewData[0];
const totalOperating = last.operating_cash + last.operating_investments;
const totalReserve = last.reserve_cash + last.reserve_investments;
const totalAll = totalOperating + totalReserve;
const firstTotal = first.operating_cash + first.operating_investments +
first.reserve_cash + first.reserve_investments;
const netChange = totalAll - firstTotal;
return {
totalOperating,
totalReserve,
totalAll,
netChange,
periodStart: first.month,
periodEnd: last.month,
};
}, [viewData]);
// Determine the first forecast month index within the view
const forecastStartLabel = useMemo(() => {
const idx = viewData.findIndex((d) => d.is_forecast);
@@ -179,65 +156,6 @@ export function CashFlowForecastPage() {
/>
</Group>
{/* Summary Cards */}
{summaryStats && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="blue" size="sm">
<IconCash size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalOperating)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="violet" size="sm">
<IconBuildingBank size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalReserve)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="teal" size="sm">
<IconChartAreaLine size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Combined Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalAll)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon
variant="light"
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
size="sm"
>
<IconCash size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
</Group>
<Text
fw={700}
size="xl"
ff="monospace"
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
>
{fmt(summaryStats.netChange)}
</Text>
</Card>
</SimpleGrid>
)}
{/* Chart Navigation */}
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
@@ -285,20 +203,20 @@ export function CashFlowForecastPage() {
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} />
<stop offset="5%" stopColor="#339af0" stopOpacity={0.6} />
<stop offset="95%" stopColor="#339af0" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} />
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} />
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} />
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
@@ -418,10 +336,10 @@ export function CashFlowForecastPage() {
<tr
key={d.month}
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
? 'var(--mantine-color-orange-0)'
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-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>

View File

@@ -1,7 +1,7 @@
import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
Popover, List,
Popover, List, Anchor,
} from '@mantine/core';
import {
IconCash,
@@ -18,7 +18,8 @@ import {
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import { useNavigate } from 'react-router-dom';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api';
interface HealthScore {
@@ -58,6 +59,28 @@ function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
return null;
}
// Map missing data items to navigation links
const missingDataLinks: Record<string, { label: string; path: string }> = {
'reserve fund account': { label: 'Set up a reserve account', path: '/accounts' },
'reserve account': { label: 'Set up a reserve account', path: '/accounts' },
'reserve projects': { label: 'Add reserve projects', path: '/projects' },
'capital projects': { label: 'Add capital projects', path: '/projects' },
'projects': { label: 'Add projects', path: '/projects' },
'budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'operating budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'reserve budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'assessment groups': { label: 'Create assessment groups', path: '/assessment-groups' },
'accounts': { label: 'Set up accounts', path: '/accounts' },
};
function getMissingDataLink(item: string): { label: string; path: string } | null {
const lower = item.toLowerCase();
for (const [key, value] of Object.entries(missingDataLinks)) {
if (lower.includes(key)) return value;
}
return null;
}
function HealthScoreCard({
score,
title,
@@ -65,6 +88,7 @@ function HealthScoreCard({
isRefreshing,
onRefresh,
lastFailed,
onNavigate,
}: {
score: HealthScore | null;
title: string;
@@ -72,6 +96,7 @@ function HealthScoreCard({
isRefreshing?: boolean;
onRefresh?: () => void;
lastFailed?: boolean;
onNavigate?: (path: string) => void;
}) {
// No score at all yet
if (!score) {
@@ -118,9 +143,19 @@ function HealthScoreCard({
<Stack align="center" gap="xs">
<Badge color="gray" variant="light" size="lg">Pending</Badge>
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
{missingItems.map((item: string, i: number) => (
{missingItems.map((item: string, i: number) => {
const link = getMissingDataLink(item);
return link ? (
<Anchor key={i} size="xs" href={link.path} onClick={(e: React.MouseEvent) => {
e.preventDefault();
onNavigate?.(link.path);
}}>
{item} &rarr; {link.label}
</Anchor>
) : (
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
))}
);
})}
</Stack>
</Center>
</Card>
@@ -306,12 +341,16 @@ interface DashboardData {
reserve_investments: string;
est_monthly_interest: string;
interest_earned_ytd: string;
interest_last_year: string;
interest_projected: string;
planned_capital_spend: string;
}
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient();
const navigate = useNavigate();
// Track whether a refresh is in progress (per score type) for async polling
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
@@ -424,8 +463,9 @@ export function DashboardPage() {
</ThemeIcon>
}
isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating}
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed}
onNavigate={navigate}
/>
<HealthScoreCard
score={healthScores?.reserve || null}
@@ -436,8 +476,9 @@ export function DashboardPage() {
</ThemeIcon>
}
isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve}
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed}
onNavigate={navigate}
/>
</SimpleGrid>
@@ -491,6 +532,66 @@ export function DashboardPage() {
</SimpleGrid>
<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">
<Title order={4} mb="sm">Recent Transactions</Title>
{(data?.recent_transactions || []).length === 0 ? (
@@ -520,43 +621,6 @@ export function DashboardPage() {
</Table>
)}
</Card>
<Card withBorder padding="lg" radius="md">
<Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">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>
</>
)}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Title,
Text,
@@ -19,6 +19,10 @@ import {
Tabs,
Collapse,
ActionIcon,
Modal,
Select,
TextInput,
Progress,
} from '@mantine/core';
import {
IconBulb,
@@ -32,10 +36,14 @@ import {
IconPigMoney,
IconChevronDown,
IconChevronUp,
IconPlaylistAdd,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { DateInput } from '@mantine/dates';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ──
@@ -80,6 +88,15 @@ interface MarketRatesResponse {
high_yield_savings: MarketRate[];
}
interface RecommendationComponent {
label: string;
amount: number;
term_months: number;
rate: number;
bank_name?: string;
investment_type?: string;
}
interface Recommendation {
type: string;
priority: 'high' | 'medium' | 'low';
@@ -92,6 +109,7 @@ interface Recommendation {
suggested_rate?: number;
bank_name?: string;
rationale: string;
components?: RecommendationComponent[];
}
interface AIResponse {
@@ -188,10 +206,12 @@ function RecommendationsDisplay({
aiResult,
lastUpdated,
lastFailed,
onAddToPlan,
}: {
aiResult: AIResponse;
lastUpdated?: string;
lastFailed?: boolean;
onAddToPlan?: (rec: Recommendation) => void;
}) {
return (
<Stack>
@@ -327,6 +347,17 @@ function RecommendationsDisplay({
<Alert variant="light" color="gray" title="Rationale">
<Text size="sm">{rec.rationale}</Text>
</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>
</Accordion.Panel>
</Accordion.Item>
@@ -345,8 +376,93 @@ function RecommendationsDisplay({
// ── Main Component ──
export function InvestmentPlanningPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
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
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -399,20 +515,31 @@ export function InvestmentPlanningPage() {
}
}, [savedRec?.status, isTriggering]);
// Ref for scrolling to AI section on completion
const aiSectionRef = useRef<HTMLDivElement>(null);
// Show notification when processing completes (transition from processing)
const prevStatusRef = useState<string | null>(null);
useEffect(() => {
const [prevStatus, setPrevStatus] = prevStatusRef;
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
notifications.show({
title: 'AI Analysis Complete',
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
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);
@@ -432,6 +559,32 @@ export function InvestmentPlanningPage() {
}
}, []);
// Auto-refresh: if no recommendations exist or they are older than 30 days, trigger automatically
const autoRefreshTriggered = useRef(false);
useEffect(() => {
if (autoRefreshTriggered.current || isProcessing || isTriggering || isReadOnly) return;
if (savedRec === undefined) return; // still loading
const shouldAutoRefresh = (() => {
// No saved recommendation at all
if (!savedRec) return true;
// Error state with no cached data
if (savedRec.status === 'error' && (!savedRec.recommendations || savedRec.recommendations.length === 0)) return true;
// Recommendations older than 30 days
if (savedRec.created_at) {
const age = Date.now() - new Date(savedRec.created_at).getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (age > thirtyDays) return true;
}
return false;
})();
if (shouldAutoRefresh) {
autoRefreshTriggered.current = true;
handleTriggerAI();
}
}, [savedRec, isProcessing, isTriggering, isReadOnly, handleTriggerAI]);
// Build AI result from saved recommendation for display
const aiResult: AIResponse | null = hasResults
? {
@@ -683,7 +836,7 @@ export function InvestmentPlanningPage() {
<Divider />
{/* ── Section 4: AI Investment Recommendations ── */}
<Card withBorder p="lg">
<Card withBorder p="lg" ref={aiSectionRef}>
<Group justify="space-between" mb="md">
<Group gap="xs">
<ThemeIcon variant="light" color="grape" size="md">
@@ -696,6 +849,7 @@ export function InvestmentPlanningPage() {
</Text>
</div>
</Group>
{!isReadOnly && (
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
@@ -705,21 +859,25 @@ export function InvestmentPlanningPage() {
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
)}
</Group>
{/* Processing State */}
{/* Processing State - shown as banner when refreshing with existing results */}
{isProcessing && (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="lg" type="dots" />
<Text c="dimmed" size="sm">
Analyzing your financial data and market rates...
<Alert variant="light" color="grape" mb="md" styles={{ root: { overflow: 'visible' } }}>
<Group gap="sm">
<Loader size="sm" color="grape" />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{aiResult ? 'Refreshing AI analysis...' : 'Running AI analysis...'}
</Text>
<Text c="dimmed" size="xs">
You can navigate away results will appear when ready
<Text size="xs" c="dimmed">
Analyzing your financial data, accounts, budgets, and current market rates
</Text>
</Stack>
</Center>
</div>
</Group>
<Progress value={100} animated color="grape" size="xs" mt="xs" />
</Alert>
)}
{/* Error State (no cached data) */}
@@ -731,16 +889,19 @@ export function InvestmentPlanningPage() {
</Alert>
)}
{/* Results (with optional failure watermark) */}
{aiResult && !isProcessing && (
{/* Results - keep visible even while refreshing (with optional failure watermark) */}
{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 */}
{/* Empty State - only when no results and not processing */}
{!aiResult && !isProcessing && !hasError && (
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
@@ -758,6 +919,77 @@ export function InvestmentPlanningPage() {
</Paper>
)}
</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>
);
}

View File

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

View File

@@ -1,15 +1,17 @@
import { useState, useMemo } from 'react';
import {
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';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth,
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconEdit,
} 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';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine {
@@ -64,8 +66,15 @@ export function MonthlyActualsPage() {
const [month, setMonth] = useState(defaults.month);
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
const [savedJEId, setSavedJEId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
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 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 y = new Date().getFullYear() - 2 + i;
@@ -78,10 +87,15 @@ export function MonthlyActualsPage() {
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
setEditedAmounts({});
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;
},
});
// Whether actuals have been previously saved (reconciled)
const hasExistingActuals = !!savedJEId;
const saveMutation = useMutation({
mutationFn: async () => {
const lines = (grid?.lines || [])
@@ -101,6 +115,8 @@ export function MonthlyActualsPage() {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
setSavedJEId(data.journal_entry_id);
setIsEditing(false);
setEditedAmounts({});
notifications.show({
message: data.message || 'Actuals saved and reconciled',
color: 'green',
@@ -125,6 +141,19 @@ export function MonthlyActualsPage() {
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
};
const handleEditClick = () => {
if (hasExistingActuals) {
openConfirm();
} else {
setIsEditing(true);
}
};
const handleConfirmEdit = () => {
closeConfirm();
setIsEditing(true);
};
const lines = grid?.lines || [];
const incomeLines = lines.filter((l) => l.account_type === 'income');
const expenseLines = lines.filter((l) => l.account_type === 'expense');
@@ -137,7 +166,6 @@ export function MonthlyActualsPage() {
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
}, [lines, editedAmounts]);
const hasChanges = Object.keys(editedAmounts).length > 0;
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
if (isLoading) return <Center h={300}><Loader /></Center>;
@@ -163,7 +191,7 @@ export function MonthlyActualsPage() {
{title}
</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"
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.Td
style={{
position: 'sticky', left: 0, background: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
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: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
@@ -198,7 +226,8 @@ export function MonthlyActualsPage() {
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
{fmt(line.budget_amount)}
</Table.Td>
<Table.Td p={2} style={{ minWidth: 130 }}>
<Table.Td p={isEditing ? 2 : undefined} style={{ minWidth: 130 }}>
{isEditing ? (
<NumberInput
value={amount}
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
@@ -209,6 +238,9 @@ export function MonthlyActualsPage() {
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ff="monospace" ta="right">{fmt(amount)}</Text>
)}
</Table.Td>
<Table.Td
ta="right" ff="monospace" style={{ minWidth: 110 }}
@@ -232,14 +264,24 @@ export function MonthlyActualsPage() {
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
{!isReadOnly && (
{!isReadOnly && !isEditing && (
<Button
leftSection={<IconEdit size={16} />}
variant="light"
onClick={handleEditClick}
disabled={lines.length === 0}
>
Edit Actuals
</Button>
)}
{!isReadOnly && isEditing && (
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={lines.length === 0}
>
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
Save Actuals
</Button>
)}
</Group>
@@ -276,7 +318,7 @@ export function MonthlyActualsPage() {
</Alert>
)}
{savedJEId && (
{hasExistingActuals && !isEditing && (
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
<Group justify="space-between" align="flex-start">
<Text size="sm">
@@ -292,10 +334,10 @@ export function MonthlyActualsPage() {
<Table striped highlightOnHover style={{ minWidth: 700 }}>
<Table.Thead>
<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 #
</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
</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
@@ -304,8 +346,8 @@ export function MonthlyActualsPage() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
</Table.Tbody>
</Table>
</div>
@@ -317,6 +359,26 @@ export function MonthlyActualsPage() {
<AttachmentPanel journalEntryId={savedJEId} />
</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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

@@ -10,7 +10,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
export function UserPreferencesPage() {
const { user, currentOrg } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const { colorScheme, toggleColorScheme, compactView, toggleCompactView } = usePreferencesStore();
return (
<Stack>
@@ -78,7 +78,7 @@ export function UserPreferencesPage() {
<Text size="sm">Compact View</Text>
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
</div>
<Switch disabled />
<Switch checked={compactView} onChange={toggleCompactView} />
</Group>
<Divider />
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>

View File

@@ -0,0 +1,284 @@
import { useState } from 'react';
import {
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
Button, List, ThemeIcon, TextInput, Center, Alert, SegmentedControl, Box,
} from '@mantine/core';
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import logoSrc from '../../assets/logo.png';
type BillingInterval = 'month' | 'year';
const plans = [
{
id: 'starter',
name: 'Starter',
monthlyPrice: 29,
annualPrice: 261, // 29 * 12 * 0.75
description: 'For small communities getting started',
icon: IconRocket,
color: 'blue',
features: [
{ text: 'Up to 50 units', included: true },
{ text: 'Chart of Accounts', included: true },
{ text: 'Assessment Tracking', included: true },
{ text: 'Basic Reports', included: true },
{ text: 'Board Planning', included: false },
{ text: 'AI Investment Advisor', included: false },
],
},
{
id: 'professional',
name: 'Professional',
monthlyPrice: 79,
annualPrice: 711, // 79 * 12 * 0.75
description: 'For growing HOAs that need full features',
icon: IconStar,
color: 'violet',
popular: true,
features: [
{ text: 'Up to 200 units', included: true },
{ text: 'Everything in Starter', included: true },
{ text: 'Board Planning & Scenarios', included: true },
{ text: 'AI Investment Advisor', included: true },
{ text: 'Advanced Reports', included: true },
{ text: 'Priority Support', included: false },
],
},
{
id: 'enterprise',
name: 'Enterprise',
monthlyPrice: 0,
annualPrice: 0,
description: 'For large communities and management firms',
icon: IconCrown,
color: 'orange',
externalUrl: 'https://www.hoaledgeriq.com/#preview-signup',
features: [
{ text: 'Unlimited units', included: true },
{ text: 'Everything in Professional', included: true },
{ text: 'Priority Support', included: true },
{ text: 'Custom Integrations', included: true },
{ text: 'Dedicated Account Manager', included: true },
{ text: 'SLA Guarantee', included: true },
],
},
];
function formatPrice(plan: typeof plans[0], interval: BillingInterval) {
if (plan.externalUrl) return { display: 'Custom', sub: '' };
if (interval === 'year') {
const monthly = (plan.annualPrice / 12).toFixed(2);
return {
display: `$${monthly}`,
sub: `/mo billed annually ($${plan.annualPrice}/yr)`,
};
}
return { display: `$${plan.monthlyPrice}`, sub: '/month' };
}
export function PricingPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState('');
const [email, setEmail] = useState('');
const [businessName, setBusinessName] = useState('');
const [billingInterval, setBillingInterval] = useState<BillingInterval>('month');
const handleStartTrial = async (planId: string) => {
if (!email.trim()) {
setError('Email address is required to start a trial');
return;
}
if (!businessName.trim()) {
setError('HOA / Business name is required to start a trial');
return;
}
setLoading(planId);
setError('');
try {
const { data } = await api.post('/billing/start-trial', {
planId,
billingInterval,
email: email.trim(),
businessName: businessName.trim(),
});
if (data.subscriptionId) {
// Navigate to pending page with subscription ID for polling
navigate(`/onboarding/pending?session_id=${data.subscriptionId}`);
} else {
setError('Unable to start trial');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to start trial');
} finally {
setLoading(null);
}
};
return (
<Container size="lg" py={60}>
<Stack align="center" mb={40}>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
<Title order={1} ta="center">
Simple, transparent pricing
</Title>
<Text size="lg" c="dimmed" ta="center" maw={500}>
Choose the plan that fits your community. All plans include a 14-day free trial.
</Text>
</Stack>
{/* Monthly / Annual Toggle */}
<Center mb="xl">
<Box pos="relative">
<SegmentedControl
value={billingInterval}
onChange={(val) => setBillingInterval(val as BillingInterval)}
data={[
{ label: 'Monthly', value: 'month' },
{ label: 'Annual', value: 'year' },
]}
size="md"
radius="xl"
/>
{billingInterval === 'year' && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: -10, right: -40 }}
>
Save 25%
</Badge>
)}
</Box>
</Center>
{/* Pre-capture fields (required for trial) */}
<Center mb="xl">
<Group>
<TextInput
placeholder="Email address *"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
style={{ width: 220 }}
required
/>
<TextInput
placeholder="HOA / Business name *"
value={businessName}
onChange={(e) => setBusinessName(e.currentTarget.value)}
style={{ width: 220 }}
required
/>
</Group>
</Center>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="lg" variant="light">
{error}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{plans.map((plan) => {
const price = formatPrice(plan, billingInterval);
return (
<Card
key={plan.id}
withBorder
shadow={plan.popular ? 'lg' : 'sm'}
radius="md"
p="xl"
style={plan.popular ? {
border: '2px solid var(--mantine-color-violet-5)',
position: 'relative',
} : undefined}
>
{plan.popular && (
<Badge
color="violet"
variant="filled"
style={{ position: 'absolute', top: -10, right: 20 }}
>
Most Popular
</Badge>
)}
<Stack gap="md">
<Group>
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
<plan.icon size={20} />
</ThemeIcon>
<div>
<Text fw={700} size="lg">{plan.name}</Text>
<Text size="xs" c="dimmed">{plan.description}</Text>
</div>
</Group>
<div>
<Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
{plan.externalUrl ? 'Request Quote' : price.display}
</Text>
</Group>
{price.sub && (
<Text size="xs" c="dimmed" mt={2}>
{price.sub}
</Text>
)}
{!plan.externalUrl && billingInterval === 'year' && (
<Text size="xs" c="dimmed" td="line-through" mt={2}>
${plan.monthlyPrice}/mo without annual discount
</Text>
)}
</div>
<List spacing="xs" size="sm" center>
{plan.features.map((f, i) => (
<List.Item
key={i}
icon={
<ThemeIcon
size={20}
radius="xl"
color={f.included ? 'teal' : 'gray'}
variant={f.included ? 'filled' : 'light'}
>
{f.included ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
}
>
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text>
</List.Item>
))}
</List>
<Button
fullWidth
size="md"
color={plan.color}
variant={plan.popular ? 'filled' : 'light'}
loading={!plan.externalUrl ? loading === plan.id : false}
onClick={() =>
plan.externalUrl
? window.open(plan.externalUrl, '_blank', 'noopener')
: handleStartTrial(plan.id)
}
>
{plan.externalUrl ? 'Request Quote' : 'Start Free Trial'}
</Button>
</Stack>
</Card>
);
})}
</SimpleGrid>
<Text ta="center" size="sm" c="dimmed" mt="xl">
All plans include a 14-day free trial. No credit card required.
</Text>
</Container>
);
}

View File

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

View File

@@ -5,6 +5,7 @@ import {
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualLine {
account_id: string;
@@ -46,6 +47,9 @@ const monthFilterOptions = [
export function BudgetVsActualPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [month, setMonth] = useState('');
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
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 y = new Date().getFullYear() - 2 + i;
@@ -92,7 +96,7 @@ export function BudgetVsActualPage() {
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
<>
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
</Table.Tr>
{sectionLines.map((line) => {

View File

@@ -8,6 +8,7 @@ import {
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
} from '@tabler/icons-react';
import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualItem {
account_id: string;
@@ -48,6 +49,9 @@ export function QuarterlyReportPage() {
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter;
const defaultYear = now.getFullYear();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const [year, setYear] = useState(String(defaultYear));
const [quarter, setQuarter] = useState(String(defaultQuarter));
@@ -207,7 +211,7 @@ export function QuarterlyReportPage() {
</Table.Thead>
<Table.Tbody>
{incomeItems.length > 0 && (
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Tr style={{ background: incomeBg }}>
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
</Table.Tr>
)}
@@ -215,7 +219,7 @@ export function QuarterlyReportPage() {
<BVARow key={item.account_id} item={item} isExpense={false} />
))}
{incomeItems.length > 0 && (
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Tr style={{ background: incomeBg }}>
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
@@ -226,7 +230,7 @@ export function QuarterlyReportPage() {
</Table.Tr>
)}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Tr style={{ background: expenseBg }}>
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
</Table.Tr>
)}
@@ -234,7 +238,7 @@ export function QuarterlyReportPage() {
<BVARow key={item.account_id} item={item} isExpense={true} />
))}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Tr style={{ background: expenseBg }}>
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>

View File

@@ -0,0 +1,97 @@
import {
Card, Title, Text, Stack, Group, Button, Badge, Alert,
} from '@mantine/core';
import { IconBrandGoogle, IconBrandAzure, IconLink, IconLinkOff, IconAlertCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
export function LinkedAccounts() {
const queryClient = useQueryClient();
const { data: providers } = useQuery({
queryKey: ['sso-providers'],
queryFn: async () => {
const { data } = await api.get('/auth/sso/providers');
return data;
},
});
const { data: profile } = useQuery({
queryKey: ['auth-profile'],
queryFn: async () => {
const { data } = await api.get('/auth/profile');
return data;
},
});
const unlinkMutation = useMutation({
mutationFn: (provider: string) => api.delete(`/auth/sso/unlink/${provider}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth-profile'] });
notifications.show({ message: 'Account unlinked', color: 'orange' });
},
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to unlink', color: 'red' }),
});
const noProviders = !providers?.google && !providers?.azure;
return (
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<div>
<Title order={4}>Linked Accounts</Title>
<Text size="sm" c="dimmed">Connect third-party accounts for single sign-on</Text>
</div>
</Group>
{noProviders && (
<Alert color="gray" variant="light" icon={<IconAlertCircle size={16} />}>
No SSO providers are configured. Contact your administrator to enable Google or Microsoft SSO.
</Alert>
)}
<Stack gap="md">
{providers?.google && (
<Group justify="space-between" p="sm" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
<Group>
<IconBrandGoogle size={24} color="#4285F4" />
<div>
<Text fw={500}>Google</Text>
<Text size="xs" c="dimmed">Sign in with your Google account</Text>
</div>
</Group>
<Button
variant="light"
size="sm"
leftSection={<IconLink size={14} />}
onClick={() => window.location.href = '/api/auth/google'}
>
Connect
</Button>
</Group>
)}
{providers?.azure && (
<Group justify="space-between" p="sm" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
<Group>
<IconBrandAzure size={24} color="#0078D4" />
<div>
<Text fw={500}>Microsoft</Text>
<Text size="xs" c="dimmed">Sign in with your Microsoft account</Text>
</div>
</Group>
<Button
variant="light"
size="sm"
leftSection={<IconLink size={14} />}
onClick={() => window.location.href = '/api/auth/azure'}
>
Connect
</Button>
</Group>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react';
import {
Card, Title, Text, Stack, Group, Button, TextInput,
PasswordInput, Alert, Code, SimpleGrid, Badge, Image,
} from '@mantine/core';
import { IconShieldCheck, IconShieldOff, IconAlertCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
export function MfaSettings() {
const queryClient = useQueryClient();
const [setupData, setSetupData] = useState<any>(null);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [verifyCode, setVerifyCode] = useState('');
const [disablePassword, setDisablePassword] = useState('');
const [showDisable, setShowDisable] = useState(false);
const { data: mfaStatus, isLoading } = useQuery({
queryKey: ['mfa-status'],
queryFn: async () => {
const { data } = await api.get('/auth/mfa/status');
return data;
},
});
const setupMutation = useMutation({
mutationFn: () => api.post('/auth/mfa/setup'),
onSuccess: ({ data }) => setSetupData(data),
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Setup failed', color: 'red' }),
});
const enableMutation = useMutation({
mutationFn: (token: string) => api.post('/auth/mfa/enable', { token }),
onSuccess: ({ data }) => {
setRecoveryCodes(data.recoveryCodes);
setSetupData(null);
setVerifyCode('');
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
notifications.show({ message: 'MFA enabled successfully', color: 'green' });
},
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid code', color: 'red' }),
});
const disableMutation = useMutation({
mutationFn: (password: string) => api.post('/auth/mfa/disable', { password }),
onSuccess: () => {
setShowDisable(false);
setDisablePassword('');
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
notifications.show({ message: 'MFA disabled', color: 'orange' });
},
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid password', color: 'red' }),
});
if (isLoading) return null;
return (
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<div>
<Title order={4}>Two-Factor Authentication (MFA)</Title>
<Text size="sm" c="dimmed">Add an extra layer of security to your account</Text>
</div>
<Badge color={mfaStatus?.enabled ? 'green' : 'gray'} variant="light" size="lg">
{mfaStatus?.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</Group>
{/* Recovery codes display (shown once after enable) */}
{recoveryCodes && (
<Alert color="orange" variant="light" mb="md" icon={<IconAlertCircle size={16} />} title="Save your recovery codes">
<Text size="sm" mb="sm">
These codes can be used to access your account if you lose your authenticator. Save them securely they will not be shown again.
</Text>
<SimpleGrid cols={2} spacing="xs">
{recoveryCodes.map((code, i) => (
<Code key={i} block>{code}</Code>
))}
</SimpleGrid>
<Button variant="subtle" size="xs" mt="sm" onClick={() => setRecoveryCodes(null)}>
I've saved my codes
</Button>
</Alert>
)}
{!mfaStatus?.enabled && !setupData && (
<Button
leftSection={<IconShieldCheck size={16} />}
onClick={() => setupMutation.mutate()}
loading={setupMutation.isPending}
>
Set Up MFA
</Button>
)}
{/* QR Code Setup */}
{setupData && (
<Stack>
<Text size="sm">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):</Text>
<Group justify="center">
<Image src={setupData.qrDataUrl} w={200} h={200} />
</Group>
<Text size="xs" c="dimmed" ta="center">
Manual entry key: <Code>{setupData.secret}</Code>
</Text>
<TextInput
label="Verification Code"
placeholder="Enter 6-digit code"
value={verifyCode}
onChange={(e) => setVerifyCode(e.currentTarget.value)}
maxLength={6}
/>
<Group>
<Button
onClick={() => enableMutation.mutate(verifyCode)}
loading={enableMutation.isPending}
disabled={verifyCode.length < 6}
>
Verify & Enable
</Button>
<Button variant="subtle" onClick={() => setSetupData(null)}>Cancel</Button>
</Group>
</Stack>
)}
{/* Disable MFA */}
{mfaStatus?.enabled && !showDisable && (
<Button
variant="subtle"
color="red"
leftSection={<IconShieldOff size={16} />}
onClick={() => setShowDisable(true)}
>
Disable MFA
</Button>
)}
{showDisable && (
<Stack mt="md">
<Alert color="red" variant="light">
Disabling MFA will make your account less secure. Enter your password to confirm.
</Alert>
<PasswordInput
label="Current Password"
value={disablePassword}
onChange={(e) => setDisablePassword(e.currentTarget.value)}
/>
<Group>
<Button color="red" onClick={() => disableMutation.mutate(disablePassword)} loading={disableMutation.isPending}>
Disable MFA
</Button>
<Button variant="subtle" onClick={() => setShowDisable(false)}>Cancel</Button>
</Group>
</Stack>
)}
</Card>
);
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react';
import {
Card, Title, Text, Stack, Group, Button, TextInput,
Table, Badge, ActionIcon, Tooltip, Alert,
} from '@mantine/core';
import { IconFingerprint, IconTrash, IconPlus, IconAlertCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { startRegistration } from '@simplewebauthn/browser';
import api from '../../services/api';
export function PasskeySettings() {
const queryClient = useQueryClient();
const [deviceName, setDeviceName] = useState('');
const [registering, setRegistering] = useState(false);
const { data: passkeys = [], isLoading } = useQuery({
queryKey: ['passkeys'],
queryFn: async () => {
const { data } = await api.get('/auth/passkeys');
return data;
},
});
const removeMutation = useMutation({
mutationFn: (id: string) => api.delete(`/auth/passkeys/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
notifications.show({ message: 'Passkey removed', color: 'orange' });
},
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to remove', color: 'red' }),
});
const handleRegister = async () => {
setRegistering(true);
try {
// 1. Get registration options from server
const { data: options } = await api.post('/auth/passkeys/register-options');
// 2. Create credential via browser WebAuthn API
const credential = await startRegistration({ optionsJSON: options });
// 3. Send attestation to server for verification
await api.post('/auth/passkeys/register', {
response: credential,
deviceName: deviceName || 'My Passkey',
});
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setDeviceName('');
notifications.show({ message: 'Passkey registered successfully', color: 'green' });
} catch (err: any) {
if (err.name === 'NotAllowedError') {
notifications.show({ message: 'Registration was cancelled', color: 'yellow' });
} else {
notifications.show({ message: err.response?.data?.message || err.message || 'Registration failed', color: 'red' });
}
} finally {
setRegistering(false);
}
};
const webauthnSupported = typeof window !== 'undefined' && !!window.PublicKeyCredential;
return (
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<div>
<Title order={4}>Passkeys</Title>
<Text size="sm" c="dimmed">Sign in with your fingerprint, face, or security key</Text>
</div>
<Badge color={passkeys.length > 0 ? 'green' : 'gray'} variant="light" size="lg">
{passkeys.length} registered
</Badge>
</Group>
{!webauthnSupported && (
<Alert color="yellow" variant="light" icon={<IconAlertCircle size={16} />} mb="md">
Your browser doesn't support WebAuthn passkeys.
</Alert>
)}
{passkeys.length > 0 && (
<Table striped mb="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Device</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Last Used</Table.Th>
<Table.Th w={60}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{passkeys.map((pk: any) => (
<Table.Tr key={pk.id}>
<Table.Td>
<Group gap="xs">
<IconFingerprint size={16} />
<Text size="sm" fw={500}>{pk.device_name || 'Passkey'}</Text>
</Group>
</Table.Td>
<Table.Td><Text size="sm">{new Date(pk.created_at).toLocaleDateString()}</Text></Table.Td>
<Table.Td>
<Text size="sm" c={pk.last_used_at ? undefined : 'dimmed'}>
{pk.last_used_at ? new Date(pk.last_used_at).toLocaleDateString() : 'Never'}
</Text>
</Table.Td>
<Table.Td>
<Tooltip label="Remove">
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(pk.id)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{webauthnSupported && (
<Group>
<TextInput
placeholder="Device name (e.g., MacBook Pro)"
value={deviceName}
onChange={(e) => setDeviceName(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Button
leftSection={<IconPlus size={16} />}
onClick={handleRegister}
loading={registering}
>
Register Passkey
</Button>
</Group>
)}
</Card>
);
}

View File

@@ -1,14 +1,92 @@
import { useState, useEffect } from 'react';
import {
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
Tabs, Button, Switch, Loader,
} from '@mantine/core';
import {
IconBuilding, IconUser, IconUsers, IconSettings, IconShieldLock,
IconCalendar,
IconBuilding, IconUser, IconSettings, IconShieldLock,
IconFingerprint, IconLink, IconLogout, IconCreditCard,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { MfaSettings } from './MfaSettings';
import { PasskeySettings } from './PasskeySettings';
import { LinkedAccounts } from './LinkedAccounts';
import api from '../../services/api';
interface SubscriptionInfo {
plan: string;
planName: string;
billingInterval: string;
status: string;
collectionMethod: string;
trialEndsAt: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeCustomer: boolean;
}
const statusColors: Record<string, string> = {
active: 'green',
trial: 'blue',
past_due: 'orange',
archived: 'red',
suspended: 'red',
};
export function SettingsPage() {
const { user, currentOrg } = useAuthStore();
const { compactView, toggleCompactView } = usePreferencesStore();
const [loggingOutAll, setLoggingOutAll] = useState(false);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
const [subLoading, setSubLoading] = useState(true);
const [portalLoading, setPortalLoading] = useState(false);
useEffect(() => {
api.get('/billing/subscription')
.then(({ data }) => setSubscription(data))
.catch(() => { /* billing not configured or no subscription */ })
.finally(() => setSubLoading(false));
}, []);
const handleLogoutEverywhere = async () => {
setLoggingOutAll(true);
try {
await api.post('/auth/logout-everywhere');
notifications.show({ message: 'All other sessions have been logged out', color: 'green' });
} catch {
notifications.show({ message: 'Failed to log out other sessions', color: 'red' });
} finally {
setLoggingOutAll(false);
}
};
const handleManageBilling = async () => {
setPortalLoading(true);
try {
const { data } = await api.post('/billing/portal');
if (data.url) {
window.location.href = data.url;
}
} catch (err: any) {
const msg = err.response?.data?.message || 'Unable to open billing portal';
notifications.show({ message: typeof msg === 'string' ? msg : 'Unable to open billing portal', color: 'red' });
} finally {
setPortalLoading(false);
}
};
const formatInterval = (interval: string) => {
return interval === 'year' ? 'Annual' : 'Monthly';
};
const formatDate = (iso: string | null) => {
if (!iso) return null;
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
});
};
return (
<Stack>
@@ -38,13 +116,82 @@ export function SettingsPage() {
<Text size="sm" c="dimmed">Your Role</Text>
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Schema</Text>
<Text size="sm" ff="monospace" c="dimmed">{currentOrg?.schemaName || 'N/A'}</Text>
</Group>
</Stack>
</Card>
{/* Billing / Subscription */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="teal" variant="light" size={40} radius="md">
<IconCreditCard size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Billing</Text>
<Text c="dimmed" size="sm">Subscription and payment</Text>
</div>
</Group>
{subLoading ? (
<Group justify="center" py="md"><Loader size="sm" /></Group>
) : subscription ? (
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Plan</Text>
<Group gap={4}>
<Badge variant="light">{subscription.planName}</Badge>
<Badge variant="light" color="gray" size="sm">{formatInterval(subscription.billingInterval)}</Badge>
</Group>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Status</Text>
<Badge
color={statusColors[subscription.status] || 'gray'}
variant="light"
>
{subscription.status === 'past_due' ? 'Past Due' : subscription.status}
{subscription.cancelAtPeriodEnd ? ' (Canceling)' : ''}
</Badge>
</Group>
{subscription.trialEndsAt && subscription.status === 'trial' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Trial Ends</Text>
<Text size="sm" fw={500}>{formatDate(subscription.trialEndsAt)}</Text>
</Group>
)}
{subscription.currentPeriodEnd && subscription.status !== 'trial' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Current Period Ends</Text>
<Text size="sm" fw={500}>{formatDate(subscription.currentPeriodEnd)}</Text>
</Group>
)}
{subscription.collectionMethod === 'send_invoice' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Payment</Text>
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
</Group>
)}
{subscription.hasStripeCustomer ? (
<Button
variant="light"
color="teal"
size="sm"
leftSection={<IconCreditCard size={16} />}
onClick={handleManageBilling}
loading={portalLoading}
mt="xs"
>
Manage Billing
</Button>
) : subscription.status === 'trial' ? (
<Text size="xs" c="dimmed" mt="xs">
Billing portal will be available once you add a payment method.
</Text>
) : null}
</Stack>
) : (
<Text size="sm" c="dimmed">No active subscription</Text>
)}
</Card>
{/* User Profile */}
<Card withBorder padding="lg">
<Group mb="md">
@@ -72,33 +219,6 @@ export function SettingsPage() {
</Stack>
</Card>
{/* Security */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="red" variant="light" size={40} radius="md">
<IconShieldLock size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Security</Text>
<Text c="dimmed" size="sm">Authentication and access</Text>
</div>
</Group>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Authentication</Text>
<Badge color="green" variant="light">Active Session</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Two-Factor Auth</Text>
<Badge color="gray" variant="light">Not Configured</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">OAuth Providers</Text>
<Badge color="gray" variant="light">None Linked</Badge>
</Group>
</Stack>
</Card>
{/* System Info */}
<Card withBorder padding="lg">
<Group mb="md">
@@ -117,15 +237,87 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.3.7 (Beta)</Badge>
<Badge variant="light">2026.03.18</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>
<Text size="sm" ff="monospace" c="dimmed">/api/docs</Text>
</Group>
<Divider />
<Group justify="space-between">
<div>
<Text size="sm">Compact View</Text>
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
</div>
<Switch checked={compactView} onChange={toggleCompactView} />
</Group>
</Stack>
</Card>
{/* Sessions */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="orange" variant="light" size={40} radius="md">
<IconLogout size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Sessions</Text>
<Text c="dimmed" size="sm">Manage active sessions</Text>
</div>
</Group>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Current Session</Text>
<Badge color="green" variant="light">Active</Badge>
</Group>
<Button
variant="light"
color="orange"
size="sm"
leftSection={<IconLogout size={16} />}
onClick={handleLogoutEverywhere}
loading={loggingOutAll}
mt="xs"
>
Log Out All Other Sessions
</Button>
</Stack>
</Card>
</SimpleGrid>
<Divider my="md" />
{/* Security Settings */}
<div>
<Title order={3} mb="sm">Security</Title>
<Text c="dimmed" size="sm" mb="md">Manage authentication methods and security settings</Text>
</div>
<Tabs defaultValue="mfa">
<Tabs.List>
<Tabs.Tab value="mfa" leftSection={<IconShieldLock size={16} />}>
Two-Factor Auth
</Tabs.Tab>
<Tabs.Tab value="passkeys" leftSection={<IconFingerprint size={16} />}>
Passkeys
</Tabs.Tab>
<Tabs.Tab value="linked" leftSection={<IconLink size={16} />}>
Linked Accounts
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="mfa" pt="md">
<MfaSettings />
</Tabs.Panel>
<Tabs.Panel value="passkeys" pt="md">
<PasskeySettings />
</Tabs.Panel>
<Tabs.Panel value="linked" pt="md">
<LinkedAccounts />
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

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

View File

@@ -1,9 +1,10 @@
import axios from 'axios';
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { useAuthStore } from '../stores/authStore';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // Send httpOnly cookies for refresh token
});
api.interceptors.request.use((config) => {
@@ -14,23 +15,89 @@ api.interceptors.request.use((config) => {
return config;
});
// ─── Silent Refresh Logic ─────────────────────────────────────────
let isRefreshing = false;
let pendingQueue: Array<{
resolve: (token: string) => void;
reject: (err: any) => void;
}> = [];
function processPendingQueue(error: any, token: string | null) {
pendingQueue.forEach((p) => {
if (error) {
p.reject(error);
} else {
p.resolve(token!);
}
});
pendingQueue = [];
}
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// If 401 and we haven't retried yet, try refreshing the token
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh') &&
!originalRequest.url?.includes('/auth/login')
) {
originalRequest._retry = true;
if (isRefreshing) {
// Another request is already refreshing — queue this one
return new Promise((resolve, reject) => {
pendingQueue.push({
resolve: (token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
},
reject: (err: any) => reject(err),
});
});
}
isRefreshing = true;
try {
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
const newToken = data.accessToken;
useAuthStore.getState().setToken(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
processPendingQueue(null, newToken);
return api(originalRequest);
} catch (refreshError) {
processPendingQueue(refreshError, null);
useAuthStore.getState().logout();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// Non-retryable 401 (e.g. refresh failed, login failed)
if (error.response?.status === 401 && originalRequest?.url?.includes('/auth/refresh')) {
useAuthStore.getState().logout();
window.location.href = '/login';
}
// Handle org suspended/archived — redirect to org selection
const responseData = error.response?.data as any;
if (
error.response?.status === 403 &&
typeof error.response?.data?.message === 'string' &&
error.response.data.message.includes('has been')
typeof responseData?.message === 'string' &&
responseData.message.includes('has been')
) {
const store = useAuthStore.getState();
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
window.location.href = '/select-org';
}
return Promise.reject(error);
},
);

View File

@@ -5,8 +5,8 @@ interface Organization {
id: string;
name: string;
role: string;
schemaName?: string;
status?: string;
planLevel?: string;
settings?: Record<string, any>;
}
@@ -34,6 +34,7 @@ interface AuthState {
currentOrg: Organization | null;
impersonationOriginal: ImpersonationOriginal | null;
setAuth: (token: string, user: User, organizations: Organization[]) => void;
setToken: (token: string) => void;
setCurrentOrg: (org: Organization, token?: string) => void;
setUserIntroSeen: () => void;
setOrgSettings: (settings: Record<string, any>) => void;
@@ -61,6 +62,7 @@ export const useAuthStore = create<AuthState>()(
// Don't auto-select org — force user through SelectOrgPage
currentOrg: null,
}),
setToken: (token) => set({ token }),
setCurrentOrg: (org, token) =>
set((state) => ({
currentOrg: org,
@@ -103,14 +105,17 @@ export const useAuthStore = create<AuthState>()(
});
}
},
logout: () =>
logout: () => {
// Fire-and-forget server-side logout to revoke refresh token cookie
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
set({
token: null,
user: null,
organizations: [],
currentOrg: null,
impersonationOriginal: null,
}),
});
},
}),
{
name: 'ledgeriq-auth',

View File

@@ -5,19 +5,26 @@ type ColorScheme = 'light' | 'dark';
interface PreferencesState {
colorScheme: ColorScheme;
compactView: boolean;
toggleColorScheme: () => void;
setColorScheme: (scheme: ColorScheme) => void;
toggleCompactView: () => void;
setCompactView: (compact: boolean) => void;
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
colorScheme: 'light',
compactView: false,
toggleColorScheme: () =>
set((state) => ({
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
})),
setColorScheme: (scheme) => set({ colorScheme: scheme }),
toggleCompactView: () =>
set((state) => ({ compactView: !state.compactView })),
setCompactView: (compact) => set({ compactView: compact }),
}),
{
name: 'ledgeriq-preferences',

View File

@@ -1,10 +1,57 @@
import { createTheme } from '@mantine/core';
export const theme = createTheme({
const baseFontFamily = '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif';
export const defaultTheme = createTheme({
primaryColor: 'blue',
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
fontFamily: baseFontFamily,
headings: {
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
fontFamily: baseFontFamily,
},
defaultRadius: 'md',
});
export const compactTheme = createTheme({
primaryColor: 'blue',
fontFamily: baseFontFamily,
headings: {
fontFamily: baseFontFamily,
},
defaultRadius: 'md',
spacing: {
xs: '4px',
sm: '6px',
md: '10px',
lg: '12px',
xl: '16px',
},
fontSizes: {
xs: '11px',
sm: '12px',
md: '13px',
lg: '15px',
xl: '18px',
},
components: {
Table: {
defaultProps: {
verticalSpacing: 'xs',
horizontalSpacing: 'xs',
fz: 'sm',
},
},
Card: {
defaultProps: {
padding: 'sm',
},
},
AppShell: {
defaultProps: {
padding: 'xs',
},
},
},
});
/** @deprecated Use `defaultTheme` or `compactTheme` instead */
export const theme = defaultTheme;

View File

@@ -12,6 +12,9 @@
#
# Replace "app.yourdomain.com" with your actual hostname throughout this file.
# Hide nginx version from Server header
server_tokens off;
# --- Rate limiting ---
# 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
@@ -49,6 +52,12 @@ server {
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Security headers — applied to all routes
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# --- Proxy defaults ---
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

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

View File

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

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