93 Commits

Author SHA1 Message Date
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
b0282b7f8b fix: show P&L debit/credit totals on journal entries list
The previous aggregation used simple SUM(debit)/SUM(credit) which
always produced equal values for balanced entries. This was misleading
for entries with income/expense lines (e.g., monthly actuals).

Now, when an entry has income/expense lines, the totals reflect only
P&L account activity (expenses as debits, income as credits), excluding
the cash offset. For balance-sheet-only entries (opening balances,
adjustments), the full entry totals are shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:41:26 -04:00
ac72905ecb fix: add total_debit/total_credit aggregations to journal entries list
The findAll query was missing SUM aggregations, so the frontend received
no total_debit/total_credit fields and fell back to displaying $0.00.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:17: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
3bf6b8c6c9 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-08 19:49:23 -04:00
4759374883 feat: add dark mode with persistent user preference
Add dark mode support using Mantine's built-in color scheme system,
persisted via a new Zustand preferences store. Includes a quick toggle
in the app header and an enabled switch in User Preferences. Also
removes the "AI Health Scores" title from the dashboard to reclaim
vertical space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:36:11 -04:00
cb6e34d5ce feat: add password reset utility script
Usage: ./scripts/reset-password.sh <email> <new-password>
Generates bcrypt hash via bcryptjs in the backend container,
updates the database, and verifies the hash matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:19:22 -05:00
2b72951e66 chore: bump version to 2026.3.7 (Beta)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
69dad7cc74 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 12:01:57 -05:00
efa5aca35f 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-07 12:01:57 -05: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
c429dcc033 Merge pull request 'fix: improve AI health score accuracy and consistency' (#1) from ai-improvements into main
Reviewed-on: #1
2026-03-06 14:44:39 -05:00
9146118df1 feat: async AI calls, 10-min timeout, and failure messaging
- Make all AI endpoints (health scores + investment recommendations)
  fire-and-forget: POST returns immediately, frontend polls for results
- Extend AI API timeout from 2-5 min to 10 min for both services
- Add "last analysis failed — showing cached data" message to the
  Investment Recommendations panel (matches health score widgets)
- Add status/error_message columns to ai_recommendations table
- Remove nginx AI timeout overrides (no longer needed)
- Users can now navigate away during AI processing without interruption

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:42:53 -05:00
07d15001ae fix: improve AI health score accuracy and consistency
Address 4 issues identified in AI feature audit:

1. Reduce temperature from 0.3 to 0.1 for health score calculations
   to reduce 16-40 point score volatility across runs

2. Add explicit cash runway classification rules to operating prompt
   preventing the model from rating sub-3-month runway as "positive"

3. Pre-compute total special assessment income in both operating and
   reserve prompts, eliminating per-unit vs total confusion ($300
   vs $20,100)

4. Make YTD budget comparison actuals-aware: only compare months with
   posted journal entries, show current month budget separately, and
   add prompt guidance about month-end posting cadence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:44:12 -05:00
a0b366e94a fix: resolve critical SQL and display bugs across 5 financial reports
- Fix systemic LEFT JOIN date filter bug in Balance Sheet, Income Statement,
  and Cash Flow Statement by using parenthesized INNER JOIN pattern so
  SUM(jel.debit/credit) respects date parameters
- Add Current Year Net Income synthetic equity line to Balance Sheet to
  satisfy the accounting equation (A = L + E) during open fiscal periods
- Add investment_accounts balances to Balance Sheet assets and corresponding
  equity lines for reserve/operating investment holdings
- Fix Cash Flow Statement beginning/ending cash always showing $0 by
  replacing LIKE '%Cash%' filter with account_type = 'asset'
- Fix Year-End Package HTTP 500 by replacing broken invoices.vendor_id
  query with journal-entry-based vendor payment lookup
- Fix Quarterly Report defaulting to previous quarter instead of current
- Fix Quarterly Report date subtitle off-by-one day from UTC parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:15:01 -05:00
3790a3bd9e docs: add scaling guide for production infrastructure
Covers vertical tuning, managed service offloading, horizontal scaling
with replicas, and multi-node strategies. Includes resource budgets for
the current 4-core/24GB VM, monitoring thresholds for New Relic alerts,
PostgreSQL/Redis tuning values, and a scaling decision tree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:06:22 -05:00
0a07c61ca3 perf: remove unnecessary postgres/redis host port mappings in production
Backend reaches postgres and redis over the Docker network (hoanet),
so host port mappings are unnecessary. Removing them eliminates 4
docker-proxy processes and closes 0.0.0.0:5432 and 0.0.0.0:6379
which were publicly reachable — a security and performance fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:52:09 -05:00
337b6061b2 feat: reliability enhancements for AI services and capital planning
1. Health Scores — separate operating/reserve refresh
   - Added POST /health-scores/calculate/operating and /calculate/reserve
   - Each health card now has its own Refresh button
   - On failure, shows cached (last good) data with "last analysis failed"
     watermark instead of blank "Error calculating score"
   - Backend getLatestScores returns latest complete score + failure flag

2. Investment Planning — increased AI timeout to 5 minutes
   - Backend callAI timeout: 180s → 300s
   - Frontend axios timeout: set explicitly to 300s (was browser default)
   - Host nginx proxy_read_timeout: 180s → 300s
   - Loading message updated to reflect longer wait times

3. Capital Planning — Unscheduled column moved to rightmost position
   - Kanban column order: current year → future → unscheduled (was leftmost)
   - Puts immediate/near-term projects front and center

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 12:02:30 -05:00
467fdd2a6c fix: auto-detect system Chromium for puppeteer on Linux servers
Puppeteer's bundled Chrome often fails on Linux servers due to
architecture mismatches. Now auto-detects system-installed Chromium
at common paths (/usr/bin/chromium-browser, /usr/bin/chromium), or
honors PUPPETEER_EXECUTABLE_PATH env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:05:53 -05:00
c12ad94b7f fix: rewrite Bankrate scraper to extract actual bank names from offer cards
The previous scraper was picking up Bankrate's summary table
(.wealth-product-rate-list) which only has "best rates" per term with
no bank names, resulting in entries like "Top CD Rate - 1 year".

Now targets the actual bank offer cards in .wrt-RateSections-sponsoredoffers
and .wrt-RateSections-additionaloffers sections. Key changes:

- Extract bank names from img[alt] (logo) with text-based fallbacks
- Fix APY parsing to avoid Bankrate score leaking in (e.g. "4.5" score
  concatenated with "4.00%" APY was parsed as 0.4%)
- Handle both "Min. deposit" (CDs) and "Min. balance for APY" (savings/MM)
- Parse abbreviated terms from Bankrate (e.g. "1yr", "14mo")
- Strip product suffixes from bank names (e.g. "Synchrony Bank CD" → "Synchrony Bank")
- Filter out entries that aren't real banks (terms, dollar amounts)
- Keep a fallback strategy for future Bankrate layout changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:44:58 -05:00
05e241c792 fix: allow null planned_date when updating projects
Empty string date values from the frontend were being passed directly
to PostgreSQL, which cannot cast "" to DATE. Normalize empty strings
to null for all date columns in the update method and the dedicated
updatePlannedDate endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:05:29 -05:00
5ee4c71fc1 chore: update package-lock.json with newrelic dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:54:12 -05:00
81908e48ea feat: add New Relic APM instrumentation to backend
Add Node.js New Relic agent with an on/off switch via NEW_RELIC_ENABLED
in .env. Uses a preload script (-r newrelic-preload.js) so the agent
loads before all other modules. Configured entirely through environment
variables — no newrelic.js config file needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:31:56 -05:00
6230558b91 fix: remove backend/frontend host port mappings from base compose
Docker Compose merges port arrays from base + override files, so the
base 3000:3000 and the prod overlay 127.0.0.1:3000:3000 conflicted.
Removed direct host port mappings from the base — dev traffic already
routes through the Docker nginx on port 80 via the bridge network.
The prod overlay cleanly adds loopback-only mappings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:14:24 -05:00
2c215353d4 refactor: remove Docker nginx from production, use host nginx directly
The production stack no longer runs a Docker nginx container. Instead,
the host-level nginx handles SSL termination AND request routing:
  /api/* → 127.0.0.1:3000 (backend)
  /*     → 127.0.0.1:3001 (frontend)

Changes:
- docker-compose.prod.yml: set nginx replicas to 0, expose backend and
  frontend on 127.0.0.1 only (loopback)
- nginx/host-production.conf: new ready-to-copy host nginx config with
  SSL, rate limiting, proxy buffering, and AI endpoint timeouts
- docs/DEPLOYMENT.md: rewritten production deployment and SSL sections
  to reflect the simplified single-nginx architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:08:32 -05:00
d526025926 fix: map Docker nginx to port 8080 to avoid conflict with host reverse proxy
The base docker-compose.yml maps nginx to 80:80, which conflicts with
the host-level nginx that handles SSL termination on production servers.
The production overlay now explicitly maps to 8080:80 so the host proxy
can forward to localhost:8080. Updated DEPLOYMENT.md with host reverse
proxy setup instructions and corrected architecture diagrams.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:59:24 -05:00
411239bea4 Prod infra: frontend on port 3001, remove certbot from compose
- Frontend container nginx listens on 3001 instead of 80 to avoid
  conflicts with the host-level reverse proxy
- Removed certbot service, volumes, and SSL config from
  docker-compose.prod.yml — SSL/certbot is managed at the host level
- Updated nginx/production.conf: HTTP-only (host handles TLS),
  upstream frontend points to port 3001
- Updated nginx/ssl.conf frontend upstream to 3001 for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:43:14 -05:00
7e6c4c16ce Update backend/src/main.ts 2026-03-02 17:56:01 -05:00
ea0e3d6f29 Fix TS error: guard null target_year in KanbanCard comparison
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:00:55 -05:00
8db89373e0 Add production infrastructure: compiled builds, clustering, connection pooling
Root cause of 502 errors under 30 concurrent users: the production server
was running dev-mode infrastructure (Vite dev server, NestJS --watch,
no DB connection pooling, single Node.js process).

Changes:
- backend/Dockerfile: multi-stage prod build (compiled JS, no devDeps)
- frontend/Dockerfile: multi-stage prod build (static assets served by nginx)
- frontend/nginx.conf: SPA routing config for frontend container
- docker-compose.prod.yml: production overlay with tuned Postgres, memory
  limits, health checks, restart policies
- nginx/production.conf: keepalive upstreams, proxy buffering, rate limiting
- backend/src/main.ts: Node.js clustering (1 worker per CPU, up to 4),
  conditional request logging, production CORS
- backend/src/app.module.ts: TypeORM connection pool (max 30, min 5)
- docs/DEPLOYMENT.md: new Production Deployment section

Deploy with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:55:30 -05:00
e719f593de Update frontend/vite.config.ts
Adding ssl hostname
2026-03-02 15:13:36 -05:00
16adfd6f26 Fix: add react-joyride to frontend dependencies
The package was imported by AppTour.tsx but missing from package.json,
causing a build failure on fresh installs / production deploys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:06:27 -05:00
704f29362a Add database backup/restore script with auto-pruning
scripts/db-backup.sh provides three commands:
- backup: creates a timestamped, gzipped pg_dump (custom format) in ./backups/
- restore: drops, recreates, and loads a backup with confirmation prompt
- list: shows available backups with sizes and dates

Supports --keep N flag for automatic pruning of backups older than N days,
making it cron-friendly for daily automated backups. Also adds backups/
and *.dump.gz to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:40:51 -05:00
42767e3119 Add SSL/TLS support with Certbot and update deployment guide
- nginx/ssl.conf: full HTTPS config with HTTP→HTTPS redirect, modern TLS
  settings, HSTS header, and ACME challenge passthrough for renewals
- nginx/certbot-init.conf: minimal HTTP config for initial cert provisioning
- docker-compose.ssl.yml: compose override adding port 443, certbot volumes,
  and auto-renewal sidecar container
- docs/DEPLOYMENT.md: comprehensive 3-phase SSL walkthrough (obtain cert,
  enable SSL, auto-renewal) with day-to-day usage and revert instructions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:28:01 -05:00
a550a8d0be Add deployment guide for staging Docker servers with DB backup/restore
Covers fresh server setup, environment configuration, database backup
(full and per-tenant), restore into staged environment, migration
execution, and verification steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:09:32 -05:00
063741adc7 Capital Planning: add Unscheduled bucket for imported projects without target_year
Projects imported via CSV that lack a target_year were invisible in Capital
Planning because findForPlanning() filtered on target_year IS NOT NULL. This
removes that filter and adds an "Unscheduled" Kanban column (orange background,
2-col layout) so users can drag unscheduled projects into year buckets.
Also bumps app version to 2026.3.2 (beta).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:03:39 -05:00
ad2f16d93b Capital Planning: show beyond-window projects in Future bucket, 2-col layout
Projects with target years beyond the 5-year planning window now
appear in the Future column of the Kanban board (previously they
were invisible). Cards for these projects show their specific target
year as a badge. The Future column uses a 2-column grid layout when
it has more than 3 projects to maximize screen utilization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:16:20 -05:00
b0b36df4e4 Reserve health: add projected cash flow with special assessments; add Last Updated to cards
Reserve fund analysis now includes 12-month forward projection with
special assessment income (by frequency), monthly budget data,
capital project costs, and investment maturities. AI prompt updated
to evaluate projected reserve liquidity and timing risks.

Both health score dashboard cards now show a subtle "Last updated"
timestamp at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:18:34 -05:00
aa7f2dab32 Add 12-month projected cash flow to operating health score analysis
The operating health score now includes forward-looking cash flow
projections using monthly budget data, assessment income schedules,
and operating project costs. AI prompt updated to evaluate projected
liquidity, timing risks, and year-end cash position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:10:51 -05:00
d2d553eed6 Fix health scores: use correct invoices column name (amount, not amount_due)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:00:23 -05:00
2ca277b6e6 Phase 8: AI-driven operating and reserve fund health scores
Add daily AI health score calculation (0-100) for both operating and
reserve funds. Scores include trajectory tracking, factor analysis,
recommendations, and data readiness checks. Dashboard displays
graphical RingProgress gauges with color-coded scores, trend
indicators, and expandable detail popovers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:56:56 -05:00
bfcbe086f2 Fix WriteAccessGuard: use req.userRole from middleware (runs before guards)
The global WriteAccessGuard was checking req.user.role, but req.user is
set by JwtAuthGuard (a per-controller guard) which runs AFTER global guards.
TenantMiddleware sets req.userRole from the JWT before guards execute,
so we now check that property first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:21:09 -05:00
c92eb1b57b RBAC: Enforce read-only viewer role across backend and frontend
- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role
- Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations)
- Add useIsReadOnly hook to auth store for frontend role checks
- Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers
- Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers
- Skip onboarding wizard for viewer role users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:18:32 -05:00
07347a644f QoL tweaks: Cash Flow cards, auto-primary accounts, investment projections, Sankey filters
- Dashboard: Remove tenant name/role subtitle
- Cash Flow: Replace Operating/Reserve net cards with inflow vs outflow
  breakdown showing In/Out amounts and signed net; replace Ending Cash
  card with AI Financial Health status from saved recommendation
- Accounts: Auto-set first asset account per fund_type as primary on creation
- Investments: Add 5th summary card for projected annual interest earnings
- Sankey: Add Actuals/Budget/Forecast data source toggle and
  All Funds/Operating/Reserve fund filter SegmentedControls with
  backend support for budget-based and forecast (actuals+budget) queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:22:37 -05:00
f1e66966f3 Phase 7: Add user onboarding tour and tenant setup wizard
Feature 1 - How-To Intro Tour (react-joyride):
- 8-step guided walkthrough highlighting Dashboard, Accounts, Assessments,
  Transactions, Budgets, Reports, and AI Investment Planning
- Runs automatically on first login, tracked via has_seen_intro flag on user
- Centralized step config in config/tourSteps.ts for easy text editing
- data-tour attributes on Sidebar nav items and Dashboard for targeting

Feature 2 - Tenant Onboarding Wizard:
- 3-step modal wizard: create operating account, assessment group + units,
  import budget CSV
- Runs after tour completes, tracked via onboardingComplete in org settings JSONB
- Reuses existing API endpoints (POST /accounts, /assessment-groups, /units,
  /budgets/:year/import)

Backend changes:
- Add has_seen_intro column to shared.users + migration
- Add PATCH /auth/intro-seen endpoint to mark tour complete
- Add PATCH /organizations/settings endpoint for org settings updates
- Include hasSeenIntro in login response, settings in switch-org response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:47:45 -05:00
d1c40c633f Fix dashboard KPIs: resolve nested aggregate and missing column errors
- Wrap account interest query in subquery to avoid SUM(SUM(...)) nesting
- Replace nonexistent interest_earned column with current_value - principal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:11:37 -05:00
0e82e238c1 Bug & tweak sprint: fix financial calculations, add quarterly report, enhance dashboard
- Fix Accounts page: include investment accounts in Est. Monthly Interest calc,
  add Fund column to investment table, split summary cards into Operating/Reserve
- Fix Cash Flow: ending balance now respects includeInvestments toggle
- Fix Budget Manager: separate operating/reserve income in summary cards
- Fix Projects: default sort by planned_date instead of name
- Add Vendors: last_negotiated date field with migration, CSV import/export
- New Quarterly Financial Report: budget vs actuals, over-budget flagging, YTD
- Enhance Dashboard: separate Operating/Reserve fund cards, expanded Quick Stats
  with monthly interest, YTD interest earned, planned capital spend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:17:30 -05:00
2fed5d6ce1 Phase 6: Expand market rates and enhance AI investment recommendations
- Rate fetcher now scrapes CD, Money Market, and High Yield Savings rates
  from Bankrate.com with pauses between fetches to avoid rate limiting
- Historical rate data is preserved (no longer deleted on each fetch)
- Database migration adds rate_type column and tenant ai_recommendations table
- Backend returns market rates grouped by type with latest-batch-only queries
- AI prompt now includes all three rate types for comprehensive analysis
- AI recommendations are saved per-tenant for retrieval on page load
- Frontend: "Market CD Rates" replaced with "Today's Market Rates" tabbed view
- Rates section is collapsible (expanded by default) to save screen space
- Saved recommendations load automatically with "Last Updated" timestamp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:39:53 -05:00
146 changed files with 23189 additions and 1409 deletions

View File

@@ -12,3 +12,8 @@ AI_API_KEY=your_nvidia_api_key_here
AI_MODEL=qwen/qwen3.5-397b-a17b AI_MODEL=qwen/qwen3.5-397b-a17b
# Set to 'true' to enable detailed AI prompt/response logging # Set to 'true' to enable detailed AI prompt/response logging
AI_DEBUG=false AI_DEBUG=false
# New Relic APM — set ENABLED=true and provide your license key to activate
NEW_RELIC_ENABLED=false
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here
NEW_RELIC_APP_NAME=HOALedgerIQ_App

5
.gitignore vendored
View File

@@ -24,6 +24,11 @@ postgres_data/
redis_data/ redis_data/
pgdata/ pgdata/
# Database backups
backups/
*.dump
*.dump.gz
# SSL # SSL
letsencrypt/ letsencrypt/

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

@@ -0,0 +1,576 @@
# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide
> **Version:** 2026.03.17
> **Last updated:** March 17, 2026
> **Migration:** `db/migrations/015-saas-onboarding-auth.sql`
---
## Table of Contents
1. [High-Level Flow](#1-high-level-flow)
2. [Stripe Billing & Checkout](#2-stripe-billing--checkout)
3. [Provisioning Pipeline](#3-provisioning-pipeline)
4. [Account Activation (Magic Link)](#4-account-activation-magic-link)
5. [Guided Onboarding Checklist](#5-guided-onboarding-checklist)
6. [Authentication & Sessions](#6-authentication--sessions)
7. [Multi-Factor Authentication (TOTP)](#7-multi-factor-authentication-totp)
8. [Single Sign-On (SSO)](#8-single-sign-on-sso)
9. [Passkeys (WebAuthn)](#9-passkeys-webauthn)
10. [Environment Variables Reference](#10-environment-variables-reference)
11. [Manual Intervention & Ops Tasks](#11-manual-intervention--ops-tasks)
12. [What's Stubbed vs. Production-Ready](#12-whats-stubbed-vs-production-ready)
13. [API Endpoint Reference](#13-api-endpoint-reference)
---
## 1. High-Level Flow
```
Visitor hits /pricing
|
v
Selects a plan (Starter $29 / Professional $79 / Enterprise $199)
|
v
POST /api/billing/create-checkout-session
|
v
Redirect to Stripe Checkout (hosted by Stripe)
|
v
Stripe fires `checkout.session.completed` webhook
|
v
Backend provisions: org -> schema -> user -> invite token -> "email"
|
v
Frontend polls GET /api/billing/status?session_id=xxx
| (OnboardingPendingPage polls every 3s)
v
Status returns "active" -> user is redirected to /login
|
v
User clicks activation link from "email" (logged to console + DB)
|
v
GET /activate?token=xxx -> validates token
POST /activate -> sets password + name, issues session
|
v
Redirect to /onboarding (4-step guided wizard)
|
v
Dashboard
```
---
## 2. Stripe Billing & Checkout
### Plans
| Plan ID | Name | Price | Unit Limit |
|---------------|--------------|---------|------------|
| `starter` | Starter | $29/mo | 50 units |
| `professional` | Professional | $79/mo | 200 units |
| `enterprise` | Enterprise | $199/mo | Unlimited |
### Checkout Flow
1. **Frontend** (`PricingPage.tsx`): User enters email + business name, selects a plan.
2. **API call**: `POST /api/billing/create-checkout-session`
- Body: `{ planId, email?, businessName? }`
- Returns: `{ url }` (Stripe hosted checkout URL)
- No auth required.
3. **Redirect**: Frontend does `window.location.href = url` to send user to Stripe.
4. **On success**: Stripe redirects to `/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`.
5. **On cancel**: Stripe redirects back to `/pricing`.
### Webhook Events Handled
The webhook endpoint is `POST /api/webhooks/stripe`.
| Event | Action |
|-------|--------|
| `checkout.session.completed` | Triggers full provisioning pipeline |
| `invoice.payment_succeeded` | Sets org status to `active` (handles reactivation after failed payment) |
| `invoice.payment_failed` | Sends payment-failed "email" (stubbed) |
| `customer.subscription.deleted` | Sets org status to `archived` |
All webhook events are deduplicated via the `shared.stripe_events` table (idempotency by Stripe event ID).
### Stripe Customer Portal
`POST /api/billing/portal` (auth required) -- creates a Stripe Customer Portal session for managing subscription/payment methods. **Note: currently throws "not implemented" -- needs org-context customer ID lookup.**
---
## 3. Provisioning Pipeline
When `checkout.session.completed` fires, the backend runs **inline provisioning** (no background queue):
1. **Create organization** in `shared.organizations` with:
- `name` = business name from checkout metadata
- `schema_name` = `tenant_{random_12_chars}`
- `status` = `active`
- `plan_level` = selected plan
- `stripe_customer_id` + `stripe_subscription_id`
- Uses `ON CONFLICT (stripe_customer_id)` for idempotency
2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`:
- Runs the full tenant DDL (accounts, journal entries, etc.)
- Skips if schema already exists
3. **Create or find user** in `shared.users` by email:
- New users are created with `is_email_verified = false` and no password
- Existing users are reused (linked to new org)
4. **Create membership** in `shared.user_organizations`:
- Role: `president`
- Idempotent via `ON CONFLICT DO NOTHING`
5. **Generate invite token** (JWT signed with `INVITE_TOKEN_SECRET`, 72-hour expiry):
- SHA-256 hash stored in `shared.invite_tokens`
- Raw token used in activation URL
6. **Send activation "email"** (stubbed -- see section 12):
- Logged to console and `shared.email_log` table
- Contains activation URL: `{APP_URL}/activate?token={jwt}`
7. **Initialize onboarding** progress row in `shared.onboarding_progress`
### Provisioning Status Polling
`GET /api/billing/status?session_id=xxx` (no auth required)
Returns: `{ status }` where status is one of:
- `not_configured` -- Stripe not set up
- `pending` -- no customer ID yet
- `provisioning` -- org exists but not active yet
- `active` -- ready to go
The `OnboardingPendingPage` polls this every 3 seconds and redirects to `/login` once active.
---
## 4. Account Activation (Magic Link)
### Validate Token
`GET /api/auth/activate?token=xxx` (no auth required)
- Verifies JWT signature (using `INVITE_TOKEN_SECRET`)
- Checks `shared.invite_tokens` for existence, expiration, and prior use
- Returns: `{ valid, email, orgName, orgId, userId }`
### Activate Account
`POST /api/auth/activate` (no auth required)
- Body: `{ token, password, fullName }`
- Password must be >= 8 characters
- Sets `password_hash`, `first_name`, `last_name`, `is_email_verified = true`
- Marks invite token as used (`used_at = NOW()`)
- Issues full session (access token + refresh token cookie)
- Frontend redirects to `/onboarding`
### Frontend (ActivatePage.tsx)
- Validates token on mount
- Shows password setup form with strength indicator (color-coded bar)
- On success: stores auth in Zustand and navigates to `/onboarding`
---
## 5. Guided Onboarding Checklist
### Required Steps
| Step Key | UI Label | Description |
|-----------------|----------------|-------------|
| `profile` | Profile | Set up user profile |
| `workspace` | Workspace | Configure organization settings |
| `invite_member` | Invite Member | Invite at least one team member |
| `first_workflow` | First Account | Create the first chart-of-accounts entry |
### API
- `GET /api/onboarding/progress` (auth required): Returns `{ completedSteps[], completedAt, requiredSteps[] }`
- `PATCH /api/onboarding/progress` (auth required): Body `{ step }` -- marks a step complete
Steps are stored as a PostgreSQL text array. When all 4 required steps are complete, `completed_at` is set. Users can skip onboarding via a "Finish Later" button (navigates to dashboard).
### Frontend (OnboardingPage.tsx)
- Mantine Stepper with 4 steps
- Each step calls `PATCH /onboarding/progress` on completion
- Celebration screen shown when all steps are done
---
## 6. Authentication & Sessions
### Token Architecture
| Token | Type | Lifetime | Storage |
|-------|------|----------|---------|
| Access token | JWT | 1 hour | Frontend Zustand store (memory/localStorage) |
| Refresh token | Opaque (base64url, 64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) |
| MFA challenge | JWT | 5 minutes | Frontend state (in-memory only) |
| Invite/activation | JWT | 72 hours | URL query parameter |
### Refresh Token Flow
1. Access token expires (401 from any API call)
2. Axios interceptor catches 401, calls `POST /api/auth/refresh`
3. Refresh token is sent automatically via httpOnly cookie
4. Server validates token hash in `shared.refresh_tokens` table
5. New access token issued (refresh token is NOT rotated)
6. Original failed request is replayed with new token
7. Concurrent requests are queued during refresh (no thundering herd)
### Cookie Configuration
- Name: `ledgeriq_rt`
- Path: `/api/auth`
- httpOnly: `true`
- Secure: `true` in production, `false` in dev
- SameSite: `strict`
- Max-Age: 30 days
### Session Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/login` | No | Email + password login |
| `POST` | `/api/auth/register` | No | Create account |
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
| `POST` | `/api/auth/logout` | Cookie | Revoke current refresh token |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all user sessions |
| `POST` | `/api/auth/switch-org` | JWT | Switch org context (new tokens) |
---
## 7. Multi-Factor Authentication (TOTP)
### Setup Flow
1. User goes to Settings > Security > Two-Factor Auth tab
2. `POST /api/auth/mfa/setup` -- returns `{ qrCodeDataUrl, secret, uri }`
3. User scans QR code in authenticator app (Google Authenticator, Authy, etc.)
4. User enters 6-digit code to confirm
5. `POST /api/auth/mfa/enable` with `{ token }` -- returns `{ recoveryCodes[] }`
6. **User must save their 10 recovery codes** (displayed once, bcrypt-hashed in DB)
### Login with MFA
1. `POST /api/auth/login` returns `{ mfaRequired: true, mfaToken }` instead of session
2. Frontend shows 6-digit PIN input (or recovery code input)
3. `POST /api/auth/mfa/verify` with `{ mfaToken, token, useRecovery? }`
4. On success: full session issued (access token + refresh cookie)
### Recovery Codes
- 10 codes generated on MFA enable
- Each code is single-use (removed from array after verification)
- Codes are bcrypt-hashed in `shared.users.recovery_codes` (JSON array)
### MFA Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR + secret |
| `POST` | `/api/auth/mfa/enable` | JWT | Verify code and enable MFA |
| `POST` | `/api/auth/mfa/verify` | No (uses mfaToken) | Verify during login |
| `POST` | `/api/auth/mfa/disable` | JWT | Disable MFA (requires password) |
| `GET` | `/api/auth/mfa/status` | JWT | Check if MFA is enabled |
### Tech Stack
- Library: `otplib` v4 (`generateSecret`, `generateURI`, `verifySync`)
- QR codes: `qrcode` package (data URL output)
- Recovery codes: `crypto.randomBytes` + `bcryptjs`
---
## 8. Single Sign-On (SSO)
### Supported Providers
| Provider | Library | Env Vars Required |
|----------|---------|-------------------|
| Google | `passport-google-oauth20` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` |
| Microsoft/Azure AD | `passport-azure-ad` | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_CALLBACK_URL` |
SSO providers are **conditionally loaded** -- they only appear on the login page if their env vars are configured. The `GET /api/auth/sso/providers` endpoint returns `{ google: boolean, azure: boolean }`.
### SSO Login Flow
1. Frontend redirects to `/api/auth/google` or `/api/auth/azure`
2. Passport handles OAuth redirect to provider
3. Provider redirects back to `/api/auth/{provider}/callback`
4. Backend creates or links user via `SsoService.findOrCreateSsoUser()`
5. Session tokens issued, redirect to `/sso-callback?token={accessToken}`
### Account Linking
- SSO fields stored on `shared.users`: `sso_provider`, `sso_id`
- If email matches existing user, SSO is auto-linked on first login
- Users can unlink: `DELETE /api/auth/sso/unlink/:provider`
### SSO Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/auth/sso/providers` | No | List configured providers |
| `GET` | `/api/auth/google/callback` | No (OAuth) | Google callback |
| `GET` | `/api/auth/azure/callback` | No (OAuth) | Azure callback |
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO provider |
---
## 9. Passkeys (WebAuthn)
### Registration Flow (authenticated user)
1. `POST /api/auth/passkeys/register-options` -- returns WebAuthn creation options
2. Browser `navigator.credentials.create()` via `@simplewebauthn/browser`
3. `POST /api/auth/passkeys/register` with `{ response, deviceName? }`
4. Credential stored in `shared.user_passkeys`
### Login Flow (unauthenticated)
1. `POST /api/auth/passkeys/login-options` with `{ email? }` -- returns assertion options
2. Browser `navigator.credentials.get()` via `@simplewebauthn/browser`
3. `POST /api/auth/passkeys/login` with `{ response, challenge }`
4. Full session issued on success
### Passkey Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/passkeys/register-options` | JWT | Get registration options |
| `POST` | `/api/auth/passkeys/register` | JWT | Complete registration |
| `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options |
| `POST` | `/api/auth/passkeys/login` | No | Authenticate with passkey |
| `GET` | `/api/auth/passkeys` | JWT | List registered passkeys |
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
### Configuration
- `WEBAUTHN_RP_ID` -- Relying party ID (e.g., `localhost` for dev, `yourdomain.com` for prod)
- `WEBAUTHN_RP_ORIGIN` -- Expected origin (e.g., `http://localhost` or `https://yourdomain.com`)
- Passkey removal is blocked if the user has no password and no other passkeys (prevents lockout)
---
## 10. Environment Variables Reference
### Stripe (Required for billing)
| Variable | Example | Description |
|----------|---------|-------------|
| `STRIPE_SECRET_KEY` | `sk_test_...` | Stripe secret key. Must NOT contain "placeholder" to activate. |
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Webhook endpoint signing secret |
| `STRIPE_STARTER_PRICE_ID` | `price_...` | Stripe Price ID for Starter plan |
| `STRIPE_PROFESSIONAL_PRICE_ID` | `price_...` | Stripe Price ID for Professional plan |
| `STRIPE_ENTERPRISE_PRICE_ID` | `price_...` | Stripe Price ID for Enterprise plan |
### SSO (Optional -- features hidden when not set)
| Variable | Example | Description |
|----------|---------|-------------|
| `GOOGLE_CLIENT_ID` | `xxx.apps.googleusercontent.com` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | `GOCSPX-...` | Google OAuth client secret |
| `GOOGLE_CALLBACK_URL` | `http://localhost/api/auth/google/callback` | OAuth redirect URI |
| `AZURE_CLIENT_ID` | `uuid` | Azure AD application (client) ID |
| `AZURE_CLIENT_SECRET` | `...` | Azure AD client secret |
| `AZURE_TENANT_ID` | `uuid` | Azure AD tenant (directory) ID |
| `AZURE_CALLBACK_URL` | `http://localhost/api/auth/azure/callback` | OAuth redirect URI |
### WebAuthn / Passkeys
| Variable | Default | Description |
|----------|---------|-------------|
| `WEBAUTHN_RP_ID` | `localhost` | Relying party identifier (your domain) |
| `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin |
### Other
| Variable | Default | Description |
|----------|---------|-------------|
| `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for signing invite/activation JWTs. **Change in production.** |
| `APP_URL` | `http://localhost` | Base URL for generated links (activation emails, Stripe redirects) |
---
## 11. Manual Intervention & Ops Tasks
### Before Going Live
1. **Set up Stripe products and prices** in the Stripe Dashboard:
- Create 3 products (Starter, Professional, Enterprise)
- Create monthly recurring prices for each
- Copy the Price IDs into `STRIPE_STARTER_PRICE_ID`, etc.
2. **Configure the Stripe webhook** in the Stripe Dashboard:
- Endpoint URL: `https://yourdomain.com/api/webhooks/stripe`
- Events to subscribe: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`
- Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`
3. **Replace the email stub** with a real provider:
- `backend/src/modules/email/email.service.ts` currently logs to console + DB
- Swap in Resend, SendGrid, SES, or your preferred provider
- The four email methods to implement: `sendActivationEmail`, `sendWelcomeEmail`, `sendPaymentFailedEmail`, `sendInviteMemberEmail`
4. **Set production secrets**:
- `INVITE_TOKEN_SECRET` -- use a strong random string (not `dev-invite-secret`)
- `JWT_SECRET` -- already required, verify it's strong
- `WEBAUTHN_RP_ID` and `WEBAUTHN_RP_ORIGIN` -- set to your production domain
5. **Configure SSO providers** (if desired):
- Register apps in Google Cloud Console and/or Azure AD
- Set the callback URLs to your production domain
- Add client IDs and secrets to env vars
6. **Set up the Stripe Customer Portal** in Stripe Dashboard:
- Configure allowed actions (cancel, upgrade/downgrade, payment method updates)
- The `/api/billing/portal` endpoint needs the org-context customer ID lookup completed
### Ongoing Ops
7. **Refresh token cleanup**: The `RefreshTokenService.cleanupExpired()` method deletes tokens that have been expired or revoked for 7+ days. **This is not currently called on a schedule.** Options:
- Add a cron job / scheduled task that calls it periodically
- Or add a NestJS `@Cron()` decorator (requires `@nestjs/schedule`)
8. **Monitor `shared.email_log`**: While email is stubbed, activation URLs are only visible in:
- Backend console logs (look for lines starting with `EMAIL STUB`)
- The `shared.email_log` table (query: `SELECT * FROM shared.email_log ORDER BY sent_at DESC`)
9. **Finding activation URLs manually** (dev/testing):
```sql
SELECT to_email, metadata->>'activationUrl' AS url, sent_at
FROM shared.email_log
WHERE template = 'activation'
ORDER BY sent_at DESC
LIMIT 10;
```
10. **Resend an activation email**: `POST /api/auth/resend-activation` with `{ email }` is stubbed (always returns success). To manually generate a new token:
```sql
-- Find the user and org
SELECT u.id AS user_id, uo.organization_id
FROM shared.users u
JOIN shared.user_organizations uo ON uo.user_id = u.id
WHERE u.email = 'user@example.com';
```
Then call `authService.generateInviteToken(userId, orgId, email)` or trigger a fresh checkout.
11. **Deprovisioning / cancellation**: When a Stripe subscription is deleted, the org is set to `archived`. Archived orgs:
- Block login (users see "Your organization has been suspended")
- Block API access (403 on org-scoped endpoints)
- Data is preserved (schema is NOT deleted)
- To restore: update `status` back to `active` in `shared.organizations`
---
## 12. What's Stubbed vs. Production-Ready
| Component | Status | Notes |
|-----------|--------|-------|
| Stripe Checkout | **Ready** (test mode) | Switch to live keys for production |
| Stripe Webhooks | **Ready** | Signature verification, idempotency, event dispatch all implemented |
| Stripe Customer Portal | **Stubbed** | Endpoint exists but needs org-context customer ID lookup |
| Provisioning (org + schema + user) | **Ready** | Inline (synchronous). Consider BullMQ queue for production scale. |
| Email service | **Stubbed** | Logs to console + `shared.email_log`. Replace with real SMTP/API provider. |
| Activation (magic link) | **Ready** | Works end-to-end (token generation, validation, password set, session issue) |
| Onboarding checklist | **Ready** | Server-side progress tracking, step completion, UI wizard |
| Refresh tokens | **Ready** | Creation, validation, revocation, cleanup method (needs scheduling) |
| TOTP MFA | **Ready** | Setup, enable, verify, recovery codes, disable |
| SSO (Google) | **Ready** (needs keys) | Conditional loading, user creation/linking |
| SSO (Azure AD) | **Ready** (needs keys) | Uses deprecated `passport-azure-ad` (works, consider `@azure/msal-node`) |
| Passkeys (WebAuthn) | **Ready** | Registration, authentication, removal with lockout protection |
| Resend activation | **Stubbed** | Always returns success, no actual email sent |
---
## 13. API Endpoint Reference
### Billing (no auth unless noted)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/billing/create-checkout-session` | No | Create Stripe Checkout, returns `{ url }` |
| `POST` | `/api/webhooks/stripe` | Stripe sig | Webhook receiver |
| `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status |
| `POST` | `/api/billing/portal` | JWT | Stripe Customer Portal (stubbed) |
### Auth
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/register` | No | Register new user |
| `POST` | `/api/auth/login` | No | Login (may return MFA challenge) |
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
| `POST` | `/api/auth/logout` | Cookie | Logout current session |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
| `GET` | `/api/auth/activate?token=` | No | Validate activation token |
| `POST` | `/api/auth/activate` | No | Set password + activate |
| `POST` | `/api/auth/resend-activation` | No | Resend activation (stubbed) |
| `GET` | `/api/auth/profile` | JWT | Get user profile |
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
### MFA
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR code + secret |
| `POST` | `/api/auth/mfa/enable` | JWT | Enable MFA with TOTP code |
| `POST` | `/api/auth/mfa/verify` | mfaToken | Verify during login |
| `POST` | `/api/auth/mfa/disable` | JWT | Disable (requires password) |
| `GET` | `/api/auth/mfa/status` | JWT | Check MFA status |
### SSO
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/auth/sso/providers` | No | List available providers |
| `GET` | `/api/auth/google/callback` | OAuth | Google callback handler |
| `GET` | `/api/auth/azure/callback` | OAuth | Azure callback handler |
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO account |
### Passkeys
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/passkeys/register-options` | JWT | Get registration options |
| `POST` | `/api/auth/passkeys/register` | JWT | Complete registration |
| `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options |
| `POST` | `/api/auth/passkeys/login` | No | Authenticate with passkey |
| `GET` | `/api/auth/passkeys` | JWT | List user's passkeys |
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
### Onboarding
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress |
| `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete |
---
## Database Tables Added (Migration 015)
| Table | Purpose |
|-------|---------|
| `shared.refresh_tokens` | Stores SHA-256 hashed refresh tokens with expiry/revocation |
| `shared.stripe_events` | Idempotency ledger for Stripe webhook events |
| `shared.invite_tokens` | Tracks activation/invite magic links |
| `shared.onboarding_progress` | Per-org onboarding step completion |
| `shared.user_passkeys` | WebAuthn credential storage |
| `shared.email_log` | Stubbed email audit trail |
Columns added to existing tables:
- `shared.organizations`: `stripe_customer_id`, `stripe_subscription_id`, `trial_ends_at`
- `shared.users`: `totp_verified_at`, `recovery_codes`, `webauthn_challenge`

136
PLAN.md Normal file
View File

@@ -0,0 +1,136 @@
# Phase 2 Bug Fix & Tweaks - Implementation Plan
## 1. Admin Panel: Tenant Creation, Contract/Plan Fields, Disable/Archive
### Database Changes
- Add `contract_number VARCHAR(100)` and `plan_level VARCHAR(50) DEFAULT 'standard'` to `shared.organizations` (live DB ALTER + init SQL)
- Add `archived` to the status CHECK constraint: `('active', 'suspended', 'trial', 'archived')`
- Add to Organization entity: `contractNumber`, `planLevel` columns
### Backend Changes
- **admin.controller.ts**: Add two new endpoints:
- `POST /admin/tenants` — Creates org + first user + tenant schema in one call. Accepts: org name, email, address, contractNumber, planLevel, plus first user's email/password/firstName/lastName. Calls OrganizationsService.create() then sets up the user.
- `PUT /admin/organizations/:id/status` — Sets status to 'active', 'suspended', or 'archived'
- **auth.module.ts**: Import OrganizationsModule so AdminController can inject OrganizationsService
- **auth.service.ts**: In `login()`, after loading user with orgs, check if the default org's status is 'suspended' or 'archived' → throw UnauthorizedException("Your organization has been suspended/archived")
- **users.service.ts**: Update `findAllOrganizations()` query to include `contract_number, plan_level` in the SELECT
### Frontend Changes
- **AdminPage.tsx**:
- Add "Create Tenant" button → opens a modal with: org name, address, email, phone, contract number, plan level (select: standard/premium/enterprise), first admin email, first admin password, first/last name
- Orgs table: add Contract #, Plan Level columns
- Orgs table: add Status dropdown/buttons (Active/Suspended/Archived) per row with confirmation
- Show status colors: active=green, trial=yellow, suspended=orange, archived=red
## 2. Units/Homeowners: Delete + Assessment Group Binding
### Backend Changes
- **units.controller.ts**: Add `@Delete(':id')` route
- **units.service.ts**:
- Add `delete(id)` method — checks for outstanding invoices first, then deletes
- Add `assessment_group_id` to `create()` INSERT and `update()` UPDATE queries
- Update `findAll()` to JOIN assessment_groups and return `assessment_group_name`
### Frontend Changes
- **UnitsPage.tsx**:
- Add delete button (trash icon) per row with confirmation dialog
- Add Assessment Group dropdown (Select) in create/edit modal, populated from `/assessment-groups` query
- Show assessment group name in table
- When an assessment group is selected and no manual monthly_assessment is set, auto-fill from the group's regular_assessment
## 3. Assessment Groups: Frequency Field
### Database Changes
- Add `frequency VARCHAR(20) DEFAULT 'monthly'` to `assessment_groups` table (live DB ALTER + tenant-schema DDL)
- CHECK constraint: `('monthly', 'quarterly', 'annual')`
### Backend Changes
- **assessment-groups.service.ts**:
- Add `frequency` to `create()` INSERT
- Add `frequency` to `update()` dynamic sets
- Update `findAll()` and `getSummary()` income calculations to adjust by frequency:
- monthly → multiply by 1 (×12/year)
- quarterly → amounts are per quarter, so monthly = amount/3
- annual → amounts are per year, so monthly = amount/12
- Summary labels should change to reflect "Monthly Equivalent" for mixed frequencies
### Frontend Changes
- **AssessmentGroupsPage.tsx**:
- Add frequency Select in create/edit modal: Monthly, Quarterly, Annual
- Show frequency badge in table
- Update summary cards: labels → "Monthly Equivalent Operating" etc.
- Assessment amount label changes based on frequency ("Per Month" / "Per Quarter" / "Per Year")
## 4. UI Streamlining: Sidebar Grouping, Rename, Logo
### Sidebar Restructure
Group nav items into labeled sections:
```
Dashboard
─── FINANCIALS ───
Accounts (renamed from "Chart of Accounts")
Budgets
Investments
─── ASSESSMENTS ───
Units / Homeowners
Assessment Groups
─── TRANSACTIONS ───
Transactions
Invoices
Payments
─── PLANNING ───
Capital Projects
Reserves
Vendors
─── REPORTS ───
(collapsible with sub-items)
─── ADMIN ───
Year-End
Settings
─── PLATFORM ADMIN ─── (superadmin only)
Admin Panel
```
### Logo
- Copy SVG to `frontend/src/assets/logo.svg`
- In AppLayout.tsx: Replace `<Title order={3} c="blue">HOA LedgerIQ</Title>` with an `<img>` tag loading the SVG, sized to fit the 60px header (height ~40px with padding)
- SVG will be served directly (Vite handles SVG imports natively), no PNG conversion needed since browsers render SVG natively and it's cleaner
## 5. Capital Projects: PDF Table Export, Kanban Default, Future Category
### Frontend Changes
- **CapitalProjectsPage.tsx**:
- Change default viewMode from `'table'` to `'kanban'`
- PDF export: temporarily switch to table view for print, then restore. Use `@media print` CSS to always show table layout regardless of current view
- Add "Future" column in kanban: projects with `target_year = 9999` (sentinel value) display as "Future"
- Update the form: Target Year select should include a "Future (Beyond 5-Year)" option that maps to year 9999
- Kanban year list: always include current year through +5, plus "Future" if any projects exist there
- Table view: group "Future" projects under a "Future" header
- Title: "Capital Projects" (remove "(5-Year Plan)" since we now have Future)
### Backend
- No backend changes needed — target_year=9999 works with existing schema (integer column, no constraint)
## File Change Summary
| File | Action |
|------|--------|
| `db/init/00-init.sql` | Add contract_number, plan_level, update status CHECK |
| `backend/src/modules/organizations/entities/organization.entity.ts` | Add contractNumber, planLevel columns |
| `backend/src/modules/organizations/dto/create-organization.dto.ts` | Add contractNumber, planLevel fields |
| `backend/src/modules/auth/admin.controller.ts` | Add POST /admin/tenants, PUT /admin/organizations/:id/status |
| `backend/src/modules/auth/auth.module.ts` | Import OrganizationsModule |
| `backend/src/modules/auth/auth.service.ts` | Add org status check on login |
| `backend/src/modules/users/users.service.ts` | Update findAllOrganizations query |
| `backend/src/modules/units/units.controller.ts` | Add DELETE route |
| `backend/src/modules/units/units.service.ts` | Add delete(), assessment_group_id support |
| `backend/src/modules/assessment-groups/assessment-groups.service.ts` | Add frequency support + adjust income calcs |
| `backend/src/database/tenant-schema.service.ts` | Add frequency to assessment_groups DDL |
| `frontend/src/assets/logo.svg` | New — copy from /Users/claw/Downloads/logo_house.svg |
| `frontend/src/components/layout/AppLayout.tsx` | Replace text with logo |
| `frontend/src/components/layout/Sidebar.tsx` | Restructure with grouped sections |
| `frontend/src/pages/admin/AdminPage.tsx` | Create tenant modal, status management, new columns |
| `frontend/src/pages/units/UnitsPage.tsx` | Delete, assessment group dropdown |
| `frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx` | Frequency field |
| `frontend/src/pages/capital-projects/CapitalProjectsPage.tsx` | Kanban default, table PDF, Future category |
| Live DB | ALTER TABLE commands for contract_number, plan_level, frequency, status CHECK |

32
backend/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# ---- Production Dockerfile for NestJS backend ----
# Multi-stage build: compile TypeScript, then run with minimal image
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
# Only install production dependencies
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Copy compiled output and New Relic preload from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/newrelic-preload.js ./newrelic-preload.js
# New Relic agent — configured entirely via environment variables
ENV NEW_RELIC_NO_CONFIG_FILE=true
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
ENV NEW_RELIC_LOG=stdout
EXPOSE 3000
# Preload the New Relic agent (activates only when NEW_RELIC_ENABLED=true)
CMD ["node", "-r", "./newrelic-preload.js", "dist/main"]

View File

@@ -7,6 +7,11 @@ RUN npm install
COPY . . COPY . .
# New Relic agent — configured entirely via environment variables
ENV NEW_RELIC_NO_CONFIG_FILE=true
ENV NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=true
ENV NEW_RELIC_LOG=stdout
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "start:dev"] CMD ["npm", "run", "start:dev"]

View File

@@ -0,0 +1,7 @@
// Conditionally load the New Relic agent before any other modules.
// Controlled by the NEW_RELIC_ENABLED environment variable (.env).
'use strict';
if (process.env.NEW_RELIC_ENABLED === 'true') {
require('newrelic');
}

2566
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", "name": "hoa-ledgeriq-backend",
"version": "0.2.0", "version": "2026.3.17",
"description": "HOA LedgerIQ - Backend API", "description": "HOA LedgerIQ - Backend API",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -8,7 +8,7 @@
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node -r ./newrelic-preload.js dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@@ -23,18 +23,30 @@
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2", "@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
"@simplewebauthn/server": "^13.3.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"bullmq": "^5.71.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"newrelic": "latest",
"otplib": "^13.3.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-azure-ad": "^4.3.5",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"stripe": "^20.4.1",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
"uuid": "^9.0.1" "uuid": "^9.0.1"
}, },
@@ -43,12 +55,15 @@
"@nestjs/schematics": "^10.2.3", "@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^20.17.12", "@types/node": "^20.17.12",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"jest": "^29.7.0", "jest": "^29.7.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",

View File

@@ -1,9 +1,13 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware'; import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module'; import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -24,6 +28,12 @@ import { ProjectsModule } from './modules/projects/projects.module';
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module'; import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
import { AttachmentsModule } from './modules/attachments/attachments.module'; import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module'; import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
import { 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({ @Module({
imports: [ imports: [
@@ -39,8 +49,19 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
autoLoadEntities: true, autoLoadEntities: true,
synchronize: false, synchronize: false,
logging: false, logging: false,
// Connection pool — reuse connections instead of creating new ones per query
extra: {
max: 30, // max pool size (across all concurrent requests)
min: 5, // keep at least 5 idle connections warm
idleTimeoutMillis: 30000, // close idle connections after 30s
connectionTimeoutMillis: 5000, // fail fast if pool is exhausted
},
}), }),
}), }),
ThrottlerModule.forRoot([{
ttl: 60000, // 1-minute window
limit: 100, // 100 requests per minute (global default)
}]),
DatabaseModule, DatabaseModule,
AuthModule, AuthModule,
OrganizationsModule, OrganizationsModule,
@@ -62,8 +83,24 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
MonthlyActualsModule, MonthlyActualsModule,
AttachmentsModule, AttachmentsModule,
InvestmentPlanningModule, InvestmentPlanningModule,
HealthScoresModule,
BoardPlanningModule,
BillingModule,
EmailModule,
OnboardingModule,
ScheduleModule.forRoot(),
], ],
controllers: [AppController], controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,
},
],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ALLOW_VIEWER_KEY = 'allowViewer';
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);

View File

@@ -0,0 +1,35 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator';
@Injectable()
export class WriteAccessGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const method = request.method;
// Allow all read methods
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true;
// Determine role from either req.userRole (set by TenantMiddleware which runs
// before guards) or req.user.role (set by JwtAuthGuard Passport strategy).
const role = request.userRole || request.user?.role;
if (!role) return true; // unauthenticated endpoints like login/register
// Check for @AllowViewer() exemption on handler or class
const allowViewer = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_KEY, [
context.getHandler(),
context.getClass(),
]);
if (allowViewer) return true;
// Block viewer role from write operations
if (role === 'viewer') {
throw new ForbiddenException('Read-only users cannot modify data');
}
return true;
}
}

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

@@ -112,6 +112,8 @@ export class TenantSchemaService {
special_assessment DECIMAL(10,2) DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0, unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')), frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}',
due_day INTEGER DEFAULT 1,
is_default BOOLEAN DEFAULT FALSE, is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -155,8 +157,11 @@ export class TenantSchemaService {
amount DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00, amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ( status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off' 'draft', 'pending', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
)), )),
period_start DATE,
period_end DATE,
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
journal_entry_id UUID REFERENCES "${s}".journal_entries(id), journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
sent_at TIMESTAMPTZ, sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ, paid_at TIMESTAMPTZ,
@@ -202,6 +207,7 @@ export class TenantSchemaService {
default_account_id UUID REFERENCES "${s}".accounts(id), default_account_id UUID REFERENCES "${s}".accounts(id),
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
ytd_payments DECIMAL(15,2) DEFAULT 0.00, ytd_payments DECIMAL(15,2) DEFAULT 0.00,
last_negotiated DATE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
)`, )`,
@@ -316,6 +322,38 @@ export class TenantSchemaService {
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
)`, )`,
// AI Investment Recommendations (saved per tenant)
`CREATE TABLE "${s}".ai_recommendations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recommendations_json JSONB NOT NULL,
overall_assessment TEXT,
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
status VARCHAR(20) DEFAULT 'complete',
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Health Scores (AI-derived operating / reserve fund health)
`CREATE TABLE "${s}".health_scores (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')),
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
previous_score INTEGER,
trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')),
label VARCHAR(30),
summary TEXT,
factors JSONB,
recommendations JSONB,
missing_data JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')),
response_time_ms INTEGER,
calculated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`,
// Attachments (file storage for receipts/invoices) // Attachments (file storage for receipts/invoices)
`CREATE TABLE "${s}".attachments ( `CREATE TABLE "${s}".attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
@@ -328,6 +366,99 @@ export class TenantSchemaService {
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
)`, )`,
// Board Planning - Scenarios
`CREATE TABLE "${s}".board_scenarios (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
scenario_type VARCHAR(30) NOT NULL CHECK (scenario_type IN ('investment', 'assessment')),
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'approved', 'archived')),
projection_months INTEGER DEFAULT 36,
projection_cache JSONB,
projection_cached_at TIMESTAMPTZ,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Board Planning - Scenario Investments
`CREATE TABLE "${s}".scenario_investments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
source_recommendation_id UUID,
label VARCHAR(255) NOT NULL,
investment_type VARCHAR(50) CHECK (investment_type IN ('cd', 'money_market', 'treasury', 'savings', 'other')),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
principal DECIMAL(15,2) NOT NULL,
interest_rate DECIMAL(6,4),
term_months INTEGER,
institution VARCHAR(255),
purchase_date DATE,
maturity_date DATE,
auto_renew BOOLEAN DEFAULT FALSE,
executed_investment_id UUID,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Board Planning - Scenario Assessments
`CREATE TABLE "${s}".scenario_assessments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
scenario_id UUID NOT NULL REFERENCES "${s}".board_scenarios(id) ON DELETE CASCADE,
change_type VARCHAR(30) NOT NULL CHECK (change_type IN ('dues_increase', 'special_assessment', 'dues_decrease')),
label VARCHAR(255) NOT NULL,
target_fund VARCHAR(20) CHECK (target_fund IN ('operating', 'reserve', 'both')),
percentage_change DECIMAL(6,3),
flat_amount_change DECIMAL(10,2),
special_total DECIMAL(15,2),
special_per_unit DECIMAL(10,2),
special_installments INTEGER DEFAULT 1,
effective_date DATE NOT NULL,
end_date DATE,
applies_to_group_id UUID,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Budget Plans
`CREATE TABLE "${s}".budget_plans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fiscal_year INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'approved', 'ratified')),
base_year INTEGER NOT NULL,
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
notes TEXT,
created_by UUID,
approved_by UUID,
approved_at TIMESTAMPTZ,
ratified_by UUID,
ratified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(fiscal_year)
)`,
// Budget Plan Lines
`CREATE TABLE "${s}".budget_plan_lines (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
budget_plan_id UUID NOT NULL REFERENCES "${s}".budget_plans(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES "${s}".accounts(id),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
is_manually_adjusted BOOLEAN DEFAULT FALSE,
notes TEXT,
UNIQUE(budget_plan_id, account_id, fund_type)
)`,
// Indexes // Indexes
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`, `CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`, `CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
@@ -340,6 +471,12 @@ export class TenantSchemaService {
`CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`, `CREATE INDEX "idx_${s}_pay_unit" ON "${s}".payments(unit_id)`,
`CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`, `CREATE INDEX "idx_${s}_pay_inv" ON "${s}".payments(invoice_id)`,
`CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`, `CREATE INDEX "idx_${s}_bud_year" ON "${s}".budgets(fiscal_year)`,
`CREATE INDEX "idx_${s}_bs_type_status" ON "${s}".board_scenarios(scenario_type, status)`,
`CREATE INDEX "idx_${s}_si_scenario" ON "${s}".scenario_investments(scenario_id)`,
`CREATE INDEX "idx_${s}_sa_scenario" ON "${s}".scenario_assessments(scenario_id)`,
`CREATE INDEX "idx_${s}_bp_year" ON "${s}".budget_plans(fiscal_year)`,
`CREATE INDEX "idx_${s}_bp_status" ON "${s}".budget_plans(status)`,
`CREATE INDEX "idx_${s}_bpl_plan" ON "${s}".budget_plan_lines(budget_plan_id)`,
]; ];
} }

View File

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

View File

@@ -1,18 +1,78 @@
import * as _cluster from 'node:cluster';
import * as os from 'node:os';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
const isProduction = process.env.NODE_ENV === 'production';
// ---------------------------------------------------------------------------
// Clustering — fork one worker per CPU core in production
// ---------------------------------------------------------------------------
const WORKERS = isProduction
? Math.min(os.cpus().length, 4) // cap at 4 workers to stay within DB pool
: 1; // single process in dev
if (WORKERS > 1 && cluster.isPrimary) {
console.log(`Primary ${process.pid} forking ${WORKERS} workers ...`);
for (let i = 0; i < WORKERS; i++) {
cluster.fork();
}
cluster.on('exit', (worker: any, code: number) => {
console.warn(`Worker ${worker.process.pid} exited (code ${code}), restarting ...`);
cluster.fork();
});
} else {
bootstrap();
}
// ---------------------------------------------------------------------------
// NestJS bootstrap
// ---------------------------------------------------------------------------
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, {
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
// Enable raw body for Stripe webhook signature verification
rawBody: true,
});
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
// Request logging // Cookie parser — needed for refresh token httpOnly cookies
app.use((req: any, _res: any, next: any) => { app.use(cookieParser());
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
next(); // 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) => {
console.log(`[REQ] ${req.method} ${req.url} auth=${req.headers.authorization ? 'yes' : 'no'}`);
next();
});
}
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
@@ -22,21 +82,24 @@ async function bootstrap() {
}), }),
); );
// CORS — in production nginx handles this; accept all origins behind the proxy
app.enableCors({ app.enableCors({
origin: ['http://localhost', 'http://localhost:5173'], origin: isProduction ? true : ['http://localhost', 'http://localhost:5173'],
credentials: true, credentials: true,
}); });
const config = new DocumentBuilder() // Swagger docs — disabled in production to avoid exposing API surface
.setTitle('HOA LedgerIQ API') if (!isProduction) {
.setDescription('API for the HOA LedgerIQ') const config = new DocumentBuilder()
.setVersion('0.1.0') .setTitle('HOA LedgerIQ API')
.addBearerAuth() .setDescription('API for the HOA LedgerIQ')
.build(); .setVersion('2026.3.11')
const document = SwaggerModule.createDocument(app, config); .addBearerAuth()
SwaggerModule.setup('api/docs', app, document); .build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
await app.listen(3000); await app.listen(3000);
console.log('Backend running on port 3000'); console.log(`Backend worker ${process.pid} listening on port 3000`);
} }
bootstrap();

View File

@@ -142,7 +142,21 @@ export class AccountsService {
} }
} }
return account; // Auto-set as primary if this is the first asset account for this fund_type
if (dto.accountType === 'asset') {
const existingPrimary = await this.tenant.query(
'SELECT id FROM accounts WHERE fund_type = $1 AND is_primary = true AND id != $2',
[dto.fundType, accountId],
);
if (!existingPrimary.length) {
await this.tenant.query(
'UPDATE accounts SET is_primary = true WHERE id = $1',
[accountId],
);
}
}
return this.findOne(accountId);
} }
async update(id: string, dto: UpdateAccountDto) { async update(id: string, dto: UpdateAccountDto) {

View File

@@ -1,6 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service'; import { TenantService } from '../../database/tenant.service';
const DEFAULT_DUE_MONTHS: Record<string, number[]> = {
monthly: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
quarterly: [1, 4, 7, 10],
annual: [1],
};
@Injectable() @Injectable()
export class AssessmentGroupsService { export class AssessmentGroupsService {
constructor(private tenant: TenantService) {} constructor(private tenant: TenantService) {}
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
return rows.length ? rows[0] : null; return rows.length ? rows[0] : null;
} }
private validateDueMonths(frequency: string, dueMonths: number[]) {
if (!dueMonths || !dueMonths.length) {
throw new BadRequestException('Due months are required');
}
// Validate all values are 1-12
if (dueMonths.some((m) => m < 1 || m > 12 || !Number.isInteger(m))) {
throw new BadRequestException('Due months must be integers between 1 and 12');
}
switch (frequency) {
case 'monthly':
if (dueMonths.length !== 12) {
throw new BadRequestException('Monthly frequency must include all 12 months');
}
break;
case 'quarterly':
if (dueMonths.length !== 4) {
throw new BadRequestException('Quarterly frequency must have exactly 4 due months');
}
break;
case 'annual':
if (dueMonths.length !== 1) {
throw new BadRequestException('Annual frequency must have exactly 1 due month');
}
break;
}
}
async create(dto: any) { async create(dto: any) {
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups'); const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0; const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
@@ -51,17 +84,23 @@ export class AssessmentGroupsService {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true'); await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
} }
const frequency = dto.frequency || 'monthly';
const dueMonths = dto.dueMonths || DEFAULT_DUE_MONTHS[frequency] || DEFAULT_DUE_MONTHS.monthly;
const dueDay = Math.min(Math.max(dto.dueDay || 1, 1), 28);
this.validateDueMonths(frequency, dueMonths);
const rows = await this.tenant.query( const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default) `INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, [dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault], dto.unitCount || 0, frequency, dueMonths, dueDay, shouldBeDefault],
); );
return rows[0]; return rows[0];
} }
async update(id: string, dto: any) { async update(id: string, dto: any) {
await this.findOne(id); const existing = await this.findOne(id);
if (dto.isDefault === true) { if (dto.isDefault === true) {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true'); await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
@@ -80,6 +119,24 @@ export class AssessmentGroupsService {
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); } if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); } if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
// Handle due_months: if frequency changed and no explicit dueMonths, auto-populate defaults
const effectiveFrequency = dto.frequency || existing.frequency;
if (dto.dueMonths !== undefined) {
this.validateDueMonths(effectiveFrequency, dto.dueMonths);
sets.push(`due_months = $${idx++}`);
params.push(dto.dueMonths);
} else if (dto.frequency !== undefined && dto.frequency !== existing.frequency) {
// Frequency changed, auto-populate due_months
const newDueMonths = DEFAULT_DUE_MONTHS[dto.frequency] || DEFAULT_DUE_MONTHS.monthly;
sets.push(`due_months = $${idx++}`);
params.push(newDueMonths);
}
if (dto.dueDay !== undefined) {
sets.push(`due_day = $${idx++}`);
params.push(Math.min(Math.max(dto.dueDay, 1), 28));
}
if (!sets.length) return this.findOne(id); if (!sets.length) return this.findOne(id);
sets.push('updated_at = NOW()'); sets.push('updated_at = NOW()');

View File

@@ -1,18 +1,50 @@
import { import {
Controller, Controller,
Post, Post,
Patch,
Body, Body,
UseGuards, UseGuards,
Request, Request,
Get, Get,
Res,
Query,
HttpCode,
ForbiddenException,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { Response } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { SwitchOrgDto } from './dto/switch-org.dto'; import { SwitchOrgDto } from './dto/switch-org.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
const COOKIE_NAME = 'ledgeriq_rt';
const isProduction = process.env.NODE_ENV === 'production';
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
function setRefreshCookie(res: Response, token: string) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
path: '/api/auth',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
}
function clearRefreshCookie(res: Response) {
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
path: '/api/auth',
});
}
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -20,18 +52,74 @@ export class AuthController {
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register a new user' }) @ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
async register(@Body() dto: RegisterDto) { @Throttle({ default: { limit: 5, ttl: 60000 } })
return this.authService.register(dto); async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
if (!isOpenRegistration) {
throw new ForbiddenException(
'Open registration is disabled. Please use an invitation link to create your account.',
);
}
const result = await this.authService.register(dto);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken, ...response } = result;
return response;
} }
@Post('login') @Post('login')
@ApiOperation({ summary: 'Login with email and password' }) @ApiOperation({ summary: 'Login with email and password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
@UseGuards(AuthGuard('local')) @UseGuards(AuthGuard('local'))
async login(@Request() req: any, @Body() _dto: LoginDto) { async login(@Request() req: any, @Body() _dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const ip = req.headers['x-forwarded-for'] || req.ip; const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent']; const ua = req.headers['user-agent'];
return this.authService.login(req.user, ip, ua); const result = await this.authService.login(req.user, ip, ua);
// MFA challenge — no cookie, just return the challenge token
if ('mfaRequired' in result) {
return result;
}
if ('refreshToken' in result && result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken: _rt, ...response } = result as any;
return response;
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh access token using httpOnly cookie' })
async refresh(@Request() req: any, @Res({ passthrough: true }) res: Response) {
const rawToken = req.cookies?.[COOKIE_NAME];
if (!rawToken) {
throw new BadRequestException('No refresh token');
}
return this.authService.refreshAccessToken(rawToken);
}
@Post('logout')
@ApiOperation({ summary: 'Logout and revoke refresh token' })
@HttpCode(200)
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
const rawToken = req.cookies?.[COOKIE_NAME];
if (rawToken) {
await this.authService.logout(rawToken);
}
clearRefreshCookie(res);
return { success: true };
}
@Post('logout-everywhere')
@ApiOperation({ summary: 'Revoke all sessions' })
@HttpCode(200)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
await this.authService.logoutEverywhere(req.user.sub);
clearRefreshCookie(res);
return { success: true };
} }
@Get('profile') @Get('profile')
@@ -42,13 +130,114 @@ export class AuthController {
return this.authService.getProfile(req.user.sub); return this.authService.getProfile(req.user.sub);
} }
@Patch('intro-seen')
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async markIntroSeen(@Request() req: any) {
await this.authService.markIntroSeen(req.user.sub);
return { success: true };
}
@Post('switch-org') @Post('switch-org')
@ApiOperation({ summary: 'Switch active organization' }) @ApiOperation({ summary: 'Switch active organization' })
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) { @AllowViewer()
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto, @Res({ passthrough: true }) res: Response) {
const ip = req.headers['x-forwarded-for'] || req.ip; const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent']; const ua = req.headers['user-agent'];
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua); const result = await this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken, ...response } = result;
return response;
}
// ─── Activation Endpoints ─────────────────────────────────────────
@Get('activate')
@ApiOperation({ summary: 'Validate an activation token' })
async validateActivation(@Query('token') token: string) {
if (!token) throw new BadRequestException('Token required');
return this.authService.validateInviteToken(token);
}
@Post('activate')
@ApiOperation({ summary: 'Activate user account with password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async activate(
@Body() body: { token: string; password: string; fullName: string },
@Res({ passthrough: true }) res: Response,
) {
if (!body.token || !body.password || !body.fullName) {
throw new BadRequestException('Token, password, and fullName are required');
}
if (body.password.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
const result = await this.authService.activateUser(body.token, body.password, body.fullName);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken, ...response } = result;
return response;
}
@Post('resend-activation')
@ApiOperation({ summary: 'Resend activation email' })
@Throttle({ default: { limit: 2, ttl: 60000 } })
async resendActivation(@Body() body: { email: string }) {
// Stubbed — will be implemented when email service is ready
return { success: true, message: 'If an account exists, a new activation link has been sent.' };
}
// ─── Password Reset Flow ──────────────────────────────────────────
@Post('forgot-password')
@ApiOperation({ summary: 'Request a password reset email' })
@HttpCode(200)
@Throttle({ default: { limit: 3, ttl: 60000 } })
async forgotPassword(@Body() body: { email: string }) {
if (!body.email) throw new BadRequestException('Email is required');
await this.authService.requestPasswordReset(body.email);
// Always return same message to prevent account enumeration
return { message: 'If that email exists, a password reset link has been sent.' };
}
@Post('reset-password')
@ApiOperation({ summary: 'Reset password using a reset token' })
@HttpCode(200)
@Throttle({ default: { limit: 5, ttl: 60000 } })
async resetPassword(@Body() body: { token: string; newPassword: string }) {
if (!body.token || !body.newPassword) {
throw new BadRequestException('Token and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.resetPassword(body.token, body.newPassword);
return { message: 'Password updated successfully.' };
}
@Patch('change-password')
@ApiOperation({ summary: 'Change password (authenticated)' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async changePassword(
@Request() req: any,
@Body() body: { currentPassword: string; newPassword: string },
) {
if (!body.currentPassword || !body.newPassword) {
throw new BadRequestException('currentPassword and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.changePassword(req.user.sub, body.currentPassword, body.newPassword);
return { message: 'Password changed successfully.' };
} }
} }

View File

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

View File

@@ -4,21 +4,37 @@ import {
ConflictException, ConflictException,
ForbiddenException, ForbiddenException,
NotFoundException, NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { randomBytes, createHash } from 'crypto';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly inviteSecret: string;
private readonly appUrl: string;
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService,
private dataSource: DataSource, private dataSource: DataSource,
) {} private refreshTokenService: RefreshTokenService,
private emailService: EmailService,
) {
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
}
async register(dto: RegisterDto) { async register(dto: RegisterDto) {
const existing = await this.usersService.findByEmail(dto.email); const existing = await this.usersService.findByEmail(dto.email);
@@ -72,9 +88,27 @@ export class AuthService {
// Record login in history (org_id is null at initial login) // Record login in history (org_id is null at initial login)
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {}); this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
// If MFA is enabled, return a challenge token instead of full session
if (u.mfaEnabled && u.mfaSecret) {
const mfaToken = this.jwtService.sign(
{ sub: u.id, type: 'mfa_challenge' },
{ expiresIn: '5m' },
);
return { mfaRequired: true, mfaToken };
}
return this.generateTokenResponse(u); return this.generateTokenResponse(u);
} }
/**
* Complete login after MFA verification — generate full session tokens.
*/
async completeMfaLogin(userId: string): Promise<any> {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) throw new UnauthorizedException('User not found');
return this.generateTokenResponse(user);
}
async getProfile(userId: string) { async getProfile(userId: string) {
const user = await this.usersService.findByIdWithOrgs(userId); const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) { if (!user) {
@@ -85,6 +119,7 @@ export class AuthService {
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
mfaEnabled: user.mfaEnabled || false,
organizations: user.userOrganizations?.map((uo) => ({ organizations: user.userOrganizations?.map((uo) => ({
id: uo.organization.id, id: uo.organization.id,
name: uo.organization.name, name: uo.organization.name,
@@ -118,23 +153,265 @@ export class AuthService {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
orgId: membership.organizationId, orgId: membership.organizationId,
orgSchema: membership.organization.schemaName,
role: membership.role, role: membership.role,
}; };
// Record org switch in login history // Record org switch in login history
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {}); this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
// Generate new refresh token for org switch
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
return { return {
accessToken: this.jwtService.sign(payload), accessToken: this.jwtService.sign(payload),
refreshToken,
organization: { organization: {
id: membership.organization.id, id: membership.organization.id,
name: membership.organization.name, name: membership.organization.name,
role: membership.role, role: membership.role,
settings: membership.organization.settings || {},
}, },
}; };
} }
/**
* 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( private async recordLoginHistory(
userId: string, userId: string,
organizationId: string | null, organizationId: string | null,
@@ -152,7 +429,7 @@ export class AuthService {
} }
} }
private generateTokenResponse(user: User, impersonatedBy?: string) { async generateTokenResponse(user: User, impersonatedBy?: string) {
const allOrgs = user.userOrganizations || []; const allOrgs = user.userOrganizations || [];
// Filter out suspended/archived organizations // Filter out suspended/archived organizations
const orgs = allOrgs.filter( const orgs = allOrgs.filter(
@@ -172,12 +449,15 @@ export class AuthService {
if (defaultOrg) { if (defaultOrg) {
payload.orgId = defaultOrg.organizationId; payload.orgId = defaultOrg.organizationId;
payload.orgSchema = defaultOrg.organization?.schemaName;
payload.role = defaultOrg.role; payload.role = defaultOrg.role;
} }
// Create refresh token
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
return { return {
accessToken: this.jwtService.sign(payload), accessToken: this.jwtService.sign(payload),
refreshToken,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
@@ -185,11 +465,12 @@ export class AuthService {
lastName: user.lastName, lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false, isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false, isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
mfaEnabled: user.mfaEnabled || false,
}, },
organizations: orgs.map((uo) => ({ organizations: orgs.map((uo) => ({
id: uo.organizationId, id: uo.organizationId,
name: uo.organization?.name, name: uo.organization?.name,
schemaName: uo.organization?.schemaName,
status: uo.organization?.status, status: uo.organization?.status,
role: uo.role, role: uo.role,
})), })),

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, sub: payload.sub,
email: payload.email, email: payload.email,
orgId: payload.orgId, orgId: payload.orgId,
orgSchema: payload.orgSchema,
role: payload.role, role: payload.role,
isSuperadmin: payload.isSuperadmin || false, isSuperadmin: payload.isSuperadmin || false,
impersonatedBy: payload.impersonatedBy || null, impersonatedBy: payload.impersonatedBy || null,

View File

@@ -0,0 +1,63 @@
import {
Controller,
Post,
Get,
Body,
Query,
Req,
UseGuards,
RawBodyRequest,
BadRequestException,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { Request as ExpressRequest } from 'express';
import { BillingService } from './billing.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('billing')
@Controller()
export class BillingController {
constructor(private billingService: BillingService) {}
@Post('billing/create-checkout-session')
@ApiOperation({ summary: 'Create a Stripe Checkout Session' })
@Throttle({ default: { limit: 10, ttl: 60000 } })
async createCheckout(
@Body() body: { planId: string; email?: string; businessName?: string },
) {
if (!body.planId) throw new BadRequestException('planId is required');
return this.billingService.createCheckoutSession(body.planId, body.email, body.businessName);
}
@Post('webhooks/stripe')
@ApiOperation({ summary: 'Stripe webhook endpoint' })
async handleWebhook(@Req() req: RawBodyRequest<ExpressRequest>) {
const signature = req.headers['stripe-signature'] as string;
if (!signature) throw new BadRequestException('Missing Stripe signature');
if (!req.rawBody) throw new BadRequestException('Missing raw body');
await this.billingService.handleWebhook(req.rawBody, signature);
return { received: true };
}
@Get('billing/status')
@ApiOperation({ summary: 'Check provisioning status for a checkout session' })
async getStatus(@Query('session_id') sessionId: string) {
if (!sessionId) throw new BadRequestException('session_id required');
return this.billingService.getProvisioningStatus(sessionId);
}
@Post('billing/portal')
@ApiOperation({ summary: 'Create Stripe Customer Portal session' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async createPortal(@Request() req: any) {
// Lookup the org's stripe_customer_id
// Only allow president or superadmin
const orgId = req.user.orgId;
if (!orgId) throw new BadRequestException('No organization context');
// For now, we'd look this up from the org
throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context');
}
}

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,294 @@
import { Injectable, Logger, BadRequestException, RawBodyRequest } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import Stripe from 'stripe';
import { v4 as uuid } from 'uuid';
import * as bcrypt from 'bcryptjs';
import { TenantSchemaService } from '../../database/tenant-schema.service';
import { AuthService } from '../auth/auth.service';
import { EmailService } from '../email/email.service';
const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
starter: { name: 'Starter', unitLimit: 50 },
professional: { name: 'Professional', unitLimit: 200 },
enterprise: { name: 'Enterprise', unitLimit: 999999 },
};
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
private stripe: Stripe | null = null;
private webhookSecret: string;
private priceMap: Record<string, string>;
constructor(
private configService: ConfigService,
private dataSource: DataSource,
private tenantSchemaService: TenantSchemaService,
private authService: AuthService,
private emailService: EmailService,
) {
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
if (secretKey && !secretKey.includes('placeholder')) {
this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' as any });
this.logger.log('Stripe initialized');
} else {
this.logger.warn('Stripe not configured — billing endpoints will return stubs');
}
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
this.priceMap = {
starter: this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
professional: this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
enterprise: this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
};
}
/**
* Create a Stripe Checkout Session for a new subscription.
*/
async createCheckoutSession(planId: string, email?: string, businessName?: string): Promise<{ url: string }> {
if (!this.stripe) {
throw new BadRequestException('Stripe not configured');
}
const priceId = this.priceMap[planId];
if (!priceId || priceId.includes('placeholder')) {
throw new BadRequestException(`Invalid plan: ${planId}`);
}
const session = await this.stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${this.getAppUrl()}/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${this.getAppUrl()}/pricing`,
customer_email: email || undefined,
metadata: {
plan_id: planId,
business_name: businessName || '',
},
});
return { url: session.url! };
}
/**
* Handle a Stripe webhook event.
*/
async handleWebhook(rawBody: Buffer, signature: string): Promise<void> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
} catch (err: any) {
this.logger.error(`Webhook signature verification failed: ${err.message}`);
throw new BadRequestException('Invalid webhook signature');
}
// Idempotency check
const existing = await this.dataSource.query(
`SELECT id FROM shared.stripe_events WHERE id = $1`,
[event.id],
);
if (existing.length > 0) {
this.logger.log(`Duplicate Stripe event ${event.id}, skipping`);
return;
}
// Record event
await this.dataSource.query(
`INSERT INTO shared.stripe_events (id, type, payload) VALUES ($1, $2, $3)`,
[event.id, event.type, JSON.stringify(event.data)],
);
// Dispatch
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'invoice.payment_succeeded':
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
default:
this.logger.log(`Unhandled Stripe event: ${event.type}`);
}
}
/**
* Get provisioning status for a checkout session.
*/
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
if (!this.stripe) return { status: 'not_configured' };
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
const customerId = session.customer as string;
if (!customerId) return { status: 'pending' };
const rows = await this.dataSource.query(
`SELECT id, status FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
if (rows.length === 0) return { status: 'provisioning' };
if (rows[0].status === 'active') return { status: 'active' };
return { status: 'provisioning' };
}
/**
* Create a Stripe Customer Portal session.
*/
async createPortalSession(customerId: string): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const session = await this.stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${this.getAppUrl()}/settings`,
});
return { url: session.url };
}
// ─── Provisioning (inline, no BullMQ for now — add queue later) ─────
private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const email = session.customer_email || session.customer_details?.email || '';
const planId = session.metadata?.plan_id || 'starter';
const businessName = session.metadata?.business_name || 'My HOA';
this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
try {
await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName);
} catch (err: any) {
this.logger.error(`Provisioning failed: ${err.message}`, err.stack);
}
}
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
// Activate tenant if it was pending
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'active', updated_at = NOW()
WHERE stripe_customer_id = $1 AND status != 'active'`,
[customerId],
);
}
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
const rows = await this.dataSource.query(
`SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
if (rows.length > 0 && rows[0].email) {
await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization');
}
this.logger.warn(`Payment failed for customer ${customerId}`);
}
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'archived', updated_at = NOW()
WHERE stripe_customer_id = $1`,
[customerId],
);
this.logger.log(`Subscription cancelled for customer ${customerId}`);
}
/**
* Full provisioning flow: create org, schema, user, invite token, email.
*/
async provisionOrganization(
customerId: string,
subscriptionId: string,
email: string,
planId: string,
businessName: string,
): Promise<void> {
// 1. Create or upsert organization
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
const orgRows = await this.dataSource.query(
`INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email)
VALUES ($1, $2, 'active', $3, $4, $5, $6)
ON CONFLICT (stripe_customer_id) DO UPDATE SET
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
plan_level = EXCLUDED.plan_level,
status = 'active',
updated_at = NOW()
RETURNING id, schema_name`,
[businessName, schemaName, planId, customerId, subscriptionId, email],
);
const orgId = orgRows[0].id;
const actualSchema = orgRows[0].schema_name;
// 2. Create tenant schema
try {
await this.tenantSchemaService.createTenantSchema(actualSchema);
this.logger.log(`Created tenant schema: ${actualSchema}`);
} catch (err: any) {
if (err.message?.includes('already exists')) {
this.logger.log(`Schema ${actualSchema} already exists, skipping creation`);
} else {
throw err;
}
}
// 3. Create or find user
let userRows = await this.dataSource.query(
`SELECT id FROM shared.users WHERE email = $1`,
[email],
);
let userId: string;
if (userRows.length === 0) {
const newUser = await this.dataSource.query(
`INSERT INTO shared.users (email, is_email_verified)
VALUES ($1, false)
RETURNING id`,
[email],
);
userId = newUser[0].id;
} else {
userId = userRows[0].id;
}
// 4. Create membership (president role)
await this.dataSource.query(
`INSERT INTO shared.user_organizations (user_id, organization_id, role)
VALUES ($1, $2, 'president')
ON CONFLICT (user_id, organization_id) DO NOTHING`,
[userId, orgId],
);
// 5. Generate invite token and "send" activation email
const inviteToken = await this.authService.generateInviteToken(userId, orgId, email);
const activationUrl = `${this.getAppUrl()}/activate?token=${inviteToken}`;
await this.emailService.sendActivationEmail(email, businessName, activationUrl);
// 6. Initialize onboarding progress
await this.dataSource.query(
`INSERT INTO shared.onboarding_progress (organization_id) VALUES ($1) ON CONFLICT DO NOTHING`,
[orgId],
);
this.logger.log(`✅ Provisioning complete for org=${orgId}, user=${userId}`);
}
private getAppUrl(): string {
return this.configService.get<string>('APP_URL') || 'http://localhost';
}
}

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,83 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
/**
* Stubbed email service — logs to console and stores in shared.email_log.
* Replace internals with Resend/SendGrid when ready for production.
*/
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
constructor(private dataSource: DataSource) {}
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
const body = [
`Welcome to HOA LedgerIQ!`,
``,
`Your organization "${businessName}" has been created.`,
`Please activate your account by clicking the link below:`,
``,
activationUrl,
``,
`This link expires in 72 hours.`,
].join('\n');
await this.log(email, subject, body, 'activation', { businessName, activationUrl });
}
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
const body = `Your account is active. Log in at http://localhost to get started.`;
await this.log(email, subject, body, 'welcome', { businessName });
}
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
const subject = `Payment failed for ${businessName} on HOA LedgerIQ`;
const body = `We were unable to process your payment. Please update your payment method.`;
await this.log(email, subject, body, '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 body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`;
await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl });
}
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password';
const body = [
`You requested a password reset for your HOA LedgerIQ account.`,
``,
`Click the link below to reset your password:`,
resetUrl,
``,
`This link expires in 15 minutes. If you didn't request this, ignore this email.`,
].join('\n');
await this.log(email, subject, body, 'password_reset', { resetUrl });
}
private async log(
toEmail: string,
subject: string,
body: string,
template: string,
metadata: Record<string, any>,
): Promise<void> {
this.logger.log(`EMAIL STUB -> ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
this.logger.log(` Body:\n${body}`);
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}`);
}
}
}

View File

@@ -0,0 +1,76 @@
import { Controller, Get, Post, UseGuards, Req, Logger } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { HealthScoresService } from './health-scores.service';
@ApiTags('health-scores')
@Controller('health-scores')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class HealthScoresController {
private readonly logger = new Logger(HealthScoresController.name);
constructor(private service: HealthScoresService) {}
@Get('latest')
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
getLatest(@Req() req: any) {
const schema = req.tenantSchema;
return this.service.getLatestScores(schema);
}
@Post('calculate')
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.tenantSchema;
// Fire-and-forget — background processing saves results to DB
Promise.all([
this.service.calculateScore(schema, 'operating'),
this.service.calculateScore(schema, 'reserve'),
]).catch((err) => {
this.logger.error(`Background health score calculation failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Health score calculations started. Results will appear when ready.',
};
}
@Post('calculate/operating')
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
@AllowViewer()
async calculateOperating(@Req() req: any) {
const schema = req.tenantSchema;
// Fire-and-forget
this.service.calculateScore(schema, 'operating').catch((err) => {
this.logger.error(`Background operating score failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Operating fund health score calculation started.',
};
}
@Post('calculate/reserve')
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
@AllowViewer()
async calculateReserve(@Req() req: any) {
const schema = req.tenantSchema;
// Fire-and-forget
this.service.calculateScore(schema, 'reserve').catch((err) => {
this.logger.error(`Background reserve score failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Reserve fund health score calculation started.',
};
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthScoresController } from './health-scores.controller';
import { HealthScoresService } from './health-scores.service';
import { HealthScoresScheduler } from './health-scores.scheduler';
@Module({
controllers: [HealthScoresController],
providers: [HealthScoresService, HealthScoresScheduler],
})
export class HealthScoresModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { HealthScoresService } from './health-scores.service';
@Injectable()
export class HealthScoresScheduler {
private readonly logger = new Logger(HealthScoresScheduler.name);
constructor(
private dataSource: DataSource,
private healthScoresService: HealthScoresService,
) {}
/**
* Run daily at 2:00 AM — calculate health scores for all active tenants.
* Uses DataSource directly to list tenants (no HTTP request context needed).
*/
@Cron('0 2 * * *')
async calculateAllTenantScores() {
this.logger.log('Starting daily health score calculation for all tenants...');
const startTime = Date.now();
try {
const orgs = await this.dataSource.query(
`SELECT id, name, schema_name FROM shared.organizations WHERE status = 'active'`,
);
this.logger.log(`Found ${orgs.length} active tenants`);
let successCount = 0;
let errorCount = 0;
for (const org of orgs) {
try {
await this.healthScoresService.calculateScore(org.schema_name, 'operating');
await this.healthScoresService.calculateScore(org.schema_name, 'reserve');
successCount++;
this.logger.log(`Health scores calculated for ${org.name} (${org.schema_name})`);
} catch (err: any) {
errorCount++;
this.logger.error(`Failed to calculate health scores for ${org.name}: ${err.message}`);
}
}
const elapsed = Date.now() - startTime;
this.logger.log(
`Daily health scores complete: ${successCount} success, ${errorCount} errors (${elapsed}ms)`,
);
} catch (err: any) {
this.logger.error(`Health score scheduler failed: ${err.message}`);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common'; import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { InvestmentPlanningService } from './investment-planning.service'; import { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning') @ApiTags('investment-planning')
@@ -17,14 +18,27 @@ export class InvestmentPlanningController {
} }
@Get('cd-rates') @Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data' }) @ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
getCdRates() { getCdRates() {
return this.service.getCdRates(); return this.service.getCdRates();
} }
@Get('market-rates')
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
getMarketRates() {
return this.service.getMarketRates();
}
@Get('saved-recommendation')
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
getSavedRecommendation() {
return this.service.getSavedRecommendation();
}
@Post('recommendations') @Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' }) @ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
getRecommendations(@Req() req: any) { @AllowViewer()
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId); triggerRecommendations(@Req() req: any) {
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
} }
} }

View File

@@ -28,15 +28,25 @@ export interface InvestmentAccount {
current_value: string; current_value: string;
} }
export interface CdRate { export interface MarketRate {
bank_name: string; bank_name: string;
apy: string; apy: string;
min_deposit: string | null; min_deposit: string | null;
term: string; term: string;
term_months: number | null; term_months: number | null;
rate_type: string;
fetched_at: string; fetched_at: string;
} }
export interface RecommendationComponent {
label: string;
amount: number;
term_months: number;
rate: number;
bank_name?: string;
investment_type?: string;
}
export interface Recommendation { export interface Recommendation {
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general'; type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
priority: 'high' | 'medium' | 'low'; priority: 'high' | 'medium' | 'low';
@@ -49,6 +59,7 @@ export interface Recommendation {
suggested_rate?: number; suggested_rate?: number;
bank_name?: string; bank_name?: string;
rationale: string; rationale: string;
components?: RecommendationComponent[];
} }
export interface AIResponse { export interface AIResponse {
@@ -57,6 +68,18 @@ export interface AIResponse {
risk_notes: string[]; risk_notes: string[];
} }
export interface SavedRecommendation {
id: string;
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
response_time_ms: number;
created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
}
@Injectable() @Injectable()
export class InvestmentPlanningService { export class InvestmentPlanningService {
private readonly logger = new Logger(InvestmentPlanningService.name); private readonly logger = new Logger(InvestmentPlanningService.name);
@@ -139,40 +162,341 @@ export class InvestmentPlanningService {
} }
/** /**
* Fetch latest CD rates from the shared schema (cross-tenant market data). * Fetch latest market rates from the shared schema (cross-tenant market data).
* Uses DataSource directly since this queries the shared schema, not tenant. * Returns rates grouped by type, each showing only the most recent fetch batch.
*/ */
async getCdRates(): Promise<CdRate[]> { async getMarketRates(): Promise<{ cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] }> {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
try { try {
await queryRunner.connect(); await queryRunner.connect();
const rates = await queryRunner.query(
`SELECT bank_name, apy, min_deposit, term, term_months, fetched_at // For each rate type, get the latest batch (same fetched_at timestamp)
FROM shared.cd_rates const fetchLatest = async (rateType: string): Promise<MarketRate[]> => {
ORDER BY apy DESC return queryRunner.query(
LIMIT 25`, `SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
); FROM shared.cd_rates
return rates; WHERE rate_type = $1
AND fetched_at = (
SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type = $1
)
ORDER BY apy DESC
LIMIT 25`,
[rateType],
);
};
const [cd, moneyMarket, highYieldSavings] = await Promise.all([
fetchLatest('cd'),
fetchLatest('money_market'),
fetchLatest('high_yield_savings'),
]);
return {
cd,
money_market: moneyMarket,
high_yield_savings: highYieldSavings,
};
} finally { } finally {
await queryRunner.release(); await queryRunner.release();
} }
} }
/**
* Backward-compatible: get only CD rates.
*/
async getCdRates(): Promise<MarketRate[]> {
const rates = await this.getMarketRates();
return rates.cd;
}
/**
* Ensure the status/error_message columns exist (for tenants created before this migration).
*/
private async ensureStatusColumn(): Promise<void> {
try {
await this.tenant.query(
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'complete'`,
);
await this.tenant.query(
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS error_message TEXT`,
);
} catch {
// Ignore — column may already exist or table may not exist
}
}
/**
* Get the latest saved AI recommendation for this tenant.
* Returns status and last_failed flag for UI state management.
*/
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
try {
await this.ensureStatusColumn();
const rows = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, status, error_message, created_at
FROM ai_recommendations
ORDER BY created_at DESC
LIMIT 1`,
);
if (!rows || rows.length === 0) return null;
const row = rows[0];
const status = row.status || 'complete';
// If still processing, return processing status
if (status === 'processing') {
return {
id: row.id,
recommendations: [],
overall_assessment: '',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'processing',
last_failed: false,
};
}
// If latest attempt failed, return the last successful result with last_failed flag
if (status === 'error') {
const lastGood = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at
FROM ai_recommendations
WHERE status = 'complete'
ORDER BY created_at DESC
LIMIT 1`,
);
if (lastGood?.length) {
const goodRow = lastGood[0];
const recData = goodRow.recommendations_json || {};
return {
id: goodRow.id,
recommendations: recData.recommendations || [],
overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '',
risk_notes: goodRow.risk_notes || recData.risk_notes || [],
response_time_ms: goodRow.response_time_ms || 0,
created_at: goodRow.created_at,
status: 'complete',
last_failed: true,
error_message: row.error_message,
};
}
// No previous good result — return error state
return {
id: row.id,
recommendations: [],
overall_assessment: row.error_message || 'AI analysis failed. Please try again.',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'error',
last_failed: true,
error_message: row.error_message,
};
}
// Complete — return the data normally
const recData = row.recommendations_json || {};
return {
id: row.id,
recommendations: recData.recommendations || [],
overall_assessment: row.overall_assessment || recData.overall_assessment || '',
risk_notes: row.risk_notes || recData.risk_notes || [],
response_time_ms: row.response_time_ms || 0,
created_at: row.created_at,
status: 'complete',
last_failed: false,
};
} catch (err: any) {
// Table might not exist yet (pre-migration tenants)
this.logger.warn(`Could not load saved recommendations: ${err.message}`);
return null;
}
}
/**
* Save a 'processing' placeholder record and return its ID.
*/
private async saveProcessingRecord(userId?: string): Promise<string> {
await this.ensureStatusColumn();
const rows = await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, status)
VALUES ('{}', '', '[]', $1, 'processing')
RETURNING id`,
[userId || null],
);
return rows[0].id;
}
/**
* Update a processing record with completed results.
*/
private async updateRecommendationComplete(
jobId: string,
aiResponse: AIResponse,
userId: string | undefined,
elapsed: number,
): Promise<void> {
try {
await this.tenant.query(
`UPDATE ai_recommendations
SET recommendations_json = $1,
overall_assessment = $2,
risk_notes = $3,
response_time_ms = $4,
status = 'complete'
WHERE id = $5`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
JSON.stringify(aiResponse.risk_notes || []),
elapsed,
jobId,
],
);
} catch (err: any) {
this.logger.warn(`Could not update recommendation ${jobId}: ${err.message}`);
}
}
/**
* Update a processing record with error status.
*/
private async updateRecommendationError(jobId: string, errorMessage: string): Promise<void> {
try {
await this.tenant.query(
`UPDATE ai_recommendations
SET status = 'error',
error_message = $1
WHERE id = $2`,
[errorMessage, jobId],
);
} catch (err: any) {
this.logger.warn(`Could not update recommendation error ${jobId}: ${err.message}`);
}
}
/**
* Trigger AI recommendations asynchronously.
* Saves a 'processing' record, starts the AI work in the background, and returns immediately.
* The TenantService instance remains alive via closure reference for the duration of the background work.
*/
async triggerAIRecommendations(userId?: string, orgId?: string): Promise<{ status: string; message: string }> {
const jobId = await this.saveProcessingRecord(userId);
this.logger.log(`AI recommendation triggered (job ${jobId}), starting background processing...`);
// Fire-and-forget — the Promise keeps this service instance (and TenantService) alive
this.runBackgroundRecommendations(jobId, userId, orgId).catch((err) => {
this.logger.error(`Background AI recommendation failed (job ${jobId}): ${err.message}`);
});
return {
status: 'processing',
message: 'AI analysis has been started. You can navigate away safely — results will appear when ready.',
};
}
/**
* Run the full AI recommendation pipeline in the background.
*/
private async runBackgroundRecommendations(jobId: string, userId?: string, orgId?: string): Promise<void> {
try {
const startTime = Date.now();
const [snapshot, allRates, monthlyForecast] = await Promise.all([
this.getFinancialSnapshot(),
this.getMarketRates(),
this.getMonthlyForecast(),
]);
this.debug('background_snapshot_summary', {
job_id: jobId,
operating_cash: snapshot.summary.operating_cash,
reserve_cash: snapshot.summary.reserve_cash,
total_all: snapshot.summary.total_all,
investment_accounts: snapshot.investment_accounts.length,
});
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime;
this.debug('background_final_response', {
job_id: jobId,
recommendation_count: aiResponse.recommendations.length,
has_assessment: !!aiResponse.overall_assessment,
elapsed_ms: elapsed,
});
// Check if the AI returned a graceful error (empty recommendations with error message)
const isGracefulError = aiResponse.recommendations.length === 0 &&
(aiResponse.overall_assessment?.includes('Unable to generate') ||
aiResponse.overall_assessment?.includes('invalid response'));
if (isGracefulError) {
await this.updateRecommendationError(jobId, aiResponse.overall_assessment);
} else {
await this.updateRecommendationComplete(jobId, aiResponse, userId, elapsed);
}
// Log AI usage (fire-and-forget)
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
this.logger.log(`Background AI recommendation completed (job ${jobId}) in ${elapsed}ms`);
} catch (err: any) {
this.logger.error(`Background AI recommendation error (job ${jobId}): ${err.message}`);
await this.updateRecommendationError(jobId, err.message);
}
}
/**
* Save AI recommendation result to tenant schema.
* @deprecated Use triggerAIRecommendations() for async flow instead
*/
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
try {
await this.ensureStatusColumn();
await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
VALUES ($1, $2, $3, $4, $5, 'complete')`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
JSON.stringify(aiResponse.risk_notes || []),
userId || null,
elapsed,
],
);
} catch (err: any) {
// Non-critical — don't let storage failure break recommendations
this.logger.warn(`Could not save recommendation: ${err.message}`);
}
}
/** /**
* Orchestrate the AI recommendation flow: * Orchestrate the AI recommendation flow:
* 1. Gather all financial data (tenant-scoped) * 1. Gather all financial data (tenant-scoped)
* 2. Fetch CD rates (shared schema) * 2. Fetch all market rates (shared schema)
* 3. Build the prompt with all context * 3. Build the prompt with all context
* 4. Call the AI API * 4. Call the AI API
* 5. Parse and return structured recommendations * 5. Parse and return structured recommendations
* 6. Save to tenant storage for future retrieval
*/ */
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> { async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
this.debug('getAIRecommendations', 'Starting AI recommendation flow'); this.debug('getAIRecommendations', 'Starting AI recommendation flow');
const startTime = Date.now(); const startTime = Date.now();
const [snapshot, cdRates, monthlyForecast] = await Promise.all([ const [snapshot, allRates, monthlyForecast] = await Promise.all([
this.getFinancialSnapshot(), this.getFinancialSnapshot(),
this.getCdRates(), this.getMarketRates(),
this.getMonthlyForecast(), this.getMonthlyForecast(),
]); ]);
@@ -183,11 +507,13 @@ export class InvestmentPlanningService {
investment_accounts: snapshot.investment_accounts.length, investment_accounts: snapshot.investment_accounts.length,
budgets: snapshot.budgets.length, budgets: snapshot.budgets.length,
projects: snapshot.projects.length, projects: snapshot.projects.length,
cd_rates: cdRates.length, cd_rates: allRates.cd.length,
money_market_rates: allRates.money_market.length,
savings_rates: allRates.high_yield_savings.length,
forecast_months: monthlyForecast.datapoints.length, forecast_months: monthlyForecast.datapoints.length,
}); });
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast); const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
const aiResponse = await this.callAI(messages); const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
@@ -197,6 +523,9 @@ export class InvestmentPlanningService {
risk_notes_count: aiResponse.risk_notes?.length || 0, risk_notes_count: aiResponse.risk_notes?.length || 0,
}); });
// Save recommendation to tenant storage (fire-and-forget)
this.saveRecommendation(aiResponse, userId, elapsed).catch(() => {});
// Log AI usage to shared.ai_recommendation_log (fire-and-forget) // Log AI usage to shared.ai_recommendation_log (fire-and-forget)
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {}); this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
@@ -345,9 +674,6 @@ export class InvestmentPlanningService {
/** /**
* Build a 12-month forward cash flow forecast for the AI. * Build a 12-month forward cash flow forecast for the AI.
* Mirrors the logic from ReportsService.getCashFlowForecast() but streamlined
* for AI context. Includes: assessment income schedule (regular + special),
* monthly budget income/expenses, investment maturities, and capital project costs.
*/ */
private async getMonthlyForecast() { private async getMonthlyForecast() {
const now = new Date(); const now = new Date();
@@ -551,7 +877,11 @@ export class InvestmentPlanningService {
// ── Private: AI Prompt Construction ── // ── Private: AI Prompt Construction ──
private buildPromptMessages(snapshot: any, cdRates: CdRate[], monthlyForecast: any) { private buildPromptMessages(
snapshot: any,
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
monthlyForecast: any,
) {
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot; const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
@@ -564,8 +894,10 @@ CRITICAL RULES:
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access. 4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
5. Operating funds should remain highly liquid (money market or high-yield savings only). 5. Operating funds should remain highly liquid (money market or high-yield savings only).
6. Respect the separation between operating funds and reserve funds. Never suggest commingling. 6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
7. Base your recommendations ONLY on the available CD rates and instruments provided. Do not reference rates or banks not in the provided data. 7. Base your recommendations ONLY on the available market rates (CDs, Money Market, High Yield Savings) provided. Do not reference rates or banks not in the provided data.
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due. 8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
RESPONSE FORMAT: RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema: Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
@@ -581,15 +913,30 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
"suggested_amount": 50000.00, "suggested_amount": 50000.00,
"suggested_term": "12 months", "suggested_term": "12 months",
"suggested_rate": 4.50, "suggested_rate": 4.50,
"bank_name": "Bank name from CD rates (if applicable)", "bank_name": "Bank name from market rates (if applicable)",
"rationale": "Financial reasoning for why this makes sense" "rationale": "Financial reasoning for why this makes sense",
"components": [
{
"label": "Component label (e.g. '6-Month CD at Marcus')",
"amount": 6600.00,
"term_months": 6,
"rate": 4.05,
"bank_name": "Marcus",
"investment_type": "cd"
}
]
} }
], ],
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities", "overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
"risk_notes": ["Array of risk items or concerns to flag for the board"] "risk_notes": ["Array of risk items or concerns to flag for the board"]
} }
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible.`; 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 // Build the data context for the user prompt
const investmentsList = investment_accounts.length === 0 const investmentsList = investment_accounts.length === 0
@@ -616,11 +963,18 @@ IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority item
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`, `- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
).join('\n'); ).join('\n');
const cdRateLines = cdRates.length === 0 // Format market rates by type
? 'No CD rate data available. Rate fetcher may not have been run yet.' const formatRates = (rates: MarketRate[], typeLabel: string): string => {
: cdRates.map((r: CdRate) => if (rates.length === 0) return `No ${typeLabel} rate data available. Rate fetcher may not have been run yet.`;
`- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}% | Term: ${r.term} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`, return rates.map((r: MarketRate) => {
).join('\n'); const termStr = r.term !== 'N/A' ? ` | Term: ${r.term}` : '';
return `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${termStr} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`;
}).join('\n');
};
const cdRateLines = formatRates(allRates.cd, 'CD');
const moneyMarketLines = formatRates(allRates.money_market, 'Money Market');
const savingsRateLines = formatRates(allRates.high_yield_savings, 'High Yield Savings');
// Format assessment schedule showing regular + special // Format assessment schedule showing regular + special
const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0 const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0
@@ -679,15 +1033,25 @@ ${projectLines}
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities. This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
${forecastLines} ${forecastLines}
=== AVAILABLE CD RATES (Market Data) === === AVAILABLE MARKET RATES ===
--- CD Rates ---
${cdRateLines} ${cdRateLines}
--- Money Market Rates ---
${moneyMarketLines}
--- High Yield Savings Rates ---
${savingsRateLines}
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider: Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
1. Is there excess cash that could earn better returns in CDs? 1. Is there excess cash that could earn better returns in CDs, money market accounts, or high-yield savings?
2. Are any current investments maturing soon that need reinvestment planning? 2. Are any current investments maturing soon that need reinvestment planning?
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low. 3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
4. Would a CD ladder strategy improve the yield while maintaining access to funds? 4. Would a CD ladder strategy improve the yield while maintaining access to funds?
5. Are operating and reserve funds properly separated in the investment strategy?`; 5. Are operating and reserve funds properly separated in the investment strategy?
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
return [ return [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
@@ -754,7 +1118,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'), 'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
}, },
timeout: 180000, // 3 minute timeout timeout: 600000, // 10 minute timeout
}; };
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
@@ -768,7 +1132,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
req.on('error', (err) => reject(err)); req.on('error', (err) => reject(err));
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
reject(new Error(`Request timed out after 180s`)); reject(new Error(`Request timed out after 600s`));
}); });
req.write(bodyString); req.write(bodyString);

View File

@@ -16,6 +16,11 @@ export class InvoicesController {
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); } findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-preview')
generatePreview(@Body() dto: { month: number; year: number }) {
return this.invoicesService.generatePreview(dto);
}
@Post('generate-bulk') @Post('generate-bulk')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) { generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub); return this.invoicesService.generateBulk(dto, req.user.sub);

View File

@@ -1,33 +1,135 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service'; import { TenantService } from '../../database/tenant.service';
const MONTH_NAMES = [
'', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTH_ABBREV = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
@Injectable() @Injectable()
export class InvoicesService { export class InvoicesService {
constructor(private tenant: TenantService) {} constructor(private tenant: TenantService) {}
async findAll() { async findAll() {
return this.tenant.query(` return this.tenant.query(`
SELECT i.*, u.unit_number, SELECT i.*, u.unit_number, u.owner_name, ag.name as assessment_group_name, ag.frequency,
(i.amount - i.amount_paid) as balance_due (i.amount - i.amount_paid) as balance_due
FROM invoices i FROM invoices i
JOIN units u ON u.id = i.unit_id JOIN units u ON u.id = i.unit_id
LEFT JOIN assessment_groups ag ON ag.id = i.assessment_group_id
ORDER BY i.invoice_date DESC, i.invoice_number DESC ORDER BY i.invoice_date DESC, i.invoice_number DESC
`); `);
} }
async findOne(id: string) { async findOne(id: string) {
const rows = await this.tenant.query(` const rows = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i SELECT i.*, u.unit_number, u.owner_name FROM invoices i
JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]); JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]);
if (!rows.length) throw new NotFoundException('Invoice not found'); if (!rows.length) throw new NotFoundException('Invoice not found');
return rows[0]; return rows[0];
} }
async generateBulk(dto: { month: number; year: number }, userId: string) { /**
const units = await this.tenant.query( * Calculate billing period based on frequency and the billing month.
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 0`, */
private calculatePeriod(frequency: string, month: number, year: number): { start: string; end: string; description: string } {
switch (frequency) {
case 'quarterly': {
// Period covers 3 months starting from the billing month
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month + 2, 0); // last day of month+2
const endMonth = month + 2 > 12 ? month + 2 - 12 : month + 2;
const quarter = Math.ceil(month / 3);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Q${quarter} ${year} Assessment (${MONTH_ABBREV[month]}-${MONTH_ABBREV[endMonth]})`,
};
}
case 'annual': {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Annual Assessment ${year}`,
};
}
default: { // monthly
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0); // last day of month
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Monthly Assessment - ${MONTH_NAMES[month]} ${year}`,
};
}
}
}
/**
* Preview which groups/units will be billed for a given month/year.
*/
async generatePreview(dto: { month: number; year: number }) {
const allGroups = await this.tenant.query(
`SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as active_units
FROM assessment_groups ag WHERE ag.is_active = true ORDER BY ag.name`,
); );
if (!units.length) throw new BadRequestException('No active units with assessments found');
const groups = allGroups.map((g: any) => {
const dueMonths: number[] = g.due_months || [1,2,3,4,5,6,7,8,9,10,11,12];
const isBillingMonth = dueMonths.includes(dto.month);
const activeUnits = parseInt(g.active_units || '0');
const totalAmount = isBillingMonth
? (parseFloat(g.regular_assessment) + parseFloat(g.special_assessment || '0')) * activeUnits
: 0;
const period = this.calculatePeriod(g.frequency || 'monthly', dto.month, dto.year);
return {
id: g.id,
name: g.name,
frequency: g.frequency || 'monthly',
due_months: dueMonths,
active_units: activeUnits,
regular_assessment: g.regular_assessment,
special_assessment: g.special_assessment,
is_billing_month: isBillingMonth,
total_amount: totalAmount,
period_description: period.description,
};
});
const billableGroups = groups.filter((g: any) => g.is_billing_month && g.active_units > 0);
const totalInvoices = billableGroups.reduce((sum: number, g: any) => sum + g.active_units, 0);
const totalAmount = billableGroups.reduce((sum: number, g: any) => sum + g.total_amount, 0);
return {
month: dto.month,
year: dto.year,
month_name: MONTH_NAMES[dto.month],
groups,
summary: { total_groups_billing: billableGroups.length, total_invoices: totalInvoices, total_amount: totalAmount },
};
}
/**
* Generate invoices for all assessment groups where the given month is a billing month.
*/
async generateBulk(dto: { month: number; year: number }, userId: string) {
// Get assessment groups where this month is a billing month
const groups = await this.tenant.query(
`SELECT * FROM assessment_groups WHERE is_active = true AND $1 = ANY(due_months)`,
[dto.month],
);
if (!groups.length) {
throw new BadRequestException(`No assessment groups have billing scheduled for ${MONTH_NAMES[dto.month]}`);
}
// Get or create fiscal period // Get or create fiscal period
let fp = await this.tenant.query( let fp = await this.tenant.query(
@@ -41,50 +143,87 @@ export class InvoicesService {
} }
const fiscalPeriodId = fp[0].id; const fiscalPeriodId = fp[0].id;
const invoiceDate = new Date(dto.year, dto.month - 1, 1); // Look up GL accounts once
const dueDate = new Date(dto.year, dto.month - 1, 15); const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
let created = 0; let created = 0;
const groupResults: any[] = [];
for (const unit of units) { for (const group of groups) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`; // Get active units in this assessment group
const units = await this.tenant.query(
// Check if already generated `SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`,
const existing = await this.tenant.query( [group.id],
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
);
if (existing.length) continue;
// Create the invoice
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
unit.monthly_assessment],
); );
// Create journal entry: DR Accounts Receivable, CR Assessment Income if (!units.length) continue;
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
if (arAccount.length && incomeAccount.length) { const frequency = group.frequency || 'monthly';
const je = await this.tenant.query( const period = this.calculatePeriod(frequency, dto.month, dto.year);
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by) const dueDay = Math.min(group.due_day || 1, 28);
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`, const invoiceDate = new Date(dto.year, dto.month - 1, 1);
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId], const dueDate = new Date(dto.year, dto.month - 1, dueDay);
// Use the group's assessment amount (full period amount, not monthly equivalent)
const assessmentAmount = parseFloat(group.regular_assessment) + parseFloat(group.special_assessment || '0');
let groupCreated = 0;
for (const unit of units) {
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
// Check if already generated
const existing = await this.tenant.query(
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
); );
await this.tenant.query( if (existing.length) continue;
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unit.monthly_assessment, incomeAccount[0].id], // Use unit-level override if set, otherwise use group amount
); const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0
await this.tenant.query( ? (frequency === 'monthly'
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id], ? parseFloat(unit.monthly_assessment)
: frequency === 'quarterly'
? parseFloat(unit.monthly_assessment) * 3
: parseFloat(unit.monthly_assessment) * 12)
: assessmentAmount;
// Create the invoice with status 'pending' (no email sending capability)
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status, period_start, period_end, assessment_group_id)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
period.description, unitAmount, period.start, period.end, group.id],
); );
// Create journal entry: DR Accounts Receivable, CR Assessment Income
if (arAccount.length && incomeAccount.length) {
const je = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
);
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[je[0].id, arAccount[0].id, unitAmount, incomeAccount[0].id],
);
await this.tenant.query(
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
);
}
created++;
groupCreated++;
} }
created++;
groupResults.push({
group_name: group.name,
frequency,
period: period.description,
invoices_created: groupCreated,
});
} }
return { created, month: dto.month, year: dto.year }; return { created, month: dto.month, year: dto.year, groups: groupResults };
} }
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) { async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
@@ -95,7 +234,7 @@ export class InvoicesService {
const overdue = await this.tenant.query(` const overdue = await this.tenant.query(`
SELECT i.*, u.unit_number FROM invoices i SELECT i.*, u.unit_number FROM invoices i
JOIN units u ON u.id = i.unit_id JOIN units u ON u.id = i.unit_id
WHERE i.status IN ('sent', 'partial') AND i.due_date < $1 WHERE i.status IN ('pending', 'partial') AND i.due_date < $1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id
AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%' AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%'
@@ -109,7 +248,7 @@ export class InvoicesService {
const lfNum = `LF-${inv.invoice_number}`; const lfNum = `LF-${inv.invoice_number}`;
await this.tenant.query( await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status) `INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'sent')`, VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'pending')`,
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount], [lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
); );
applied++; applied++;

View File

@@ -13,6 +13,16 @@ export class JournalEntriesService {
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) { async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
let sql = ` let sql = `
SELECT je.*, SELECT je.*,
CASE
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
ELSE COALESCE(SUM(jel.debit), 0)
END as total_debit,
CASE
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
ELSE COALESCE(SUM(jel.credit), 0)
END as total_credit,
json_agg(json_build_object( json_agg(json_build_object(
'id', jel.id, 'account_id', jel.account_id, 'id', jel.id, 'account_id', jel.account_id,
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo, 'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,

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,4 +1,4 @@
import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common'; import { Controller, Post, Get, Put, Patch, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service'; import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto';
@@ -23,6 +23,13 @@ export class OrganizationsController {
return this.orgService.findByUser(req.user.sub); return this.orgService.findByUser(req.user.sub);
} }
@Patch('settings')
@ApiOperation({ summary: 'Update settings for the current organization' })
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
this.requireTenantAdmin(req);
return this.orgService.updateSettings(req.user.orgId, body);
}
// ── Org Member Management ── // ── Org Member Management ──
private requireTenantAdmin(req: any) { private requireTenantAdmin(req: any) {

View File

@@ -78,6 +78,13 @@ export class OrganizationsService {
return this.orgRepository.save(org); return this.orgRepository.save(org);
} }
async updateSettings(id: string, settings: Record<string, any>) {
const org = await this.orgRepository.findOne({ where: { id } });
if (!org) throw new NotFoundException('Organization not found');
org.settings = { ...(org.settings || {}), ...settings };
return this.orgRepository.save(org);
}
async findByUser(userId: string) { async findByUser(userId: string) {
const memberships = await this.userOrgRepository.find({ const memberships = await this.userOrgRepository.find({
where: { userId, isActive: true }, where: { userId, isActive: true },
@@ -146,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role; existing.role = data.role;
return this.userOrgRepository.save(existing); return this.userOrgRepository.save(existing);
} }
// Update password for existing user being added to a new org
if (data.password) {
const passwordHash = await bcrypt.hash(data.password, 12);
await dataSource.query(
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
[passwordHash, userId],
);
}
} else { } else {
// Create new user // Create new user
const passwordHash = await bcrypt.hash(data.password, 12); const passwordHash = await bcrypt.hash(data.password, 12);

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common'; import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PaymentsService } from './payments.service'; import { PaymentsService } from './payments.service';
@@ -18,4 +18,12 @@ export class PaymentsController {
@Post() @Post()
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); } create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
return this.paymentsService.update(id, dto, req.user.sub);
}
@Delete(':id')
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
} }

View File

@@ -74,17 +74,95 @@ export class PaymentsService {
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]); await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
} }
// Update invoice if linked // Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error
if (invoice) { if (invoice) {
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount); const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
const invoiceAmt = parseFloat(invoice.amount); const invoiceAmt = parseFloat(invoice.amount);
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial'; const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
await this.tenant.query( await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $3`, `UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $4`,
[newPaid, newStatus, invoice.id], [newPaid, newStatus, newStatus, invoice.id],
); );
} }
return payment[0]; return payment[0];
} }
async update(id: string, dto: any, userId: string) {
const existing = await this.findOne(id);
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.payment_date !== undefined) { sets.push(`payment_date = $${idx++}`); params.push(dto.payment_date); }
if (dto.amount !== undefined) { sets.push(`amount = $${idx++}`); params.push(dto.amount); }
if (dto.payment_method !== undefined) { sets.push(`payment_method = $${idx++}`); params.push(dto.payment_method); }
if (dto.reference_number !== undefined) { sets.push(`reference_number = $${idx++}`); params.push(dto.reference_number); }
if (dto.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(dto.notes); }
if (!sets.length) return this.findOne(id);
params.push(id);
await this.tenant.query(
`UPDATE payments SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
// If amount changed and payment is linked to an invoice, recalculate invoice totals
if (dto.amount !== undefined && existing.invoice_id) {
await this.recalculateInvoice(existing.invoice_id);
}
return this.findOne(id);
}
async delete(id: string) {
const payment = await this.findOne(id);
const invoiceId = payment.invoice_id;
// Delete associated journal entry lines and journal entry
if (payment.journal_entry_id) {
await this.tenant.query('DELETE FROM journal_entry_lines WHERE journal_entry_id = $1', [payment.journal_entry_id]);
await this.tenant.query('DELETE FROM journal_entries WHERE id = $1', [payment.journal_entry_id]);
}
// Delete the payment
await this.tenant.query('DELETE FROM payments WHERE id = $1', [id]);
// Recalculate invoice totals if payment was linked
if (invoiceId) {
await this.recalculateInvoice(invoiceId);
}
return { success: true };
}
private async recalculateInvoice(invoiceId: string) {
// Sum all remaining payments for this invoice
const result = await this.tenant.query(
'SELECT COALESCE(SUM(amount), 0) as total_paid FROM payments WHERE invoice_id = $1',
[invoiceId],
);
const totalPaid = parseFloat(result[0].total_paid);
// Get the invoice amount
const inv = await this.tenant.query('SELECT amount FROM invoices WHERE id = $1', [invoiceId]);
if (!inv.length) return;
const invoiceAmt = parseFloat(inv[0].amount);
let newStatus: string;
if (totalPaid >= invoiceAmt) {
newStatus = 'paid';
} else if (totalPaid > 0) {
newStatus = 'partial';
} else {
newStatus = 'pending';
}
await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = $4`,
[totalPaid, newStatus, newStatus, invoiceId],
);
}
} }

View File

@@ -7,7 +7,7 @@ export class ProjectsService {
async findAll() { async findAll() {
const projects = await this.tenant.query( const projects = await this.tenant.query(
'SELECT * FROM projects WHERE is_active = true ORDER BY name', 'SELECT * FROM projects WHERE is_active = true ORDER BY planned_date NULLS LAST, target_year NULLS LAST, target_month NULLS LAST, name',
); );
return this.computeFunding(projects); return this.computeFunding(projects);
} }
@@ -20,7 +20,7 @@ export class ProjectsService {
async findForPlanning() { async findForPlanning() {
const projects = await this.tenant.query( const projects = await this.tenant.query(
'SELECT * FROM projects WHERE is_active = true AND target_year IS NOT NULL ORDER BY target_year, target_month NULLS LAST, priority', 'SELECT * FROM projects WHERE is_active = true ORDER BY target_year NULLS LAST, target_month NULLS LAST, priority',
); );
return this.computeFunding(projects); return this.computeFunding(projects);
} }
@@ -157,6 +157,9 @@ export class ProjectsService {
const params: any[] = []; const params: any[] = [];
let idx = 1; let idx = 1;
// Date columns must be null (not empty string) for PostgreSQL DATE type
const dateFields = new Set(['last_replacement_date', 'next_replacement_date', 'planned_date']);
// Build dynamic SET clause // Build dynamic SET clause
const fields: [string, string][] = [ const fields: [string, string][] = [
['name', 'name'], ['description', 'description'], ['category', 'category'], ['name', 'name'], ['description', 'description'], ['category', 'category'],
@@ -175,7 +178,8 @@ export class ProjectsService {
for (const [dtoKey, dbCol] of fields) { for (const [dtoKey, dbCol] of fields) {
if (dto[dtoKey] !== undefined) { if (dto[dtoKey] !== undefined) {
sets.push(`${dbCol} = $${idx++}`); sets.push(`${dbCol} = $${idx++}`);
params.push(dto[dtoKey]); const val = dateFields.has(dtoKey) && dto[dtoKey] === '' ? null : dto[dtoKey];
params.push(val);
} }
} }
@@ -276,7 +280,7 @@ export class ProjectsService {
await this.findOne(id); await this.findOne(id);
const rows = await this.tenant.query( const rows = await this.tenant.query(
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *', 'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
[id, planned_date], [id, planned_date || null],
); );
return rows[0]; return rows[0];
} }

View File

@@ -24,8 +24,16 @@ export class ReportsController {
} }
@Get('cash-flow-sankey') @Get('cash-flow-sankey')
getCashFlowSankey(@Query('year') year?: string) { getCashFlowSankey(
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear()); @Query('year') year?: string,
@Query('source') source?: string,
@Query('fundType') fundType?: string,
) {
return this.reportsService.getCashFlowSankey(
parseInt(year || '') || new Date().getFullYear(),
source || 'actuals',
fundType || 'all',
);
} }
@Get('cash-flow') @Get('cash-flow')
@@ -66,4 +74,20 @@ export class ReportsController {
const mo = Math.min(parseInt(months || '') || 24, 48); const mo = Math.min(parseInt(months || '') || 24, 48);
return this.reportsService.getCashFlowForecast(yr, mo); return this.reportsService.getCashFlowForecast(yr, mo);
} }
@Get('quarterly')
getQuarterlyFinancial(
@Query('year') year?: string,
@Query('quarter') quarter?: string,
) {
const now = new Date();
const defaultYear = now.getFullYear();
// Default to last complete quarter
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4;
const defaultQYear = currentQuarter > 1 ? defaultYear : defaultYear - 1;
const yr = parseInt(year || '') || defaultQYear;
const q = Math.min(Math.max(parseInt(quarter || '') || defaultQuarter, 1), 4);
return this.reportsService.getQuarterlyFinancial(yr, q);
}
} }

View File

@@ -14,10 +14,12 @@ export class ReportsService {
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance END as balance
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN (
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id journal_entry_lines jel
AND je.is_posted = true AND je.is_void = false INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.entry_date <= $1 AND je.is_posted = true AND je.is_void = false
AND je.entry_date <= $1
) ON jel.account_id = a.id
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity') WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
HAVING CASE HAVING CASE
@@ -32,6 +34,71 @@ export class ReportsService {
const liabilities = rows.filter((r: any) => r.account_type === 'liability'); const liabilities = rows.filter((r: any) => r.account_type === 'liability');
const equity = rows.filter((r: any) => r.account_type === 'equity'); const equity = rows.filter((r: any) => r.account_type === 'equity');
// Compute current year net income (income - expenses) for the fiscal year through as_of date
// This balances the accounting equation: Assets = Liabilities + Equity + Net Income
const fiscalYearStart = `${asOf.substring(0, 4)}-01-01`;
const netIncomeSql = `
SELECT
COALESCE(SUM(CASE WHEN a.account_type = 'income'
THEN jel.credit - jel.debit ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
THEN jel.debit - jel.credit ELSE 0 END), 0) as net_income
FROM journal_entry_lines jel
INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
INNER JOIN accounts a ON a.id = jel.account_id
AND a.account_type IN ('income', 'expense') AND a.is_active = true
`;
const netIncomeResult = await this.tenant.query(netIncomeSql, [fiscalYearStart, asOf]);
const netIncome = parseFloat(netIncomeResult[0]?.net_income || '0');
// Add current year net income as a synthetic equity line
if (netIncome !== 0) {
equity.push({
id: null,
account_number: '',
name: 'Current Year Net Income',
account_type: 'equity',
fund_type: 'operating',
balance: netIncome.toFixed(2),
});
}
// Add investment account balances to assets and corresponding equity
const investmentsSql = `
SELECT id, name, institution, current_value as balance, fund_type
FROM investment_accounts
WHERE is_active = true AND current_value > 0
`;
const investments = await this.tenant.query(investmentsSql);
const investmentsByFund: Record<string, number> = {};
for (const inv of investments) {
assets.push({
id: inv.id,
account_number: '',
name: `${inv.name} (${inv.institution})`,
account_type: 'asset',
fund_type: inv.fund_type,
balance: parseFloat(inv.balance).toFixed(2),
});
investmentsByFund[inv.fund_type] = (investmentsByFund[inv.fund_type] || 0) + parseFloat(inv.balance);
}
// Add investment balances as synthetic equity lines to maintain A = L + E
for (const [fundType, total] of Object.entries(investmentsByFund)) {
if (total > 0) {
const label = fundType === 'reserve' ? 'Reserve' : 'Operating';
equity.push({
id: null,
account_number: '',
name: `${label} Investment Holdings`,
account_type: 'equity',
fund_type: fundType,
balance: total.toFixed(2),
});
}
}
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0); const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0); const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0); const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
@@ -54,10 +121,12 @@ export class ReportsService {
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount END as amount
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN (
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id journal_entry_lines jel
AND je.is_posted = true AND je.is_void = false INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.entry_date BETWEEN $1 AND $2 AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
) ON jel.account_id = a.id
WHERE a.is_active = true AND a.account_type IN ('income', 'expense') WHERE a.is_active = true AND a.account_type IN ('income', 'expense')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
HAVING CASE HAVING CASE
@@ -83,33 +152,151 @@ export class ReportsService {
}; };
} }
async getCashFlowSankey(year: number) { async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
// Get income accounts with amounts let income: any[];
const income = await this.tenant.query(` let expenses: any[];
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, [year]);
const expenses = await this.tenant.query(` const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : '';
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount const fundParams = fundType !== 'all' ? [year, fundType] : [year];
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id const monthSum = `COALESCE(b.jan,0)+COALESCE(b.feb,0)+COALESCE(b.mar,0)+COALESCE(b.apr,0)+COALESCE(b.may,0)+COALESCE(b.jun,0)+COALESCE(b.jul,0)+COALESCE(b.aug,0)+COALESCE(b.sep,0)+COALESCE(b.oct,0)+COALESCE(b.nov,0)+COALESCE(b.dec_amt,0)`;
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false if (source === 'budget') {
AND EXTRACT(YEAR FROM je.entry_date) = $1 income = await this.tenant.query(`
WHERE a.account_type = 'expense' AND a.is_active = true SELECT a.name, SUM(${monthSum}) as amount
GROUP BY a.id, a.name, a.fund_type FROM budgets b
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0 JOIN accounts a ON a.id = b.account_id
ORDER BY amount DESC WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
`, [year]); GROUP BY a.id, a.name
HAVING SUM(${monthSum}) > 0
ORDER BY SUM(${monthSum}) DESC
`, fundParams);
expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, SUM(${monthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
HAVING SUM(${monthSum}) > 0
ORDER BY SUM(${monthSum}) DESC
`, fundParams);
} else if (source === 'forecast') {
// Combine actuals (Jan to current date) + budget (remaining months)
const now = new Date();
const currentMonth = now.getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
const remainingMonths = monthNames.slice(currentMonth + 1);
const actualsFundCond = fundType !== 'all' ? ' AND a.fund_type = $2' : '';
const actualsParams: any[] = fundType !== 'all' ? [`${year}-01-01`, fundType] : [`${year}-01-01`];
const actualsIncome = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
WHERE a.account_type = 'income' AND a.is_active = true${actualsFundCond}
GROUP BY a.id, a.name
`, actualsParams);
const actualsExpenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
WHERE a.account_type = 'expense' AND a.is_active = true${actualsFundCond}
GROUP BY a.id, a.name, a.fund_type
`, actualsParams);
// Budget for remaining months
let budgetIncome: any[] = [];
let budgetExpenses: any[] = [];
if (remainingMonths.length > 0) {
const budgetMonthSum = remainingMonths.map(m => `COALESCE(b.${m},0)`).join('+');
budgetIncome = await this.tenant.query(`
SELECT a.name, SUM(${budgetMonthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
`, fundParams);
budgetExpenses = await this.tenant.query(`
SELECT a.name, a.fund_type, SUM(${budgetMonthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
`, fundParams);
}
// Merge actuals + budget by account name
const incomeMap = new Map<string, number>();
for (const a of actualsIncome) {
const amt = parseFloat(a.amount) || 0;
if (amt > 0) incomeMap.set(a.name, (incomeMap.get(a.name) || 0) + amt);
}
for (const b of budgetIncome) {
const amt = parseFloat(b.amount) || 0;
if (amt > 0) incomeMap.set(b.name, (incomeMap.get(b.name) || 0) + amt);
}
income = Array.from(incomeMap.entries())
.map(([name, amount]) => ({ name, amount: String(amount) }))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
const expenseMap = new Map<string, { amount: number; fund_type: string }>();
for (const a of actualsExpenses) {
const amt = parseFloat(a.amount) || 0;
if (amt > 0) {
const existing = expenseMap.get(a.name);
expenseMap.set(a.name, { amount: (existing?.amount || 0) + amt, fund_type: a.fund_type });
}
}
for (const b of budgetExpenses) {
const amt = parseFloat(b.amount) || 0;
if (amt > 0) {
const existing = expenseMap.get(b.name);
expenseMap.set(b.name, { amount: (existing?.amount || 0) + amt, fund_type: b.fund_type });
}
}
expenses = Array.from(expenseMap.entries())
.map(([name, { amount, fund_type }]) => ({ name, amount: String(amount), fund_type }))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
} else {
// Actuals: query journal entries for the year
income = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, fundParams);
expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
ORDER BY amount DESC
`, fundParams);
}
if (!income.length && !expenses.length) { if (!income.length && !expenses.length) {
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 }; return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
@@ -222,20 +409,20 @@ export class ReportsService {
ORDER BY a.name ORDER BY a.name
`, [from, to]); `, [from, to]);
// Asset filter: cash-only vs cash + investment accounts // Asset filter: all asset accounts (bank/checking/savings are the cash accounts)
const assetFilter = includeInvestments const assetFilter = `a.account_type = 'asset'`;
? `a.account_type = 'asset'`
: `a.account_type = 'asset' AND a.name LIKE '%Cash%'`;
// Cash beginning and ending balances // Cash beginning and ending balances
const beginCash = await this.tenant.query(` const beginCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM ( SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN (
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id journal_entry_lines jel
AND je.is_posted = true AND je.is_void = false INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.entry_date < $1 AND je.is_posted = true AND je.is_void = false
AND je.entry_date < $1
) ON jel.account_id = a.id
WHERE ${assetFilter} AND a.is_active = true WHERE ${assetFilter} AND a.is_active = true
GROUP BY a.id GROUP BY a.id
) sub ) sub
@@ -245,10 +432,12 @@ export class ReportsService {
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM ( SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN (
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id journal_entry_lines jel
AND je.is_posted = true AND je.is_void = false INNER JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.entry_date <= $1 AND je.is_posted = true AND je.is_void = false
AND je.entry_date <= $1
) ON jel.account_id = a.id
WHERE ${assetFilter} AND a.is_active = true WHERE ${assetFilter} AND a.is_active = true
GROUP BY a.id GROUP BY a.id
) sub ) sub
@@ -273,7 +462,8 @@ export class ReportsService {
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0); const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0); const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0); const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance; // Only include investment balances in ending balance when includeInvestments is toggled on
const endingBalance = parseFloat(endCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
return { return {
from, to, from, to,
@@ -360,19 +550,22 @@ export class ReportsService {
const incomeStmt = await this.getIncomeStatement(from, to); const incomeStmt = await this.getIncomeStatement(from, to);
const balanceSheet = await this.getBalanceSheet(to); const balanceSheet = await this.getBalanceSheet(to);
// 1099 vendor data // 1099 vendor data — uses journal entries via vendor's default_account_id
const vendors1099 = await this.tenant.query(` const vendors1099 = await this.tenant.query(`
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code, SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
COALESCE(SUM(p.amount), 0) as total_paid COALESCE(SUM(p_amounts.amount), 0) as total_paid
FROM vendors v FROM vendors v
JOIN ( LEFT JOIN (
SELECT vendor_id, amount FROM invoices SELECT jel.account_id, jel.debit as amount
WHERE EXTRACT(YEAR FROM invoice_date) = $1 FROM journal_entry_lines jel
AND status IN ('paid', 'partial') JOIN journal_entries je ON je.id = jel.journal_entry_id
) p ON p.vendor_id = v.id WHERE je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
AND jel.debit > 0
) p_amounts ON p_amounts.account_id = v.default_account_id
WHERE v.is_1099_eligible = true WHERE v.is_1099_eligible = true
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
HAVING COALESCE(SUM(p.amount), 0) >= 600 HAVING COALESCE(SUM(p_amounts.amount), 0) >= 600
ORDER BY v.name ORDER BY v.name
`, [year]); `, [year]);
@@ -444,24 +637,43 @@ export class ReportsService {
} }
async getDashboardKPIs() { async getDashboardKPIs() {
// Total cash: ALL asset accounts (not just those named "Cash") // Operating cash (asset accounts, fund_type=operating)
// Uses proper double-entry balance: debit - credit for assets const opCash = await this.tenant.query(`
const cash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM accounts a FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.is_active = true WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
GROUP BY a.id GROUP BY a.id
) sub ) sub
`); `);
// Also include investment account current_value in total cash // Reserve cash (asset accounts, fund_type=reserve)
const investmentCash = await this.tenant.query(` const resCash = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
FROM investment_accounts WHERE is_active = true SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
GROUP BY a.id
) sub
`); `);
const totalCash = parseFloat(cash[0]?.total || '0') + parseFloat(investmentCash[0]?.total || '0'); // Investment accounts split by fund type
const opInv = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
`);
const resInv = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
const operatingCash = parseFloat(opCash[0]?.total || '0');
const reserveCash = parseFloat(resCash[0]?.total || '0');
const operatingInvestments = parseFloat(opInv[0]?.total || '0');
const reserveInvestments = parseFloat(resInv[0]?.total || '0');
const totalCash = operatingCash + reserveCash + operatingInvestments + reserveInvestments;
// Receivables: sum of unpaid invoices // Receivables: sum of unpaid invoices
const ar = await this.tenant.query(` const ar = await this.tenant.query(`
@@ -469,9 +681,7 @@ export class ReportsService {
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off') FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
`); `);
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100) // Reserve fund balance via equity accounts + reserve investments
// The equity accounts track the total reserve fund position via double-entry bookkeeping
// This is the standard HOA approach — every reserve contribution/expenditure flows through equity
const reserves = await this.tenant.query(` const reserves = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
@@ -482,17 +692,67 @@ export class ReportsService {
GROUP BY a.id GROUP BY a.id
) sub ) sub
`); `);
// Add reserve investment account values to the reserve fund total
const reserveInvestments = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
// Delinquent count (overdue invoices) // Delinquent count (overdue invoices)
const delinquent = await this.tenant.query(` const delinquent = await this.tenant.query(`
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue' SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
`); `);
// Monthly interest estimate from accounts + investments with rates
const acctInterest = await this.tenant.query(`
SELECT COALESCE(SUM(sub.monthly_interest), 0) as total FROM (
SELECT (COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) * (a.interest_rate / 100) / 12 as monthly_interest
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.is_active = true AND a.interest_rate > 0
GROUP BY a.id, a.interest_rate
) sub
`);
const acctInterestTotal = parseFloat(acctInterest[0]?.total || '0');
const invInterest = await this.tenant.query(`
SELECT COALESCE(SUM(current_value * interest_rate / 100 / 12), 0) as total
FROM investment_accounts WHERE is_active = true AND interest_rate > 0
`);
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
// Interest earned YTD: actual interest income from journal entries for current year
const currentYear = new Date().getFullYear();
const interestEarned = 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]);
// 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 capitalSpend = await this.tenant.query(`
SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
`, [currentYear]);
// Recent transactions // Recent transactions
const recentTx = await this.tenant.query(` const recentTx = await this.tenant.query(`
SELECT je.id, je.entry_date, je.description, je.entry_type, SELECT je.id, je.entry_date, je.description, je.entry_type,
@@ -504,9 +764,19 @@ export class ReportsService {
return { return {
total_cash: totalCash.toFixed(2), total_cash: totalCash.toFixed(2),
total_receivables: ar[0]?.total || '0.00', total_receivables: ar[0]?.total || '0.00',
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2), reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + reserveInvestments).toFixed(2),
delinquent_units: parseInt(delinquent[0]?.count || '0'), delinquent_units: parseInt(delinquent[0]?.count || '0'),
recent_transactions: recentTx, recent_transactions: recentTx,
// Enhanced split data
operating_cash: operatingCash.toFixed(2),
reserve_cash: reserveCash.toFixed(2),
operating_investments: operatingInvestments.toFixed(2),
reserve_investments: reserveInvestments.toFixed(2),
est_monthly_interest: estMonthlyInterest.toFixed(2),
interest_earned_ytd: 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',
}; };
} }
@@ -594,15 +864,37 @@ export class ReportsService {
// We need budgets for startYear and startYear+1 to cover 24 months // We need budgets for startYear and startYear+1 to cover 24 months
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {}; const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
for (const yr of [startYear, startYear + 1, startYear + 2]) { const endYear = startYear + Math.ceil(months / 12) + 1;
const budgetRows = await this.tenant.query( for (let yr = startYear; yr <= endYear; yr++) {
`SELECT b.fund_type, a.account_type, let budgetRows: any[];
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, try {
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt budgetRows = await this.tenant.query(
FROM budgets b `SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
JOIN accounts a ON a.id = b.account_id SELECT b.account_id, b.fund_type, a.account_type,
WHERE b.fiscal_year = $1`, [yr], 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
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++) { for (let m = 0; m < 12; m++) {
const key = `${yr}-${m + 1}`; const key = `${yr}-${m + 1}`;
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 }; if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
@@ -795,4 +1087,168 @@ export class ReportsService {
datapoints, datapoints,
}; };
} }
/**
* Quarterly Financial Report: quarter income statement, YTD income statement,
* budget vs actuals for the quarter and YTD, and over-budget items.
*/
async getQuarterlyFinancial(year: number, quarter: number) {
// Quarter date ranges
const qStartMonths = [1, 4, 7, 10];
const qEndMonths = [3, 6, 9, 12];
const qStart = `${year}-${String(qStartMonths[quarter - 1]).padStart(2, '0')}-01`;
const qEndMonth = qEndMonths[quarter - 1];
const qEndDay = [31, 30, 30, 31][quarter - 1]; // Mar=31, Jun=30, Sep=30, Dec=31
const qEnd = `${year}-${String(qEndMonth).padStart(2, '0')}-${qEndDay}`;
const ytdStart = `${year}-01-01`;
// Quarter and YTD income statements (reuse existing method)
const quarterIS = await this.getIncomeStatement(qStart, qEnd);
const ytdIS = await this.getIncomeStatement(ytdStart, qEnd);
// Budget data for the quarter months
const budgetMonthCols = {
1: ['jan', 'feb', 'mar'],
2: ['apr', 'may', 'jun'],
3: ['jul', 'aug', 'sep'],
4: ['oct', 'nov', 'dec_amt'],
} as Record<number, string[]>;
const ytdMonthCols = {
1: ['jan', 'feb', 'mar'],
2: ['jan', 'feb', 'mar', 'apr', 'may', 'jun'],
3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep'],
4: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'],
} as Record<number, string[]>;
const qCols = budgetMonthCols[quarter];
const ytdCols = ytdMonthCols[quarter];
const budgetRows = await this.tenant.query(
`SELECT b.account_id, a.account_number, a.name, a.account_type, a.fund_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1`, [year],
);
// Actual amounts per account for the quarter and YTD
const quarterActuals = await this.tenant.query(`
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
`, [qStart, qEnd]);
const ytdActuals = await this.tenant.query(`
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
`, [ytdStart, qEnd]);
// Build budget vs actual comparison
const actualsByIdQ = new Map<string, number>();
for (const a of quarterActuals) {
actualsByIdQ.set(a.account_id, parseFloat(a.amount) || 0);
}
const actualsByIdYTD = new Map<string, number>();
for (const a of ytdActuals) {
actualsByIdYTD.set(a.account_id, parseFloat(a.amount) || 0);
}
const budgetVsActual: any[] = [];
const overBudgetItems: any[] = [];
for (const b of budgetRows) {
const qBudget = qCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
const ytdBudget = ytdCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
const qActual = actualsByIdQ.get(b.account_id) || 0;
const ytdActual = actualsByIdYTD.get(b.account_id) || 0;
if (qBudget === 0 && ytdBudget === 0 && qActual === 0 && ytdActual === 0) continue;
const qVariance = qActual - qBudget;
const ytdVariance = ytdActual - ytdBudget;
const isExpense = b.account_type === 'expense';
const item = {
account_id: b.account_id,
account_number: b.account_number,
name: b.name,
account_type: b.account_type,
fund_type: b.fund_type,
quarter_budget: qBudget,
quarter_actual: qActual,
quarter_variance: qVariance,
ytd_budget: ytdBudget,
ytd_actual: ytdActual,
ytd_variance: ytdVariance,
};
budgetVsActual.push(item);
// Flag expenses over budget by more than 10%
if (isExpense && qBudget > 0 && qActual > qBudget * 1.1) {
overBudgetItems.push({
...item,
variance_pct: ((qActual / qBudget - 1) * 100).toFixed(1),
});
}
}
// Also include accounts with actuals but no budget
for (const a of quarterActuals) {
if (!budgetRows.find((b: any) => b.account_id === a.account_id)) {
const ytdActual = actualsByIdYTD.get(a.account_id) || 0;
budgetVsActual.push({
account_id: a.account_id,
account_number: a.account_number,
name: a.name,
account_type: a.account_type,
fund_type: a.fund_type,
quarter_budget: 0,
quarter_actual: parseFloat(a.amount) || 0,
quarter_variance: parseFloat(a.amount) || 0,
ytd_budget: 0,
ytd_actual: ytdActual,
ytd_variance: ytdActual,
});
}
}
// Sort: income first, then expenses, both by account number
budgetVsActual.sort((a: any, b: any) => {
if (a.account_type !== b.account_type) return a.account_type === 'income' ? -1 : 1;
return (a.account_number || '').localeCompare(b.account_number || '');
});
return {
year,
quarter,
quarter_label: `Q${quarter} ${year}`,
date_range: { from: qStart, to: qEnd },
quarter_income_statement: quarterIS,
ytd_income_statement: ytdIS,
budget_vs_actual: budgetVsActual,
over_budget_items: overBudgetItems,
};
}
} }

View File

@@ -49,6 +49,9 @@ export class User {
@Column({ name: 'is_platform_owner', default: false }) @Column({ name: 'is_platform_owner', default: false })
isPlatformOwner: boolean; isPlatformOwner: boolean;
@Column({ name: 'has_seen_intro', default: false })
hasSeenIntro: boolean;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt: Date; lastLoginAt: Date;

View File

@@ -57,6 +57,10 @@ export class UsersService {
`); `);
} }
async markIntroSeen(id: string): Promise<void> {
await this.usersRepository.update(id, { hasSeenIntro: true });
}
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> { async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
// Protect platform owner from having superadmin removed // Protect platform owner from having superadmin removed
const user = await this.usersRepository.findOne({ where: { id: userId } }); const user = await this.usersRepository.findOne({ where: { id: userId } });

View File

@@ -17,10 +17,10 @@ export class VendorsService {
async create(dto: any) { async create(dto: any) {
const rows = await this.tenant.query( const rows = await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id) `INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id, last_negotiated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code, [dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code,
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null], dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null, dto.last_negotiated || null],
); );
return rows[0]; return rows[0];
} }
@@ -32,24 +32,25 @@ export class VendorsService {
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1), email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code), city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code),
tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible), tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible),
default_account_id = COALESCE($12, default_account_id), updated_at = NOW() default_account_id = COALESCE($12, default_account_id), last_negotiated = $13, updated_at = NOW()
WHERE id = $1 RETURNING *`, WHERE id = $1 RETURNING *`,
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, [id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id], dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id, dto.last_negotiated || null],
); );
return rows[0]; return rows[0];
} }
async exportCSV(): Promise<string> { async exportCSV(): Promise<string> {
const rows = await this.tenant.query( const rows = await this.tenant.query(
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible `SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated
FROM vendors WHERE is_active = true ORDER BY name`, FROM vendors WHERE is_active = true ORDER BY name`,
); );
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible']; const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible', 'last_negotiated'];
const lines = [headers.join(',')]; const lines = [headers.join(',')];
for (const r of rows) { for (const r of rows) {
lines.push(headers.map((h) => { lines.push(headers.map((h) => {
const v = r[h] ?? ''; let v = r[h] ?? '';
if (v instanceof Date) v = v.toISOString().split('T')[0];
const s = String(v); const s = String(v);
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s; return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
}).join(',')); }).join(','));
@@ -80,20 +81,22 @@ export class VendorsService {
zip_code = COALESCE(NULLIF($8, ''), zip_code), zip_code = COALESCE(NULLIF($8, ''), zip_code),
tax_id = COALESCE(NULLIF($9, ''), tax_id), tax_id = COALESCE(NULLIF($9, ''), tax_id),
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible), is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
updated_at = NOW() updated_at = NOW()
WHERE id = $1`, WHERE id = $1`,
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1, [existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible], row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible, row.last_negotiated],
); );
updated++; updated++;
} else { } else {
await this.tenant.query( await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible) `INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[name, row.contact_name || null, row.email || null, row.phone || null, [name, row.contact_name || null, row.email || null, row.phone || null,
row.address_line1 || null, row.city || null, row.state || null, row.address_line1 || null, row.city || null, row.state || null,
row.zip_code || null, row.tax_id || null, row.zip_code || null, row.tax_id || null,
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false], row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false,
row.last_negotiated || null],
); );
created++; created++;
} }

View File

@@ -77,7 +77,8 @@ CREATE TABLE shared.invitations (
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
-- CD Rates (cross-tenant market data for investment recommendations) -- Market Rates (cross-tenant market data for investment recommendations)
-- Supports CD, Money Market, and High Yield Savings rate types
CREATE TABLE shared.cd_rates ( CREATE TABLE shared.cd_rates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bank_name VARCHAR(255) NOT NULL, bank_name VARCHAR(255) NOT NULL,
@@ -85,6 +86,7 @@ CREATE TABLE shared.cd_rates (
min_deposit DECIMAL(15,2), min_deposit DECIMAL(15,2),
term VARCHAR(100) NOT NULL, term VARCHAR(100) NOT NULL,
term_months INTEGER, term_months INTEGER,
rate_type VARCHAR(50) NOT NULL DEFAULT 'cd',
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url VARCHAR(500), source_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
@@ -121,6 +123,8 @@ CREATE INDEX idx_invitations_token ON shared.invitations(token);
CREATE INDEX idx_invitations_email ON shared.invitations(email); CREATE INDEX idx_invitations_email ON shared.invitations(email);
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC); CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC); CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);
CREATE INDEX idx_cd_rates_type ON shared.cd_rates(rate_type);
CREATE INDEX idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC); CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC);
CREATE INDEX idx_login_history_user ON shared.login_history(user_id); CREATE INDEX idx_login_history_user ON shared.login_history(user_id);
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC); CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);

View File

@@ -0,0 +1,36 @@
-- Migration: Expand cd_rates for multiple market rate types + tenant AI recommendation storage
-- Phase 6: AI Features Part 2
-- 1) Add rate_type column to shared.cd_rates to support CD, Money Market, and High Yield Savings
ALTER TABLE shared.cd_rates
ADD COLUMN IF NOT EXISTS rate_type VARCHAR(50) DEFAULT 'cd' NOT NULL;
-- Index for filtering by rate type
CREATE INDEX IF NOT EXISTS idx_cd_rates_type ON shared.cd_rates(rate_type);
-- Composite index for getting latest rates by type efficiently
CREATE INDEX IF NOT EXISTS idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
-- 2) Create ai_recommendations table in each existing tenant schema
-- This stores saved AI investment recommendations per tenant
-- For new tenants, this is handled by tenant-schema.service.ts
DO $$
DECLARE
tenant_schema TEXT;
BEGIN
FOR tenant_schema IN
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I.ai_recommendations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recommendations_json JSONB NOT NULL,
overall_assessment TEXT,
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
)', tenant_schema
);
END LOOP;
END $$;

View File

@@ -0,0 +1,16 @@
-- Migration: Add last_negotiated date to vendors table
-- Bug & Tweak Sprint
DO $$
DECLARE
tenant_schema TEXT;
BEGIN
FOR tenant_schema IN
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
LOOP
EXECUTE format(
'ALTER TABLE %I.vendors ADD COLUMN IF NOT EXISTS last_negotiated DATE',
tenant_schema
);
END LOOP;
END $$;

View File

@@ -0,0 +1,9 @@
-- Migration: Add onboarding tracking flag to users table
-- Phase 7: Onboarding Features
BEGIN;
ALTER TABLE shared.users
ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
COMMIT;

View File

@@ -0,0 +1,34 @@
-- Migration: Add health_scores table to all tenant schemas
-- This table stores AI-derived operating and reserve fund health scores
DO $$
DECLARE
tenant RECORD;
BEGIN
FOR tenant IN
SELECT schema_name FROM shared.organizations WHERE status = 'active'
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I.health_scores (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
score_type VARCHAR(20) NOT NULL CHECK (score_type IN (''operating'', ''reserve'')),
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
previous_score INTEGER,
trajectory VARCHAR(20) CHECK (trajectory IN (''improving'', ''stable'', ''declining'')),
label VARCHAR(30),
summary TEXT,
factors JSONB,
recommendations JSONB,
missing_data JSONB,
status VARCHAR(20) NOT NULL DEFAULT ''complete'' CHECK (status IN (''complete'', ''pending'', ''error'')),
response_time_ms INTEGER,
calculated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
)', tenant.schema_name
);
EXECUTE format(
'CREATE INDEX IF NOT EXISTS idx_%s_hs_type_calc ON %I.health_scores(score_type, calculated_at DESC)',
replace(tenant.schema_name, '.', '_'), tenant.schema_name
);
END LOOP;
END $$;

View File

@@ -0,0 +1,57 @@
-- Migration 011: Add billing frequency support to invoices
-- Adds due_months and due_day to assessment_groups
-- Adds period_start, period_end, assessment_group_id to invoices
DO $$
DECLARE
v_schema TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Add due_months and due_day to assessment_groups
EXECUTE format('
ALTER TABLE %I.assessment_groups
ADD COLUMN IF NOT EXISTS due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
ADD COLUMN IF NOT EXISTS due_day INTEGER DEFAULT 1
', v_schema);
-- Add period tracking and assessment group link to invoices
EXECUTE format('
ALTER TABLE %I.invoices
ADD COLUMN IF NOT EXISTS period_start DATE,
ADD COLUMN IF NOT EXISTS period_end DATE,
ADD COLUMN IF NOT EXISTS assessment_group_id UUID
', v_schema);
-- Backfill due_months based on existing frequency values
EXECUTE format('
UPDATE %I.assessment_groups
SET due_months = CASE frequency
WHEN ''quarterly'' THEN ''{1,4,7,10}''::INTEGER[]
WHEN ''annual'' THEN ''{1}''::INTEGER[]
ELSE ''{1,2,3,4,5,6,7,8,9,10,11,12}''::INTEGER[]
END
WHERE due_months IS NULL OR due_months = ''{1,2,3,4,5,6,7,8,9,10,11,12}''
AND frequency != ''monthly''
', v_schema);
-- Backfill period_start/period_end for existing invoices (all monthly)
EXECUTE format('
UPDATE %I.invoices
SET period_start = invoice_date,
period_end = (invoice_date + INTERVAL ''1 month'' - INTERVAL ''1 day'')::DATE
WHERE period_start IS NULL AND invoice_type = ''regular_assessment''
', v_schema);
-- Backfill assessment_group_id on existing invoices from units
EXECUTE format('
UPDATE %I.invoices i
SET assessment_group_id = u.assessment_group_id
FROM %I.units u
WHERE i.unit_id = u.id AND i.assessment_group_id IS NULL
', v_schema, v_schema);
END LOOP;
END $$;

View File

@@ -0,0 +1,33 @@
-- Migration 012: Replace 'sent' status with 'pending' for invoices
-- 'sent' implied email delivery which doesn't exist; 'pending' is more accurate
DO $$
DECLARE
v_schema TEXT;
v_constraint TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Find and drop the existing status check constraint
SELECT constraint_name INTO v_constraint
FROM information_schema.table_constraints
WHERE table_schema = v_schema
AND table_name = 'invoices'
AND constraint_type = 'CHECK'
AND constraint_name LIKE '%status%';
IF v_constraint IS NOT NULL THEN
EXECUTE format('ALTER TABLE %I.invoices DROP CONSTRAINT %I', v_schema, v_constraint);
END IF;
-- Add new constraint that includes 'pending'
EXECUTE format('ALTER TABLE %I.invoices ADD CONSTRAINT invoices_status_check CHECK (status IN (
''draft'', ''pending'', ''sent'', ''paid'', ''partial'', ''overdue'', ''void'', ''written_off''
))', v_schema);
-- Convert existing 'sent' invoices to 'pending'
EXECUTE format('UPDATE %I.invoices SET status = ''pending'' WHERE status = ''sent''', v_schema);
END LOOP;
END $$;

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

@@ -204,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
special_assessment DECIMAL(10,2) DEFAULT 0.00, special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0, unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT ''monthly'', frequency VARCHAR(20) DEFAULT ''monthly'',
due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
due_day INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
)', v_schema); )', v_schema);
@@ -244,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices (
amount DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00, amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT ''draft'', status VARCHAR(20) DEFAULT ''draft'',
period_start DATE,
period_end DATE,
assessment_group_id UUID,
journal_entry_id UUID, journal_entry_id UUID,
sent_at TIMESTAMPTZ, sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ, paid_at TIMESTAMPTZ,
@@ -443,10 +449,10 @@ END LOOP;
-- ============================================================ -- ============================================================
-- 4b. Seed Assessment Groups -- 4b. Seed Assessment Groups
-- ============================================================ -- ============================================================
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day) VALUES
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20), (''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 15),
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15), (''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 1275.00, 0.00, 15, ''quarterly'', ''{1,4,7,10}'', 1),
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15) (''Estate Lots'', ''Large estate lots (Units 36-50)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1)
', v_schema); ', v_schema);
-- ============================================================ -- ============================================================

95
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,95 @@
# Production override — use with:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# What this changes from the base (dev) config:
# - Disables the Docker nginx container (host nginx handles routing + SSL)
# - Backend: production Dockerfile (compiled JS, no watch, no devDeps)
# - Frontend: production Dockerfile (static build served by nginx on port 3001)
# - Backend + Frontend bound to 127.0.0.1 only (host nginx proxies to them)
# - No source-code volume mounts (uses baked-in built code)
# - Memory limits and health checks on backend
# - Tuned PostgreSQL for production workloads
# - Restart policies for reliability
#
# SSL/TLS and request routing are handled by the host-level nginx.
# See nginx/host-production.conf for a ready-to-use reference config.
services:
nginx:
# Disabled in production — host nginx handles routing + SSL directly.
# The dev-only Docker nginx is still used by the base docker-compose.yml.
deploy:
replicas: 0
backend:
build:
context: ./backend
dockerfile: Dockerfile # production Dockerfile (compiled JS)
ports:
- "127.0.0.1:3000:3000" # loopback only — host nginx proxies here
volumes: [] # override: no source mounts in prod
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- JWT_SECRET=${JWT_SECRET}
- NODE_ENV=production
- AI_API_URL=${AI_API_URL}
- AI_API_KEY=${AI_API_KEY}
- AI_MODEL=${AI_MODEL}
- AI_DEBUG=${AI_DEBUG:-false}
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
deploy:
resources:
limits:
memory: 1024M
reservations:
memory: 256M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile # production Dockerfile (static nginx)
ports:
- "127.0.0.1:3001:3001" # loopback only — host nginx proxies here
volumes: [] # override: no source mounts in prod
environment:
- NODE_ENV=production
restart: unless-stopped
postgres:
# Tune PostgreSQL for production workloads
command: >
postgres
-c max_connections=200
-c shared_buffers=256MB
-c effective_cache_size=512MB
-c work_mem=4MB
-c maintenance_work_mem=64MB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c random_page_cost=1.1
# No host port mapping — backend reaches postgres via the Docker network.
# Removes 2 docker-proxy processes and closes 0.0.0.0:5432 to the internet.
ports: []
deploy:
resources:
limits:
memory: 1024M
reservations:
memory: 512M
restart: unless-stopped
redis:
# No host port mapping — backend reaches redis via the Docker network.
# Removes 2 docker-proxy processes and closes 0.0.0.0:6379 to the internet.
ports: []
restart: unless-stopped

28
docker-compose.ssl.yml Normal file
View File

@@ -0,0 +1,28 @@
# SSL override — use with: docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d
#
# This adds port 443, certbot volumes, and a certbot renewal service
# to the base docker-compose.yml configuration.
services:
nginx:
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/ssl.conf:/etc/nginx/conf.d/default.conf:ro
- certbot_www:/var/www/certbot:ro
- certbot_conf:/etc/letsencrypt:ro
certbot:
image: certbot/certbot:latest
volumes:
- certbot_www:/var/www/certbot
- certbot_conf:/etc/letsencrypt
networks:
- hoanet
# Auto-renew: check twice daily, only renews if < 30 days remain
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done'"
volumes:
certbot_www:
certbot_conf:

View File

@@ -15,8 +15,8 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
ports: # No host port mapping — dev traffic goes through the Docker nginx container.
- "3000:3000" # Production overlay maps 127.0.0.1:3000 for the host reverse proxy.
environment: environment:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
@@ -26,6 +26,24 @@ services:
- AI_API_KEY=${AI_API_KEY} - AI_API_KEY=${AI_API_KEY}
- AI_MODEL=${AI_MODEL} - AI_MODEL=${AI_MODEL}
- AI_DEBUG=${AI_DEBUG:-false} - AI_DEBUG=${AI_DEBUG:-false}
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-http://localhost/api/auth/azure/callback}
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
volumes: volumes:
- ./backend/src:/app/src - ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json - ./backend/nest-cli.json:/app/nest-cli.json
@@ -43,8 +61,8 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
ports: # No host port mapping — dev traffic goes through the Docker nginx container.
- "5173:5173" # Production overlay maps 127.0.0.1:3001 for the host reverse proxy.
environment: environment:
- NODE_ENV=${NODE_ENV} - NODE_ENV=${NODE_ENV}
volumes: volumes:

545
docs/AI_FEATURE_AUDIT.md Normal file
View File

@@ -0,0 +1,545 @@
# AI Feature Audit Report
**Audit Date:** 2026-03-05
**Tenant Under Test:** Pine Creek HOA (`tenant_pine_creek_hoa_q33i`)
**AI Model:** Qwen 3.5-397B-A17B via NVIDIA NIM (Temperature: 0.3)
**Auditor:** Claude Opus 4.6 (automated)
**Data Snapshot Date:** 2026-03-04
---
## Executive Summary
Three AI-powered features were audited against ground-truth database records: **Operating Fund Health**, **Reserve Fund Health**, and **Investment Recommendations**. Overall, the AI demonstrates strong financial reasoning and produces actionable, fiduciary-appropriate recommendations. However, score consistency across runs is a concern (16-point spread on operating, 20-point spread on reserve), and several specific data interpretation issues were identified.
| Feature | Latest Score/Grade | Concurrence | Verdict |
|---|---|---|---|
| Operating Fund Health | 88 / Good | **72%** | Score ~10-15 pts high; cash runway below its own "Good" threshold |
| Reserve Fund Health | 45 / Needs Attention | **85%** | Well-calibrated; minor data misquote on annual contributions |
| Investment Recommendations | 6 recommendations | **88%** | Excellent specificity; all market rates verified accurate |
---
## Data Foundation (Ground Truth)
### Financial Position
| Metric | Value | Source |
|---|---|---|
| Operating Cash (Checking) | $27,418.81 | GL balance |
| Reserve Cash (Savings) | $10,688.45 | GL balance |
| Reserve CD #1a (FCB) | $10,000 @ 3.67%, matures 6/19/26 | `investment_accounts` |
| Reserve CD #2a (FCB) | $8,000 @ 3.60%, matures 4/14/26 | `investment_accounts` |
| Reserve CD #3a (FCB) | $10,000 @ 3.67%, matures 8/18/26 | `investment_accounts` |
| Total Reserve Fund | $38,688.45 | Cash + Investments |
| Total Assets | $66,107.26 | Operating + Reserve |
### Budget (FY2026)
| Category | Annual Total |
|---|---|
| Operating Income | $184,207.40 |
| Operating Expense | $139,979.95 |
| **Net Operating Surplus** | **$44,227.45** |
| Monthly Expense Run Rate | $11,665.00 |
| Reserve Interest Income | $1,449.96 |
| Reserve Disbursements | $22,000.00 (Mar $13K, Apr $9K) |
### Assessment Structure
- **67 units** at $2,328.14/year regular + $300.00/year special (annual frequency)
- Total annual regular assessments: ~$155,985
- Total annual special assessments: ~$20,100
- Budget timing: assessments front-loaded in Mar-Jun
### Actuals (YTD through March 4, 2026)
| Metric | Value |
|---|---|
| YTD Income | $88.16 (ARC fees $100 - $50 adj + $38.16 interest) |
| YTD Expenses | $1,850.42 (January only) |
| Delinquent Invoices | 0 ($0.00) |
| Journal Entries Posted | 4 (Jan actuals + Feb adjusting + Feb opening balances) |
### Capital Projects (from `projects` table, 26 total)
| Project | Cost | Target | Funded % |
|---|---|---|---|
| Pond Spillway | $7,000 | Mar 2026 | 0% |
| Tuscany Drain Box | $5,500 | May 2026 | 0% |
| Front Entrance Power Washing | $1,500 | Mar 2027 | 0% |
| Irrigation Pump Replacement | $1,500 | Jun 2027 | 0% |
| **Road Sealing - All Roads** | **$80,000** | **Jun 2029** | **0%** |
| Asphalt Repair - Creek Stone Dr | $43,000 | TBD | 0% |
| Pavilion & Vineyard Structures | $7,000 | Jun 2035 | 0% |
| 16 placeholder items | $1.00 each | TBD | 0% |
| **Total Planned** | **$152,016** | | **0%** |
### Reserve Components
- **0 components tracked** (empty `reserve_components` table)
### Market Rates (fetched 2026-03-04)
| Type | Top Rate | Bank | Term |
|---|---|---|---|
| CD | 4.10% | E*TRADE / Synchrony | 12-14 mo |
| High-Yield Savings | 4.09% | Openbank | Liquid |
| Money Market | 4.03% | Vio Bank | Liquid |
---
## 1. Operating Fund Health Score
**Latest Score:** 88 (Good) — Generated 2026-03-04T19:24:36Z
**Score History:** 48 → 72 → 78 → 72 → 78 → **88** (6 runs, March 2-4)
**Overall Concurrence: 72%**
### Factor-by-Factor Analysis
#### Factor 1: "Projected Cash Flow" — Impact: Positive
> "12-month forecast shows consistent positive liquidity, with cash balances never dipping below the starting $27,419 and peaking at $142,788 in June."
| Check | Result |
|---|---|
| Budget surplus ($184K income vs $140K expense) | **Verified** ✅ |
| Assessments front-loaded Mar-Jun | **Verified** ✅ (budget shows $48K Mar, $64K Apr, $32K May, $16K Jun) |
| Peak of ~$142K in June | **Plausible** ✅ ($27K + cumulative income through June) |
| Cash never below starting $27K | **Plausible** ✅ (expenses < income by month) |
**Concurrence: 95%** Forecast logic is sound. The only risk is the assumption that assessments are collected on the exact budget schedule.
---
#### Factor 2: "Delinquency Rate" — Impact: Positive
> "$0.00 in overdue invoices and a 0.0% delinquency rate."
**Concurrence: 100%** Database confirms zero delinquent invoices.
---
#### Factor 3: "Budget Performance (Timing)" — Impact: Neutral
> "YTD income is 99.8% below budget ($55k variance) primarily due to the timing of the large Special Assessment ($20,700) and regular assessments appearing in future projected months."
| Check | Result |
|---|---|
| YTD income $88.16 | **Verified** |
| Budget includes March ($55K) in YTD calc | **Accurate** AI uses month 3 of 12, includes full March budget |
| Timing explanation | **Reasonable** we're only 4 days into March |
| Rating as "neutral" vs "negative" | **Appropriate** correctly avoids penalizing for calendar timing |
**Concurrence: 80%** The variance is accurately computed but presenting a $55K "variance" when we're 4 days into March could alarm a board member. The YTD window through month 3 includes all of March's budget despite only 4 days having elapsed. Consider computing YTD budget pro-rata or through the prior complete month.
**🔧 Tuning Suggestion:** Add a note to the prompt about pro-rating the current month's budget, or instruct the AI to note "X days into the current month" when the variance is driven by incomplete-month timing.
---
#### Factor 4: "Cash Reserves" — Impact: Positive
> "Current operating cash of $27,419 provides 2.4 months of runway based on the annual expense run rate."
| Check | Result |
|---|---|
| $27,419 / ($139,980 / 12) = 2.35 months | **Math verified** |
| Rated as "positive" | **Questionable** |
**Concurrence: 60%** The math is correct, but rating 2.4 months as "positive" contradicts the scoring guidelines which state 2-3 months = "Fair" (60-74) and 3-6 months = "Good" (75-89). This factor should be "neutral" at best, and the overall score should reflect that the HOA is *below* the "Good" threshold for cash reserves.
**🔧 Tuning Suggestion:** Add explicit guidance in the prompt: "If cash runway is below 3 months, this factor MUST be neutral or negative, regardless of projected future inflows."
---
#### Factor 5: "Expense Management" — Impact: Positive
> "YTD expenses are $36,313 under budget (4.8% of annual budget spent vs 25% of year elapsed)."
| Check | Result |
|---|---|
| YTD expenses $1,850.42 | **Verified** |
| Budget YTD (3 months): ~$38,164 | **Correct** |
| $1,850 / $38,164 = 4.85% | **Math verified** |
| "25% of year elapsed" | **Correct** (month 3 of 12) |
| Phrasing "of annual budget" | **Misleading** it's actually 4.8% of YTD budget, not annual |
**Concurrence: 70%** The percentage is correctly calculated against YTD budget, but the phrasing "of annual budget" is incorrect. Also, the low spend is not necessarily positive only January actuals exist; February hasn't been posted yet, which the AI partially acknowledges with "or delayed billing cycles."
---
### Recommendation Assessment
| # | Recommendation | Priority | Concurrence |
|---|---|---|---|
| 1 | "Verify the posting schedule for the $20,700 Special Assessment" | Low | **90%** Valid; assessments are annual, collection timing matters |
| 2 | "Investigate the low YTD expense recognition ($1,850 vs $38,164)" | Medium | **95%** Excellent catch; Feb expenses not posted yet |
| 3 | "Consider moving excess cash over $100K in Q2 to interest-bearing account" | Low | **85%** Sound advice; aligns with HY Savings at 4.09% |
**Recommendation Concurrence: 90%** All three recommendations are actionable and data-backed.
---
### Score Assessment
**Is 88 (Good) the right score?**
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|---|---|---|---|
| Cash reserves | 3-6 months for "Good" | 2.4 months | Below threshold |
| Income vs expenses | "Roughly matching" for Good | $184K vs $140K (surplus) | Exceeds |
| Delinquency | "Manageable" for Good | 0% | Excellent |
| Budget performance | No major overruns for Good | Under budget (timing) | Positive |
| Projected cash flow | Not explicitly in guidelines | Strong positive trajectory | Positive |
The cash runway of 2.4 months is below the stated "Good" (75-89) threshold of 3-6 months and technically falls in the "Fair" (60-74) range of 2-3 months. Earlier AI runs scored this 72-78, which better aligns with the guidelines. The 88 appears to overweight the projected future cash flow (which is speculative) vs the current actual position.
**Suggested correct score: 74-80** (high end of Fair to low end of Good)
---
### Score Consistency Concern
| Run Date | Score | Label |
|---|---|---|
| Mar 2 15:07 | 48 | Needs Attention |
| Mar 2 15:12 | 78 | Good |
| Mar 2 15:36 | 72 | Fair |
| Mar 2 17:09 | 78 | Good |
| Mar 3 02:03 | 72 | Fair |
| Mar 4 19:24 | 88 | Good |
A **40-point spread** (48-88) across 6 runs with essentially the same data is concerning. Even excluding the outlier first run (which noted a data config issue with "1 units"), the remaining 5 runs span 72-88 (16 points). At temperature 0.3, this suggests the model is not deterministic enough for financial scoring.
**🔧 Tuning Suggestion:** Consider lowering temperature to 0.1 for health score calculations to improve consistency. Alternatively, implement a moving average of the last 3 scores to smooth volatility.
---
## 2. Reserve Fund Health Score
**Latest Score:** 45 (Needs Attention) Generated 2026-03-04T19:24:50Z
**Score History:** 25 48 42 25 45 35 **45** (7 runs, March 2-4)
**Overall Concurrence: 85%**
### Factor-by-Factor Analysis
#### Factor 1: "Funded Ratio" — Impact: Negative
> "Calculated at 0% because no reserve components have been inventoried or assigned replacement costs, making it impossible to measure true funding health against the $152,016 in planned projects."
| Check | Result |
|---|---|
| 0 reserve components in DB | **Verified** |
| $152,016 in planned projects | **Verified** (sum of all `projects` rows) |
| 0% funded ratio | **Technically accurate** (no denominator from components) |
| Distinction between components and projects | **Well articulated** |
**Concurrence: 95%** The AI correctly identifies that the 0% is an artifact of missing reserve study data, not a literal lack of funds. It appropriately flags this as a governance failure.
---
#### Factor 2: "Projected Cash Flow" — Impact: Positive
> "Strong immediate liquidity; cash balance is projected to rise from $10,688 to over $49,000 by May 2026 due to special assessment income covering the $12,500 in urgent 2026 project costs."
| Check | Result |
|---|---|
| Starting reserve cash $10,688 | **Verified** |
| 2026 project costs: $7K (Mar) + $5.5K (May) = $12,500 | **Verified** |
| Special assessment: $300 × 67 = $20,100/year | **Verified** |
| CD maturities: $8K (Apr), $10K (Jun), $10K (Aug) | **Verified** |
| Projected rise to $49K by May | **Plausible** (income + maturities - project costs) |
**Concurrence: 85%** Math is directionally correct. However, the assessment is annual frequency so the full $20,100 may arrive in a single payment, not spread monthly. The timing assumption is critical.
---
#### Factor 3: "Component Tracking" — Impact: Negative
> "Critical failure in governance: 'No reserve components tracked' means the association is flying blind on the condition and remaining useful life of major assets like roads and irrigation."
**Concurrence: 100%** Database confirms 0 rows in `reserve_components`. This is objectively a critical gap.
---
#### Factor 4: "Annual Contributions" — Impact: Negative
> "Recurring annual reserve income is only $300 (plus minimal interest), which is grossly insufficient to fund the $80,000 road sealing project due in 2029."
| Check | Result |
|---|---|
| Reserve budget income: $1,449.96/yr (interest only) | **Verified** |
| Special assessment: $300/unit × 67 = $20,100/yr | **Verified** |
| "$300" cited as annual reserve income | **Incorrect** |
| Road Sealing $80K in June 2029 | **Verified** |
**Concurrence: 65%** The concern about insufficient contributions is valid, but the "$300" figure appears to confuse the per-unit special assessment amount ($300/unit) with the total annual reserve income. Actual annual reserve income = $1,450 (interest) + $20,100 (special assessments) = **$21,550/yr**. Even at $21,550/yr, the 3 years until Road Sealing would accumulate ~$64,650, still short of $80K. So the directional concern is correct, but the magnitude is significantly misstated.
**🔧 Tuning Suggestion:** The prompt should explicitly label the special assessment income total (not per-unit) in the data context. Currently the data says "$300.00/unit × 67 units (annual)" the AI should compute $20,100 but sometimes fixates on the $300 per-unit figure. Consider pre-computing and passing the total.
---
### Recommendation Assessment
| # | Recommendation | Priority | Concurrence |
|---|---|---|---|
| 1 | "Commission a professional Reserve Study to inventory assets and establish funded ratio" | High | **100%** Critical and universally correct |
| 2 | "Develop a long-term funding plan for the $80,000 Road Sealing project (2029)" | High | **90%** Verified project exists; $80K with 0% funded |
| 3 | "Formalize collection of special assessments into the reserve fund vs operating" | Medium | **95%** Budget shows special assessments in operating income section |
**Recommendation Concurrence: 95%** All recommendations are actionable, appropriately prioritized, and backed by database evidence.
---
### Score Assessment
**Is 45 (Needs Attention) the right score?**
| Scoring Criterion | Guidelines Say | Actual | Alignment |
|---|---|---|---|
| Percent funded | 20-30% for "Needs Attention" | 0% (no components) | Worse than threshold |
| Contributions | "Inadequate" for Needs Attention | $21,550/yr for $152K in projects | Borderline |
| Component tracking | "Multiple urgent unfunded" | 0 tracked, 2 due in 2026 | Critical gap |
| Investments | Not scored negatively | 3 CDs earning 3.6-3.67% | Positive |
| Capital readiness | | $12.5K due soon, only $10.7K cash | Tight |
A score of 45 is reasonable. The 0% funded ratio technically suggests "At Risk" (20-39), but the presence of real assets ($38.7K), active investments, and manageable near-term liquidity justifies bumping it into the "Needs Attention" band. The AI's balancing of the artificial 0% metric against actual fund health shows good judgment.
**Suggested correct score: 40-50** the AI's 45 is well-calibrated.
---
### Score Consistency Concern
| Run Date | Score | Label |
|---|---|---|
| Mar 2 15:06 | 25 | At Risk |
| Mar 2 15:13 | 25 | At Risk |
| Mar 2 15:37 | 48 | Needs Attention |
| Mar 2 17:10 | 42 | Needs Attention |
| Mar 3 02:04 | 45 | Needs Attention |
| Mar 4 18:49 | 35 | At Risk |
| Mar 4 19:24 | 45 | Needs Attention |
A **23-point spread** (25-48) across 7 runs. The scores oscillate between "At Risk" and "Needs Attention" the model cannot consistently decide which band this falls into. The most recent 3 runs (35, 45, 45) are more stable.
**🔧 Tuning Suggestion:** Add boundary guidance to the prompt: "When the score falls within ±5 points of a threshold (40, 60, 75, 90), explicitly justify which side of the boundary the HOA falls on."
---
## 3. AI Investment Recommendations
**Latest Run:** 2026-03-04T19:28:22Z (3 runs saved)
**Overall Concurrence: 88%**
### Overall Assessment
> "The HOA has a healthy long-term cash flow outlook with significant surpluses projected by mid-2026, but faces an immediate liquidity pinch in the Reserve Fund for March/April capital projects. The current investment strategy relies on older, lower-yielding CDs (3.60-3.67%) that are maturing soon."
**Concurrence: 92%** Every claim verified:
- CDs are at 3.60-3.67% vs market 4.10% (verified)
- March project ($7K) vs reserve cash ($10.7K) is tight (verified)
- Long-term surplus projected from assessment income (verified from budget)
---
### Recommendation-by-Recommendation Analysis
#### Rec 1: "Critical Reserve Shortfall for March Project" — HIGH / Liquidity Warning
| Claim | Database Value | Match |
|---|---|---|
| Reserve cash = $10,688 | $10,688.45 | Exact |
| $7,000 Pond Spillway project due March | Projects table: $7,000, Mar 2026 | Exact |
| Shortfall risk | $10,688 - $7,000 = $3,688 remaining tight but feasible | |
| Suggested action: expedite special assessment or transfer from operating | Sound advice | |
**Concurrence: 90%** The liquidity concern is real. After paying the $7K project, only $3.7K would remain in reserve cash before the $5.5K May project. The AI correctly flags the timing risk even though the fund is technically solvent.
---
#### Rec 2: "Reinvest Maturing CD #2a at Higher Rate" — HIGH / Maturity Action
| Claim | Database Value | Match |
|---|---|---|
| CD #2a = $8,000 | $8,000.00 | Exact |
| Current rate = 3.60% | 3.60% | Exact |
| Maturity = April 14, 2026 | 2026-04-14 | Exact |
| Market rate = 4.10% (E*TRADE) | CD rates: E*TRADE 4.10%, 1 year, $0 min | Exact |
| Additional yield: ~$40/year per $8K | $8K × 0.50% = $40 | Math correct |
**Concurrence: 95%** Textbook-correct recommendation. Every data point verified. The 50 bps improvement is risk-free income.
---
#### Rec 3: "Establish 12-Month CD Ladder for Reserves" — MEDIUM / CD Ladder
| Claim | Database Value | Match |
|---|---|---|
| ~$38K total reserve portfolio | $38,688.45 | Exact |
| Suggest 4-rung ladder (3/6/9/12 mo) | Standard strategy | |
| Rates up to 4.10% | Market data confirmed | |
| $9K matures every quarter | $38K / 4 = $9.5K per rung | Approximate |
**Concurrence: 75%** Strategy is sound in principle, but the recommendation overlooks two constraints:
1. **Immediate project costs ($12.5K in 2026)** must be reserved first, leaving ~$26K for laddering
2. **Investing the entire $38K** is aggressive some cash buffer should remain liquid
**🔧 Tuning Suggestion:** Add a constraint to the prompt: "When recommending CD ladders, always subtract upcoming project costs (next 12 months) and a minimum emergency reserve (1 month of budgeted reserve expenses) before calculating the investable amount."
---
#### Rec 4: "Deploy Excess Operating Cash to High-Yield Savings" — MEDIUM / New Investment
| Claim | Database Value | Match |
|---|---|---|
| Operating cash = $27,418 | $27,418.81 | Exact |
| 3-month buffer = ~$35,000 | $11,665 × 3 = $34,995 | Math correct |
| Current cash below buffer | $27.4K < $35K | Correctly identified |
| Openbank 4.09% APY | Market data: Openbank 4.09%, $0.01 min | Exact |
| Trigger: "As soon as balance exceeds $35K" | Sound deferred recommendation | |
**Concurrence: 90%** The AI correctly identifies the current shortfall and provides a forward-looking trigger. Well-structured advice that respects the liquidity constraint.
---
#### Rec 5: "Optimize Reserve Cash Yield Post-Project" — LOW / Reallocation
| Claim | Database Value | Match |
|---|---|---|
| Vio Bank Money Market at 4.03% | Market data: Vio Bank 4.03%, $0 min | Exact |
| Post-project reserve cash deployment | Appropriate timing | |
| T+1 liquidity for emergencies | Correct MM account characteristic | |
**Concurrence: 85%** Reasonable low-priority optimization. Correctly uses market data.
---
#### Rec 6: "Formalize Special Assessment Collection for Reserves" — LOW / General
| Claim | Database Value | Match |
|---|---|---|
| $300/unit special assessment | Assessment groups: $300.00 special | Exact |
| Risk of commingling with operating | Budget shows special assessments in operating income | Identified |
**Concurrence: 90%** Important governance recommendation. The budget structure does show special assessments as operating income, which could lead to improper fund commingling.
---
### Risk Notes Assessment
| Risk Note | Verified | Concurrence |
|---|---|---|
| "Reserve cash ($10.6K) barely sufficient for $7K + $5.5K projects" | $10,688 vs $12,500 in projects | **95%** |
| "Concentration risk: CDs maturing in 4-month window (Apr-Aug)" | All 3 CDs mature Apr-Aug 2026 | **100%** |
| "Operating cash ballooning to $140K+ without investment plan" | Budget shows large Q2 surplus | **85%** |
| "Road Sealing $80K in 2029 needs dedicated savings plan" | Project exists, 0% funded | **95%** |
**Risk Notes Concurrence: 94%** All risk items are data-backed and appropriately flagged.
---
### Cross-Run Consistency (Investment Recommendations)
Three runs were compared. Key observations:
- **Core recommendations are highly consistent** across runs: CD reinvestment, HY savings for operating, CD ladder for reserves
- **Dollar amounts match exactly** across all runs (same data inputs)
- **Bank name recommendations vary slightly** (E*TRADE vs "Top CD Rate") cosmetic, not substantive
- **Priority levels are stable** (HIGH for liquidity warnings, MEDIUM for optimization)
**Consistency Grade: A-** Investment recommendations show much better consistency than health scores, likely because the structured data (specific CDs, specific rates) constrains the output more than the subjective health scoring.
---
## Cross-Cutting Issues
### Issue 1: Score Volatility (MEDIUM Priority)
Health scores vary significantly across runs despite identical input data:
- Operating: 40-point spread (48-88)
- Reserve: 23-point spread (25-48)
**Root Cause:** Temperature 0.3 allows too much variance for numerical scoring. The model interprets guidelines subjectively.
**Recommended Fix:**
1. Reduce temperature to **0.1** for health score calculations
2. Implement a **3-run moving average** to smooth individual run variance
3. Add explicit **boundary justification** requirements to prompts
### Issue 2: YTD Budget Calculation Includes Incomplete Month (LOW Priority)
The operating health score computes YTD budget through the current month (March), but actual data may only cover a few days. This creates alarming income variances (e.g., "$55K variance") that are pure timing artifacts.
**Recommended Fix:**
- Compute YTD budget through the **prior completed month** (February)
- OR pro-rate the current month's budget by days elapsed
- Add a note to the prompt: "If the variance is driven by the current incomplete month, flag it as 'timing' and weight it minimally."
### Issue 3: Per-Unit vs Total Confusion on Special Assessments (LOW Priority)
The AI sometimes quotes "$300" as the annual reserve income instead of $300 × 67 = $20,100. The data passed says "$300.00/unit × 67 units (annual)" but the model occasionally fixates on the per-unit figure.
**Recommended Fix:**
- Pre-compute and include the total in the data: "Total Annual Special Assessment Income: $20,100.00"
- Keep the per-unit breakdown for context but lead with the total
### Issue 4: Cash Runway Classification Inconsistency (MEDIUM Priority)
The operating health score rates 2.4 months of cash runway as "positive" despite the scoring guidelines defining 2-3 months as "Fair" territory. This inflates the overall score.
**Recommended Fix:**
- Add explicit prompt guidance: "Cash runway categorization: <2 months = negative, 2-3 months = neutral, 3-6 months = positive, 6+ months = strongly positive. Do NOT rate below-threshold runway as positive based on projected future inflows."
### Issue 5: Dual Project Tables (INFORMATIONAL)
The schema contains both `capital_projects` (empty) and `projects` (26 rows). The health score service correctly queries `projects`, but auditors initially checked `capital_projects` and found no data. This dual-table pattern could confuse future developers.
**Recommended Fix:**
- Consolidate into a single table, OR
- Add a comment/documentation clarifying the canonical source
---
## Concurrence Summary by Recommendation
### Operating Fund Health — Recommendations
| Recommendation | Concurrence |
|---|---|
| Verify posting schedule for $20,700 Special Assessment | 90% |
| Investigate low YTD expense recognition | 95% |
| Move excess cash to interest-bearing account | 85% |
| **Average** | **90%** |
### Reserve Fund Health — Recommendations
| Recommendation | Concurrence |
|---|---|
| Commission professional Reserve Study | 100% |
| Develop funding plan for $80K Road Sealing | 90% |
| Formalize special assessment collection for reserves | 95% |
| **Average** | **95%** |
### Investment Planning — Recommendations
| Recommendation | Concurrence |
|---|---|
| Critical Reserve Shortfall for March Project | 90% |
| Reinvest Maturing CD #2a at Higher Rate | 95% |
| Establish 12-Month CD Ladder | 75% |
| Deploy Operating Cash to HY Savings | 90% |
| Optimize Reserve Cash Post-Project | 85% |
| Formalize Special Assessment Collection | 90% |
| **Average** | **88%** |
---
## Final Grades
| Feature | Score Accuracy | Recommendation Quality | Data Fidelity | Consistency | **Overall** |
|---|---|---|---|---|---|
| Operating Fund Health | C+ (score ~15 pts high) | A (90%) | B+ (minor math phrasing) | C (16-pt spread) | **72% — B-** |
| Reserve Fund Health | A- (well-calibrated) | A (95%) | B (per-unit confusion) | B- (23-pt spread) | **85% — B+** |
| Investment Recommendations | N/A (no single score) | A (88%) | A (exact data matches) | A- (stable across runs) | **88% — A-** |
---
## Priority Action Items for Tuning
1. **[HIGH]** Reduce AI temperature from 0.3 0.1 for health score calculations to reduce score volatility
2. **[MEDIUM]** Add explicit cash-runway-to-impact mapping in operating prompt to prevent misclassification
3. **[MEDIUM]** Pre-compute total special assessment income in data context (not just per-unit)
4. **[LOW]** Adjust YTD budget calculation to use prior completed month or pro-rate current month
5. **[LOW]** Add boundary justification requirement to scoring prompts
6. **[LOW]** Consider implementing 3-run moving average for displayed health scores
---
*Generated by Claude Opus 4.6 — Automated AI Feature Audit*

22
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# ---- Production Dockerfile for React frontend ----
# Multi-stage build: compile to static assets, serve with nginx
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
# Copy the built static files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy a small nginx config for SPA routing
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3001
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -9,5 +9,34 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script>
(function(d,t) {
var BASE_URL="https://chat.hoaledgeriq.com";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async=true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken:'K6VXvTtKXvaCMvre4yK85SPb',
baseUrl:BASE_URL
})
}
})(document,"script");
window.addEventListener('chatwoot:ready', function() {
try {
var raw = localStorage.getItem('ledgeriq-auth');
if (!raw) return;
var auth = JSON.parse(raw);
var user = auth && auth.state && auth.state.user;
if (user && window.$chatwoot) {
window.$chatwoot.setUser(user.id, {
name: (user.firstName || '') + ' ' + (user.lastName || ''),
email: user.email
});
}
} catch (e) {}
});
</script>
</body> </body>
</html> </html>

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
# Minimal nginx config for serving the React SPA inside the frontend container.
# The outer nginx reverse proxy forwards non-API requests here.
server {
listen 3001;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Serve static assets with long cache (Vite hashes filenames)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — any non-file route returns index.html
location / {
try_files $uri $uri/ /index.html;
}
}

3331
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "0.2.0", "version": "2026.3.17",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,31 +11,33 @@
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^7.15.3", "@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/form": "^7.15.3",
"@mantine/dates": "^7.15.3", "@mantine/dates": "^7.15.3",
"@mantine/notifications": "^7.15.3", "@mantine/form": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3", "@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@simplewebauthn/browser": "^13.3.0",
"@tabler/icons-react": "^3.28.1", "@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",
"d3-sankey": "^0.12.3",
"dayjs": "^1.11.13",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-joyride": "^2.9.3",
"react-router-dom": "^6.28.2", "react-router-dom": "^6.28.2",
"recharts": "^2.15.0", "recharts": "^2.15.0",
"d3-sankey": "^0.12.3", "zustand": "^4.5.5"
"zustand": "^4.5.5",
"axios": "^1.7.9",
"@tanstack/react-query": "^5.64.2",
"dayjs": "^1.11.13"
}, },
"devDependencies": { "devDependencies": {
"@types/d3-sankey": "^0.12.4",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/d3-sankey": "^0.12.4",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^5.4.14",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1" "postcss-simple-vars": "^7.0.1",
"typescript": "^5.7.3",
"vite": "^5.4.14"
} }
} }

View File

@@ -4,6 +4,7 @@ import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/auth/LoginPage'; import { LoginPage } from './pages/auth/LoginPage';
import { RegisterPage } from './pages/auth/RegisterPage'; import { RegisterPage } from './pages/auth/RegisterPage';
import { SelectOrgPage } from './pages/auth/SelectOrgPage'; import { SelectOrgPage } from './pages/auth/SelectOrgPage';
import { ActivatePage } from './pages/auth/ActivatePage';
import { DashboardPage } from './pages/dashboard/DashboardPage'; import { DashboardPage } from './pages/dashboard/DashboardPage';
import { AccountsPage } from './pages/accounts/AccountsPage'; import { AccountsPage } from './pages/accounts/AccountsPage';
import { TransactionsPage } from './pages/transactions/TransactionsPage'; import { TransactionsPage } from './pages/transactions/TransactionsPage';
@@ -22,6 +23,7 @@ import { SankeyPage } from './pages/reports/SankeyPage';
import { CashFlowPage } from './pages/reports/CashFlowPage'; import { CashFlowPage } from './pages/reports/CashFlowPage';
import { AgingReportPage } from './pages/reports/AgingReportPage'; import { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage'; import { YearEndPage } from './pages/reports/YearEndPage';
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
import { SettingsPage } from './pages/settings/SettingsPage'; import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
@@ -30,6 +32,15 @@ import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroups
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage'; import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage'; import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
import { InvestmentScenariosPage } from './pages/board-planning/InvestmentScenariosPage';
import { InvestmentScenarioDetailPage } from './pages/board-planning/InvestmentScenarioDetailPage';
import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenariosPage';
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
import { PricingPage } from './pages/pricing/PricingPage';
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token); const token = useAuthStore((s) => s.token);
@@ -70,6 +81,12 @@ function AuthRoute({ children }: { children: React.ReactNode }) {
export function App() { export function App() {
return ( return (
<Routes> <Routes>
{/* Public routes (no auth required) */}
<Route path="/pricing" element={<PricingPage />} />
<Route path="/activate" element={<ActivatePage />} />
<Route path="/onboarding/pending" element={<OnboardingPendingPage />} />
{/* Auth routes (redirect if already logged in) */}
<Route <Route
path="/login" path="/login"
element={ element={
@@ -94,6 +111,18 @@ export function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
{/* Onboarding (requires auth but not org selection) */}
<Route
path="/onboarding"
element={
<ProtectedRoute>
<OnboardingPage />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route <Route
path="/admin" path="/admin"
element={ element={
@@ -104,6 +133,8 @@ export function App() {
> >
<Route index element={<AdminPage />} /> <Route index element={<AdminPage />} />
</Route> </Route>
{/* Main app routes (require auth + org) */}
<Route <Route
path="/*" path="/*"
element={ element={
@@ -135,6 +166,13 @@ export function App() {
<Route path="reports/aging" element={<AgingReportPage />} /> <Route path="reports/aging" element={<AgingReportPage />} />
<Route path="reports/sankey" element={<SankeyPage />} /> <Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} /> <Route path="reports/year-end" element={<YearEndPage />} />
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
<Route path="board-planning/assessments" element={<AssessmentScenariosPage />} />
<Route path="board-planning/assessments/:id" element={<AssessmentScenarioDetailPage />} />
<Route path="board-planning/compare" element={<ScenarioComparisonPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="preferences" element={<UserPreferencesPage />} /> <Route path="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} /> <Route path="org-members" element={<OrgMembersPage />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -1,4 +1,5 @@
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core'; import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
IconLogout, IconLogout,
@@ -8,18 +9,58 @@ import {
IconUserCog, IconUserCog,
IconUsersGroup, IconUsersGroup,
IconEyeOff, IconEyeOff,
IconSun,
IconMoon,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import logoSrc from '../../assets/logo.svg'; import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import logoSrc from '../../assets/logo.png';
export function AppLayout() { export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure(); const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore(); const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const isImpersonating = !!impersonationOriginal; const isImpersonating = !!impersonationOriginal;
// ── Onboarding State ──
const [showTour, setShowTour] = useState(false);
const [showWizard, setShowWizard] = useState(false);
useEffect(() => {
// Only run for non-impersonating users with an org selected, on dashboard
if (isImpersonating || !currentOrg || !user) return;
if (!location.pathname.startsWith('/dashboard')) return;
// Read-only users (viewers) skip onboarding entirely
if (currentOrg.role === 'viewer') return;
if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) {
// Delay to ensure DOM elements are rendered for tour targeting
const timer = setTimeout(() => setShowTour(true), 800);
return () => clearTimeout(timer);
} else if (currentOrg.settings?.onboardingComplete !== true) {
setShowWizard(true);
}
}, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.role, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]);
const handleTourComplete = () => {
setShowTour(false);
// After tour, check if onboarding wizard should run
if (currentOrg && currentOrg.settings?.onboardingComplete !== true) {
// Small delay before showing wizard
setTimeout(() => setShowWizard(true), 500);
}
};
const handleWizardComplete = () => {
setShowWizard(false);
};
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
navigate('/login'); navigate('/login');
@@ -65,12 +106,31 @@ export function AppLayout() {
<Group h={60} px="md" justify="space-between"> <Group h={60} px="md" justify="space-between">
<Group> <Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" /> <Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} /> <img
src={logoSrc}
alt="HOA LedgerIQ"
style={{
height: 40,
...(colorScheme === 'dark' ? {
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
} : {}),
}}
/>
</Group> </Group>
<Group> <Group>
{currentOrg && ( {currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text> <Text size="sm" c="dimmed">{currentOrg.name}</Text>
)} )}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
onClick={toggleColorScheme}
aria-label="Toggle color scheme"
>
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={220}> <Menu shadow="md" width={220}>
<Menu.Target> <Menu.Target>
<UnstyledButton> <UnstyledButton>
@@ -145,6 +205,10 @@ export function AppLayout() {
<AppShell.Main> <AppShell.Main>
<Outlet /> <Outlet />
</AppShell.Main> </AppShell.Main>
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
</AppShell> </AppShell>
); );
} }

View File

@@ -17,7 +17,9 @@ import {
IconChartAreaLine, IconChartAreaLine,
IconClipboardCheck, IconClipboardCheck,
IconSparkles, IconSparkles,
IconHeartRateMonitor, IconCalculator,
IconGitCompare,
IconScale,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -30,42 +32,58 @@ const navSections = [
{ {
label: 'Financials', label: 'Financials',
items: [ items: [
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' }, { label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' }, { label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' }, { label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' }, { label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
], ],
}, },
{ {
label: 'Assessments', label: 'Assessments',
items: [ items: [
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' }, { label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' }, { label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
],
},
{
label: 'Board Planning',
items: [
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
{
label: 'Projects', icon: IconShieldCheck, path: '/projects',
children: [
{ label: 'Capital Planning', path: '/capital-projects' },
],
},
{
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
},
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
],
},
{
label: 'Board Reference',
items: [
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
], ],
}, },
{ {
label: 'Transactions', label: 'Transactions',
items: [ items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' }, { label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' }, { label: 'Payments', icon: IconCash, path: '/payments' },
], ],
}, },
{
label: 'Planning',
items: [
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
],
},
{ {
label: 'Reports', label: 'Reports',
items: [ items: [
{ {
label: 'Reports', label: 'Reports',
icon: IconChartSankey, icon: IconChartSankey,
tourId: 'nav-reports',
children: [ children: [
{ label: 'Balance Sheet', path: '/reports/balance-sheet' }, { label: 'Balance Sheet', path: '/reports/balance-sheet' },
{ label: 'Income Statement', path: '/reports/income-statement' }, { label: 'Income Statement', path: '/reports/income-statement' },
@@ -74,6 +92,7 @@ const navSections = [
{ label: 'Aging Report', path: '/reports/aging' }, { label: 'Aging Report', path: '/reports/aging' },
{ label: 'Sankey Diagram', path: '/reports/sankey' }, { label: 'Sankey Diagram', path: '/reports/sankey' },
{ label: 'Year-End', path: '/reports/year-end' }, { label: 'Year-End', path: '/reports/year-end' },
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
], ],
}, },
], ],
@@ -127,7 +146,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
} }
return ( return (
<ScrollArea p="sm"> <ScrollArea p="sm" data-tour="sidebar-nav">
{navSections.map((section, sIdx) => ( {navSections.map((section, sIdx) => (
<div key={sIdx}> <div key={sIdx}>
{section.label && ( {section.label && (
@@ -139,7 +158,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
</> </>
)} )}
{section.items.map((item: any) => {section.items.map((item: any) =>
item.children ? ( item.children && !item.path ? (
// Collapsible group without a parent route (e.g. Reports)
<NavLink <NavLink
key={item.label} key={item.label}
label={item.label} label={item.label}
@@ -147,6 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
defaultOpened={item.children.some((c: any) => defaultOpened={item.children.some((c: any) =>
location.pathname.startsWith(c.path), location.pathname.startsWith(c.path),
)} )}
data-tour={item.tourId || undefined}
> >
{item.children.map((child: any) => ( {item.children.map((child: any) => (
<NavLink <NavLink
@@ -157,6 +178,29 @@ export function Sidebar({ onNavigate }: SidebarProps) {
/> />
))} ))}
</NavLink> </NavLink>
) : item.children && item.path ? (
// Parent with its own route + nested children (e.g. Projects > Capital Planning)
<NavLink
key={item.path}
label={item.label}
leftSection={<item.icon size={18} />}
defaultOpened={
location.pathname === item.path ||
item.children.some((c: any) => location.pathname.startsWith(c.path))
}
data-tour={item.tourId || undefined}
active={location.pathname === item.path}
onClick={() => go(item.path!)}
>
{item.children.map((child: any) => (
<NavLink
key={child.path}
label={child.label}
active={location.pathname === child.path}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); go(child.path); }}
/>
))}
</NavLink>
) : ( ) : (
<NavLink <NavLink
key={item.path} key={item.path}
@@ -164,6 +208,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
leftSection={<item.icon size={18} />} leftSection={<item.icon size={18} />}
active={location.pathname === item.path} active={location.pathname === item.path}
onClick={() => go(item.path!)} onClick={() => go(item.path!)}
data-tour={item.tourId || undefined}
/> />
), ),
)} )}

View File

@@ -0,0 +1,93 @@
import { useState, useCallback } from 'react';
import Joyride, { type CallBackProps, STATUS, ACTIONS, EVENTS } from 'react-joyride';
import { TOUR_STEPS } from '../../config/tourSteps';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
interface AppTourProps {
run: boolean;
onComplete: () => void;
}
export function AppTour({ run, onComplete }: AppTourProps) {
const [stepIndex, setStepIndex] = useState(0);
const setUserIntroSeen = useAuthStore((s) => s.setUserIntroSeen);
const handleCallback = useCallback(
async (data: CallBackProps) => {
const { status, action, type } = data;
const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED];
if (finishedStatuses.includes(status)) {
// Mark intro as seen on backend (fire-and-forget)
api.patch('/auth/intro-seen').catch(() => {});
setUserIntroSeen();
onComplete();
return;
}
// Handle step navigation
if (type === EVENTS.STEP_AFTER) {
setStepIndex((prev) =>
action === ACTIONS.PREV ? prev - 1 : prev + 1,
);
}
},
[onComplete, setUserIntroSeen],
);
if (!run) return null;
return (
<Joyride
steps={TOUR_STEPS}
run={run}
stepIndex={stepIndex}
continuous
showProgress
showSkipButton
scrollToFirstStep
disableOverlayClose
callback={handleCallback}
styles={{
options: {
primaryColor: '#228be6',
zIndex: 10000,
arrowColor: '#fff',
backgroundColor: '#fff',
textColor: '#333',
overlayColor: 'rgba(0, 0, 0, 0.5)',
},
tooltip: {
borderRadius: 8,
fontSize: 14,
padding: 20,
},
tooltipTitle: {
fontSize: 16,
fontWeight: 600,
},
buttonNext: {
borderRadius: 6,
fontSize: 14,
padding: '8px 16px',
},
buttonBack: {
borderRadius: 6,
fontSize: 14,
marginRight: 8,
},
buttonSkip: {
fontSize: 13,
},
}}
locale={{
back: 'Previous',
close: 'Close',
last: 'Finish Tour',
next: 'Next',
skip: 'Skip Tour',
}}
/>
);
}

View File

@@ -0,0 +1,646 @@
import { useState } from 'react';
import {
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconBuildingBank, IconUsers, IconFileSpreadsheet,
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
IconAlertCircle,
} from '@tabler/icons-react';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
interface OnboardingWizardProps {
opened: boolean;
onComplete: () => void;
}
interface UnitRow {
unitNumber: string;
ownerName: string;
ownerEmail: string;
}
// ── CSV Parsing (reused from BudgetsPage pattern) ──
function parseCSV(text: string): Record<string, string>[] {
const lines = text.split('\n').filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
return lines.slice(1).map((line) => {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes; }
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += char; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, i) => { row[h] = values[i] || ''; });
return row;
});
}
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const setOrgSettings = useAuthStore((s) => s.setOrgSettings);
// ── Step 1: Account State ──
const [accountCreated, setAccountCreated] = useState(false);
const [accountName, setAccountName] = useState('Operating Checking');
const [accountNumber, setAccountNumber] = useState('1000');
const [accountDescription, setAccountDescription] = useState('');
const [initialBalance, setInitialBalance] = useState<number | string>(0);
// ── Step 2: Assessment Group State ──
const [groupCreated, setGroupCreated] = useState(false);
const [groupName, setGroupName] = useState('Standard Assessment');
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
const [frequency, setFrequency] = useState('monthly');
const [units, setUnits] = useState<UnitRow[]>([]);
const [unitsCreated, setUnitsCreated] = useState(false);
// ── Step 3: Budget State ──
const [budgetFile, setBudgetFile] = useState<File | null>(null);
const [budgetUploaded, setBudgetUploaded] = useState(false);
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
const currentYear = new Date().getFullYear();
// ── Step 1: Create Account ──
const handleCreateAccount = async () => {
if (!accountName.trim()) {
setError('Account name is required');
return;
}
if (!accountNumber.trim()) {
setError('Account number is required');
return;
}
const balance = typeof initialBalance === 'string' ? parseFloat(initialBalance) : initialBalance;
if (isNaN(balance)) {
setError('Initial balance must be a valid number');
return;
}
setLoading(true);
setError(null);
try {
await api.post('/accounts', {
accountNumber: accountNumber.trim(),
name: accountName.trim(),
description: accountDescription.trim(),
accountType: 'asset',
fundType: 'operating',
initialBalance: balance,
});
setAccountCreated(true);
notifications.show({
title: 'Account Created',
message: `${accountName} has been created with an initial balance of $${balance.toLocaleString()}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create account';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Step 2: Create Assessment Group ──
const handleCreateGroup = async () => {
if (!groupName.trim()) {
setError('Group name is required');
return;
}
const assessment = typeof regularAssessment === 'string' ? parseFloat(regularAssessment) : regularAssessment;
if (isNaN(assessment) || assessment <= 0) {
setError('Assessment amount must be greater than zero');
return;
}
setLoading(true);
setError(null);
try {
const { data: group } = await api.post('/assessment-groups', {
name: groupName.trim(),
regularAssessment: assessment,
frequency,
isDefault: true,
});
setGroupCreated(true);
// Create units if any were added
if (units.length > 0) {
let created = 0;
for (const unit of units) {
if (!unit.unitNumber.trim()) continue;
try {
await api.post('/units', {
unitNumber: unit.unitNumber.trim(),
ownerName: unit.ownerName.trim() || null,
ownerEmail: unit.ownerEmail.trim() || null,
assessmentGroupId: group.id,
});
created++;
} catch {
// Continue even if a unit fails
}
}
setUnitsCreated(true);
notifications.show({
title: 'Assessment Group Created',
message: `${groupName} created with ${created} unit(s)`,
color: 'green',
});
} else {
notifications.show({
title: 'Assessment Group Created',
message: `${groupName} created successfully`,
color: 'green',
});
}
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to create assessment group';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Step 3: Budget Import ──
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${currentYear}/template`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `budget_template_${currentYear}.csv`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch {
notifications.show({
title: 'Error',
message: 'Failed to download template',
color: 'red',
});
}
};
const handleUploadBudget = async () => {
if (!budgetFile) {
setError('Please select a CSV file');
return;
}
setLoading(true);
setError(null);
try {
const text = await budgetFile.text();
const rows = parseCSV(text);
if (rows.length === 0) {
setError('CSV file appears to be empty or invalid');
setLoading(false);
return;
}
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
setBudgetUploaded(true);
setBudgetImportResult(data);
notifications.show({
title: 'Budget Imported',
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to import budget';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Finish Wizard ──
const handleFinish = async () => {
setLoading(true);
try {
await api.patch('/organizations/settings', { onboardingComplete: true });
setOrgSettings({ onboardingComplete: true });
onComplete();
} catch {
// Even if API fails, close the wizard — onboarding data is already created
onComplete();
} finally {
setLoading(false);
}
};
// ── Unit Rows ──
const addUnit = () => {
setUnits([...units, { unitNumber: '', ownerName: '', ownerEmail: '' }]);
};
const updateUnit = (index: number, field: keyof UnitRow, value: string) => {
const updated = [...units];
updated[index] = { ...updated[index], [field]: value };
setUnits(updated);
};
const removeUnit = (index: number) => {
setUnits(units.filter((_, i) => i !== index));
};
// ── Navigation ──
const canGoNext = () => {
if (active === 0) return accountCreated;
if (active === 1) return groupCreated;
if (active === 2) return true; // Budget is optional
return false;
};
const nextStep = () => {
setError(null);
if (active < 3) setActive(active + 1);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing without completing
withCloseButton={false}
size="xl"
centered
overlayProps={{ opacity: 0.6, blur: 3 }}
styles={{
body: { padding: 0 },
}}
>
{/* Header */}
<Box px="xl" pt="xl" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
<Group>
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
<IconRocket size={24} />
</ThemeIcon>
<div>
<Title order={3}>Set Up Your Organization</Title>
<Text c="dimmed" size="sm">
Let&apos;s get the essentials configured so you can start managing your HOA finances.
</Text>
</div>
</Group>
</Box>
<Box px="xl" py="lg">
<Stepper active={active} size="sm" mb="xl">
<Stepper.Step
label="Operating Account"
description="Set up your primary bank account"
icon={<IconBuildingBank size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Assessment Group"
description="Define homeowner assessments"
icon={<IconUsers size={18} />}
completedIcon={<IconCheck size={18} />}
/>
<Stepper.Step
label="Budget"
description="Import your annual budget"
icon={<IconFileSpreadsheet size={18} />}
completedIcon={<IconCheck size={18} />}
/>
</Stepper>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md" withCloseButton onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* ── Step 1: Create Operating Account ── */}
{active === 0 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create Your Primary Operating Account</Text>
<Text size="sm" c="dimmed" mb="md">
This is your HOA&apos;s main bank account for day-to-day operations. You can add more accounts later.
</Text>
{accountCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{accountName} created successfully!</Text>
<Text size="sm" c="dimmed">
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={2} mb="md">
<TextInput
label="Account Name"
placeholder="e.g. Operating Checking"
value={accountName}
onChange={(e) => setAccountName(e.currentTarget.value)}
required
/>
<TextInput
label="Account Number"
placeholder="e.g. 1000"
value={accountNumber}
onChange={(e) => setAccountNumber(e.currentTarget.value)}
required
/>
</SimpleGrid>
<Textarea
label="Description"
placeholder="Optional description"
value={accountDescription}
onChange={(e) => setAccountDescription(e.currentTarget.value)}
mb="md"
autosize
minRows={2}
/>
<NumberInput
label="Current Balance"
description="Enter the current balance of this bank account"
placeholder="0.00"
value={initialBalance}
onChange={setInitialBalance}
thousandSeparator=","
prefix="$"
decimalScale={2}
mb="md"
/>
<Button
onClick={handleCreateAccount}
loading={loading}
leftSection={<IconBuildingBank size={16} />}
>
Create Account
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 2: Assessment Group + Units ── */}
{active === 1 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Create an Assessment Group</Text>
<Text size="sm" c="dimmed" mb="md">
Assessment groups define how much each homeowner pays and how often. You can create additional groups later for different unit types.
</Text>
{groupCreated ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>{groupName} created successfully!</Text>
<Text size="sm" c="dimmed">
${(typeof regularAssessment === 'number' ? regularAssessment : parseFloat(regularAssessment as string) || 0).toLocaleString()} {frequency}
{unitsCreated && ` with ${units.length} unit(s)`}
</Text>
</Alert>
) : (
<>
<SimpleGrid cols={3} mb="md">
<TextInput
label="Group Name"
placeholder="e.g. Standard Assessment"
value={groupName}
onChange={(e) => setGroupName(e.currentTarget.value)}
required
/>
<NumberInput
label="Assessment Amount"
placeholder="0.00"
value={regularAssessment}
onChange={setRegularAssessment}
thousandSeparator=","
prefix="$"
decimalScale={2}
required
/>
<Select
label="Frequency"
value={frequency}
onChange={(v) => setFrequency(v || 'monthly')}
data={[
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' },
]}
/>
</SimpleGrid>
<Divider my="md" label="Add Homeowner Units (Optional)" labelPosition="center" />
{units.length > 0 && (
<Table mb="md" striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Unit Number</Table.Th>
<Table.Th>Owner Name</Table.Th>
<Table.Th>Owner Email</Table.Th>
<Table.Th w={40}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((unit, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<TextInput
size="xs"
placeholder="e.g. 101"
value={unit.unitNumber}
onChange={(e) => updateUnit(idx, 'unitNumber', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="John Smith"
value={unit.ownerName}
onChange={(e) => updateUnit(idx, 'ownerName', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="john@example.com"
value={unit.ownerEmail}
onChange={(e) => updateUnit(idx, 'ownerEmail', e.currentTarget.value)}
/>
</Table.Td>
<Table.Td>
<ActionIcon color="red" variant="subtle" size="sm" onClick={() => removeUnit(idx)}>
<IconTrash size={14} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group mb="md">
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={addUnit}
>
Add Unit
</Button>
<Text size="xs" c="dimmed">You can also import units in bulk later from the Units page.</Text>
</Group>
<Button
onClick={handleCreateGroup}
loading={loading}
leftSection={<IconUsers size={16} />}
>
Create Assessment Group
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Step 3: Budget Upload ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
<Text size="sm" c="dimmed" mb="md">
Upload a CSV file with your annual budget. If you don&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' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
<IconCheck size={32} />
</ThemeIcon>
<Title order={3} mb="xs">You&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.
</Text>
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconBuildingBank size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Account</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconUsers size={16} />
</ThemeIcon>
<Badge color="green" size="sm">Done</Badge>
<Text size="xs" mt={4}>Assessments</Text>
</Card>
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} />
</ThemeIcon>
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
{budgetUploaded ? 'Done' : 'Skipped'}
</Badge>
<Text size="xs" mt={4}>Budget</Text>
</Card>
</SimpleGrid>
<Button
size="lg"
onClick={handleFinish}
loading={loading}
leftSection={<IconRocket size={18} />}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Start Using LedgerIQ
</Button>
</Card>
)}
{/* ── Navigation Buttons ── */}
{active < 3 && (
<Group justify="flex-end" mt="xl">
{active === 2 && !budgetUploaded && (
<Button variant="subtle" onClick={nextStep}>
Skip for now
</Button>
)}
<Button
onClick={nextStep}
disabled={!canGoNext()}
>
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
</Button>
</Group>
)}
</Box>
</Modal>
);
}

View File

@@ -0,0 +1,68 @@
/**
* How-To Intro Tour Steps
*
* Centralized configuration for the react-joyride walkthrough.
* Edit the title and content fields below to change tour text.
* Steps are ordered to mirror the natural workflow of the platform.
*/
import type { Step } from 'react-joyride';
export const TOUR_STEPS: Step[] = [
{
target: '[data-tour="dashboard-content"]',
title: 'Your Financial Dashboard',
content:
'Welcome to LedgerIQ! This dashboard gives you an at-a-glance view of your HOA\'s financial health — operating funds, reserve funds, receivables, delinquencies, and recent transactions. It updates automatically as you record activity.',
placement: 'center',
disableBeacon: true,
},
{
target: '[data-tour="sidebar-nav"]',
title: 'Navigation',
content:
'The sidebar organizes all your tools into five sections: Financials, Assessments, Transactions, Planning, and Reports. Click any item to navigate directly to that module.',
placement: 'right',
},
{
target: '[data-tour="nav-accounts"]',
title: 'Chart of Accounts',
content:
'Manage your Chart of Accounts here. Set up operating and reserve fund bank accounts, track balances, record opening balances, and manage your investment accounts — all separated by fund type.',
placement: 'right',
},
{
target: '[data-tour="nav-assessment-groups"]',
title: 'Assessments & Homeowners',
content:
'Create assessment groups to define your monthly, quarterly, or annual HOA dues. Add homeowner units, assign them to groups, and generate invoices automatically based on your assessment schedule.',
placement: 'right',
},
{
target: '[data-tour="nav-transactions"]',
title: 'Transactions & Journal Entries',
content:
'Record all financial activity here through double-entry journal entries. The system also automatically creates entries when you record payments, generate invoices, or set opening balances.',
placement: 'right',
},
{
target: '[data-tour="nav-budgets"]',
title: 'Budget Management',
content:
'Create and manage annual budgets for every income and expense account. You can enter amounts manually by month or import your budget from a CSV file for quick setup.',
placement: 'right',
},
{
target: '[data-tour="nav-reports"]',
title: 'Financial Reports',
content:
'Generate comprehensive reports including Balance Sheet, Income Statement, Cash Flow Statement, Budget vs Actual, Aging Report, and more. All reports are generated in real-time from your journal data.',
placement: 'right',
},
{
target: '[data-tour="nav-investment-planning"]',
title: 'AI Investment Planning',
content:
'Use AI-powered recommendations to optimize your reserve fund investments. The system analyzes current market rates for CDs, money market accounts, and high-yield savings to suggest the best allocation strategy.',
placement: 'right',
},
];

View File

@@ -9,7 +9,8 @@ import '@mantine/core/styles.css';
import '@mantine/dates/styles.css'; import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import { App } from './App'; import { App } from './App';
import { theme } from './theme/theme'; import { defaultTheme, compactTheme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -21,9 +22,13 @@ const queryClient = new QueryClient({
}, },
}); });
ReactDOM.createRoot(document.getElementById('root')!).render( function Root() {
<React.StrictMode> const colorScheme = usePreferencesStore((s) => s.colorScheme);
<MantineProvider theme={theme}> const compactView = usePreferencesStore((s) => s.compactView);
const activeTheme = compactView ? compactTheme : defaultTheme;
return (
<MantineProvider theme={activeTheme} forceColorScheme={colorScheme}>
<Notifications position="top-right" /> <Notifications position="top-right" />
<ModalsProvider> <ModalsProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -33,5 +38,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</QueryClientProvider> </QueryClientProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -40,6 +40,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage']; const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
@@ -126,6 +127,7 @@ export function AccountsPage() {
const [filterType, setFilterType] = useState<string | null>(null); const [filterType, setFilterType] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ── Accounts query ── // ── Accounts query ──
const { data: accounts = [], isLoading } = useQuery<Account[]>({ const { data: accounts = [], isLoading } = useQuery<Account[]>({
@@ -434,14 +436,44 @@ export function AccountsPage() {
// Net position = assets + investments - liabilities // Net position = assets + investments - liabilities
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0); const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
// ── Estimated monthly interest across all accounts with rates ── // ── Estimated monthly interest across all accounts + investments with rates ──
const estMonthlyInterest = accounts const acctMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0) .filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
.reduce((sum, a) => { .reduce((sum, a) => {
const bal = parseFloat(a.balance || '0'); const bal = parseFloat(a.balance || '0');
const rate = parseFloat(a.interest_rate || '0'); const rate = parseFloat(a.interest_rate || '0');
return sum + (bal * (rate / 100) / 12); return sum + (bal * (rate / 100) / 12);
}, 0); }, 0);
const invMonthlyInterest = investments
.filter((i) => i.is_active && parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => {
const val = parseFloat(i.current_value || i.principal || '0');
const rate = parseFloat(i.interest_rate || '0');
return sum + (val * (rate / 100) / 12);
}, 0);
const estMonthlyInterest = acctMonthlyInterest + invMonthlyInterest;
// ── Per-fund cash and interest breakdowns ──
const operatingCash = accounts
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'operating')
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
const reserveCash = accounts
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'reserve')
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
const opInvTotal = operatingInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const resInvTotal = reserveInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const opMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'operating' && parseFloat(a.interest_rate || '0') > 0)
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
+ operatingInvestments
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
const resMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'reserve' && parseFloat(a.interest_rate || '0') > 0)
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
+ reserveInvestments
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
// ── Adjust modal: current balance from trial balance ── // ── Adjust modal: current balance from trial balance ──
const adjustCurrentBalance = adjustingAccount const adjustCurrentBalance = adjustingAccount
@@ -472,37 +504,35 @@ export function AccountsPage() {
onChange={(e) => setShowArchived(e.currentTarget.checked)} onChange={(e) => setShowArchived(e.currentTarget.checked)}
size="sm" size="sm"
/> />
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}> {!isReadOnly && (
Add Account <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
</Button> Add Account
</Button>
)}
</Group> </Group>
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 4 }}> <SimpleGrid cols={{ base: 2, sm: 4 }}>
<Card withBorder p="xs"> <Card withBorder p="xs">
<Text size="xs" c="dimmed">Cash on Hand</Text> <Text size="xs" c="dimmed">Operating Fund</Text>
<Text fw={700} size="sm" c="green">{fmt(totalsByType['asset'] || 0)}</Text> <Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
</Card> </Card>
{investmentTotal > 0 && (
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Investments</Text>
<Text fw={700} size="sm" c="teal">{fmt(investmentTotal)}</Text>
</Card>
)}
{(totalsByType['liability'] || 0) > 0 && (
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Liabilities</Text>
<Text fw={700} size="sm" c="red">{fmt(totalsByType['liability'] || 0)}</Text>
</Card>
)}
<Card withBorder p="xs"> <Card withBorder p="xs">
<Text size="xs" c="dimmed">Net Position</Text> <Text size="xs" c="dimmed">Reserve Fund</Text>
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
</Card>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Total All Funds</Text>
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text> <Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
<Text size="xs" c="dimmed">Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)}</Text>
</Card> </Card>
{estMonthlyInterest > 0 && ( {estMonthlyInterest > 0 && (
<Card withBorder p="xs"> <Card withBorder p="xs">
<Text size="xs" c="dimmed">Est. Monthly Interest</Text> <Text size="xs" c="dimmed">Est. Monthly Interest</Text>
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text> <Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
</Card> </Card>
)} )}
</SimpleGrid> </SimpleGrid>
@@ -552,12 +582,12 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{investments.filter(i => i.is_active).length > 0 && ( {investments.filter(i => i.is_active).length > 0 && (
<> <>
<Divider label="Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -570,12 +600,12 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{operatingInvestments.length > 0 && ( {operatingInvestments.length > 0 && (
<> <>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -588,12 +618,12 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{reserveInvestments.length > 0 && ( {reserveInvestments.length > 0 && (
<> <>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -606,7 +636,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
isArchivedView isArchivedView
/> />
</Tabs.Panel> </Tabs.Panel>
@@ -908,6 +938,7 @@ function AccountTable({
onArchive, onArchive,
onSetPrimary, onSetPrimary,
onAdjustBalance, onAdjustBalance,
isReadOnly = false,
isArchivedView = false, isArchivedView = false,
}: { }: {
accounts: Account[]; accounts: Account[];
@@ -915,6 +946,7 @@ function AccountTable({
onArchive: (a: Account) => void; onArchive: (a: Account) => void;
onSetPrimary: (id: string) => void; onSetPrimary: (id: string) => void;
onAdjustBalance: (a: Account) => void; onAdjustBalance: (a: Account) => void;
isReadOnly?: boolean;
isArchivedView?: boolean; isArchivedView?: boolean;
}) { }) {
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0); const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
@@ -1003,42 +1035,44 @@ function AccountTable({
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''} {a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4}> {!isReadOnly && (
{!a.is_system && ( <Group gap={4}>
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}> {!a.is_system && (
<ActionIcon <Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
variant="subtle" <ActionIcon
color="yellow" variant="subtle"
onClick={() => onSetPrimary(a.id)} color="yellow"
> onClick={() => onSetPrimary(a.id)}
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />} >
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
)}
{!a.is_system && (
<Tooltip label="Adjust Balance">
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Edit account">
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} {!a.is_system && (
{!a.is_system && ( <Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<Tooltip label="Adjust Balance"> <ActionIcon
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}> variant="subtle"
<IconAdjustments size={16} /> color={a.is_active ? 'gray' : 'green'}
</ActionIcon> onClick={() => onArchive(a)}
</Tooltip> >
)} {a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
<Tooltip label="Edit account"> </ActionIcon>
<ActionIcon variant="subtle" onClick={() => onEdit(a)}> </Tooltip>
<IconEdit size={16} /> )}
</ActionIcon> </Group>
</Tooltip> )}
{!a.is_system && (
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<ActionIcon
variant="subtle"
color={a.is_active ? 'gray' : 'green'}
onClick={() => onArchive(a)}
>
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
</ActionIcon>
</Tooltip>
)}
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); );
@@ -1053,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({ function InvestmentMiniTable({
investments, investments,
onEdit, onEdit,
isReadOnly = false,
}: { }: {
investments: Investment[]; investments: Investment[];
onEdit: (inv: Investment) => void; onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) { }) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0); const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce( const totalValue = investments.reduce(
@@ -1090,6 +1126,7 @@ function InvestmentMiniTable({
<Table.Th>Name</Table.Th> <Table.Th>Name</Table.Th>
<Table.Th>Institution</Table.Th> <Table.Th>Institution</Table.Th>
<Table.Th>Type</Table.Th> <Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Principal</Table.Th> <Table.Th ta="right">Principal</Table.Th>
<Table.Th ta="right">Current Value</Table.Th> <Table.Th ta="right">Current Value</Table.Th>
<Table.Th ta="right">Rate</Table.Th> <Table.Th ta="right">Rate</Table.Th>
@@ -1097,13 +1134,13 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th> <Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th> <Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th> <Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th> {!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{investments.length === 0 && ( {investments.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={11}> <Table.Td colSpan={12}>
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text> <Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -1117,6 +1154,11 @@ function InvestmentMiniTable({
{inv.investment_type} {inv.investment_type}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td>
<Badge color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td> <Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td> <Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td>
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td> <Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
@@ -1142,13 +1184,15 @@ function InvestmentMiniTable({
'-' '-'
)} )}
</Table.Td> </Table.Td>
<Table.Td> {!isReadOnly && (
<Tooltip label="Edit investment"> <Table.Td>
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}> <Tooltip label="Edit investment">
<IconEdit size={16} /> <ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
</ActionIcon> <IconEdit size={16} />
</Tooltip> </ActionIcon>
</Table.Td> </Tooltip>
</Table.Td>
)}
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip, ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
MultiSelect,
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
@@ -11,6 +12,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface AssessmentGroup { interface AssessmentGroup {
id: string; id: string;
@@ -20,6 +22,8 @@ interface AssessmentGroup {
special_assessment: string; special_assessment: string;
unit_count: number; unit_count: number;
frequency: string; frequency: string;
due_months: number[];
due_day: number;
actual_unit_count: string; actual_unit_count: string;
monthly_operating_income: string; monthly_operating_income: string;
monthly_reserve_income: string; monthly_reserve_income: string;
@@ -48,10 +52,34 @@ const frequencyColors: Record<string, string> = {
annual: 'violet', annual: 'violet',
}; };
const MONTH_OPTIONS = [
{ value: '1', label: 'January' },
{ value: '2', label: 'February' },
{ value: '3', label: 'March' },
{ value: '4', label: 'April' },
{ value: '5', label: 'May' },
{ value: '6', label: 'June' },
{ value: '7', label: 'July' },
{ value: '8', label: 'August' },
{ value: '9', label: 'September' },
{ value: '10', label: 'October' },
{ value: '11', label: 'November' },
{ value: '12', label: 'December' },
];
const MONTH_ABBREV = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DEFAULT_DUE_MONTHS: Record<string, string[]> = {
monthly: ['1','2','3','4','5','6','7','8','9','10','11','12'],
quarterly: ['1','4','7','10'],
annual: ['1'],
};
export function AssessmentGroupsPage() { export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null); const [editing, setEditing] = useState<AssessmentGroup | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({ const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'], queryKey: ['assessment-groups'],
@@ -71,18 +99,31 @@ export function AssessmentGroupsPage() {
specialAssessment: 0, specialAssessment: 0,
unitCount: 0, unitCount: 0,
frequency: 'monthly', frequency: 'monthly',
dueMonths: DEFAULT_DUE_MONTHS.monthly,
dueDay: 1,
}, },
validate: { validate: {
name: (v) => (v.length > 0 ? null : 'Required'), name: (v) => (v.length > 0 ? null : 'Required'),
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'), regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
dueMonths: (v, values) => {
if (values.frequency === 'quarterly' && v.length !== 4) return 'Quarterly requires exactly 4 months';
if (values.frequency === 'annual' && v.length !== 1) return 'Annual requires exactly 1 month';
return null;
},
dueDay: (v) => (v >= 1 && v <= 28 ? null : 'Must be 1-28'),
}, },
}); });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (values: any) => mutationFn: (values: any) => {
editing const payload = {
? api.put(`/assessment-groups/${editing.id}`, values) ...values,
: api.post('/assessment-groups', values), dueMonths: values.dueMonths.map(Number),
};
return editing
? api.put(`/assessment-groups/${editing.id}`, payload)
: api.post('/assessment-groups', payload);
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] }); queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] }); queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
@@ -119,6 +160,9 @@ export function AssessmentGroupsPage() {
const handleEdit = (group: AssessmentGroup) => { const handleEdit = (group: AssessmentGroup) => {
setEditing(group); setEditing(group);
const dueMonths = group.due_months
? group.due_months.map(String)
: DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly;
form.setValues({ form.setValues({
name: group.name, name: group.name,
description: group.description || '', description: group.description || '',
@@ -126,6 +170,8 @@ export function AssessmentGroupsPage() {
specialAssessment: parseFloat(group.special_assessment || '0'), specialAssessment: parseFloat(group.special_assessment || '0'),
unitCount: group.unit_count || 0, unitCount: group.unit_count || 0,
frequency: group.frequency || 'monthly', frequency: group.frequency || 'monthly',
dueMonths,
dueDay: group.due_day || 1,
}); });
open(); open();
}; };
@@ -136,6 +182,12 @@ export function AssessmentGroupsPage() {
open(); open();
}; };
const handleFrequencyChange = (value: string | null) => {
if (!value) return;
form.setFieldValue('frequency', value);
form.setFieldValue('dueMonths', DEFAULT_DUE_MONTHS[value] || DEFAULT_DUE_MONTHS.monthly);
};
const fmt = (v: string | number) => const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@@ -147,6 +199,11 @@ export function AssessmentGroupsPage() {
} }
}; };
const formatDueMonths = (months: number[], frequency: string) => {
if (!months || frequency === 'monthly') return 'Every month';
return months.map((m) => MONTH_ABBREV[m]).join(', ');
};
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
return ( return (
@@ -156,9 +213,11 @@ export function AssessmentGroupsPage() {
<Title order={2}>Assessment Groups</Title> <Title order={2}>Assessment Groups</Title>
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text> <Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
</div> </div>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}> {!isReadOnly && (
Add Group <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
</Button> Add Group
</Button>
)}
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}> <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
@@ -215,6 +274,7 @@ export function AssessmentGroupsPage() {
<Table.Th>Group Name</Table.Th> <Table.Th>Group Name</Table.Th>
<Table.Th ta="center">Units</Table.Th> <Table.Th ta="center">Units</Table.Th>
<Table.Th>Frequency</Table.Th> <Table.Th>Frequency</Table.Th>
<Table.Th>Due Months</Table.Th>
<Table.Th ta="right">Regular Assessment</Table.Th> <Table.Th ta="right">Regular Assessment</Table.Th>
<Table.Th ta="right">Special Assessment</Table.Th> <Table.Th ta="right">Special Assessment</Table.Th>
<Table.Th ta="right">Monthly Equiv.</Table.Th> <Table.Th ta="right">Monthly Equiv.</Table.Th>
@@ -225,7 +285,7 @@ export function AssessmentGroupsPage() {
<Table.Tbody> <Table.Tbody>
{groups.length === 0 && ( {groups.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={8}> <Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg"> <Text ta="center" c="dimmed" py="lg">
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc. No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
</Text> </Text>
@@ -259,6 +319,9 @@ export function AssessmentGroupsPage() {
{frequencyLabels[g.frequency] || 'Monthly'} {frequencyLabels[g.frequency] || 'Monthly'}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDueMonths(g.due_months, g.frequency)}</Text>
</Table.Td>
<Table.Td ta="right" ff="monospace"> <Table.Td ta="right" ff="monospace">
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)} {fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
</Table.Td> </Table.Td>
@@ -274,28 +337,30 @@ export function AssessmentGroupsPage() {
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4}> {!isReadOnly && (
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}> <Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
<ActionIcon
variant="subtle"
color={g.is_default ? 'yellow' : 'gray'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
disabled={g.is_default}
>
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={g.is_default ? 'yellow' : 'gray'} color={g.is_active ? 'gray' : 'green'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)} onClick={() => archiveMutation.mutate(g)}
disabled={g.is_default}
> >
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />} <IconArchive size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Group>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}> )}
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={g.is_active ? 'gray' : 'green'}
onClick={() => archiveMutation.mutate(g)}
>
<IconArchive size={16} />
</ActionIcon>
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
@@ -316,8 +381,22 @@ export function AssessmentGroupsPage() {
{ value: 'quarterly', label: 'Quarterly' }, { value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' }, { value: 'annual', label: 'Annual' },
]} ]}
{...form.getInputProps('frequency')} value={form.values.frequency}
onChange={handleFrequencyChange}
/> />
{form.values.frequency !== 'monthly' && (
<MultiSelect
label={form.values.frequency === 'quarterly' ? 'Billing Quarters (select 4 months)' : 'Due Month'}
description={form.values.frequency === 'quarterly'
? 'Select the first month of each quarter when assessments are due'
: 'Select the month when the annual assessment is due'}
data={MONTH_OPTIONS}
value={form.values.dueMonths}
onChange={(v) => form.setFieldValue('dueMonths', v)}
error={form.errors.dueMonths}
maxValues={form.values.frequency === 'annual' ? 1 : 4}
/>
)}
<Group grow> <Group grow>
<NumberInput <NumberInput
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`} label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
@@ -334,7 +413,16 @@ export function AssessmentGroupsPage() {
{...form.getInputProps('specialAssessment')} {...form.getInputProps('specialAssessment')}
/> />
</Group> </Group>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} /> <Group grow>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
<NumberInput
label="Due Day of Month"
description="Day invoices are due (1-28)"
min={1}
max={28}
{...form.getInputProps('dueDay')}
/>
</Group>
<Button type="submit" loading={saveMutation.isPending}> <Button type="submit" loading={saveMutation.isPending}>
{editing ? 'Update' : 'Create'} {editing ? 'Update' : 'Create'}
</Button> </Button>

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 { import {
Center, Center,
Container, Container,
@@ -10,19 +10,44 @@ import {
Anchor, Anchor,
Stack, Stack,
Alert, Alert,
Divider,
Group,
PinInput,
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconAlertCircle } from '@tabler/icons-react'; import {
IconAlertCircle,
IconBrandGoogle,
IconBrandWindows,
IconFingerprint,
IconShieldLock,
} from '@tabler/icons-react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { startAuthentication } from '@simplewebauthn/browser';
import api from '../../services/api'; import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import logoSrc from '../../assets/logo.svg'; import { usePreferencesStore } from '../../stores/preferencesStore';
import logoSrc from '../../assets/logo.png';
type LoginState = 'credentials' | 'mfa';
export function LoginPage() { export function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loginState, setLoginState] = useState<LoginState>('credentials');
const [mfaToken, setMfaToken] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [useRecovery, setUseRecovery] = useState(false);
const [recoveryCode, setRecoveryCode] = useState('');
const [ssoProviders, setSsoProviders] = useState<{ google: boolean; azure: boolean }>({
google: false,
azure: false,
});
const [passkeySupported, setPasskeySupported] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth); const setAuth = useAuthStore((s) => s.setAuth);
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const form = useForm({ const form = useForm({
initialValues: { email: '', password: '' }, initialValues: { email: '', password: '' },
@@ -32,20 +57,42 @@ export function LoginPage() {
}, },
}); });
// Fetch SSO providers & check passkey support on mount
useEffect(() => {
api
.get('/auth/sso/providers')
.then(({ data }) => setSsoProviders(data))
.catch(() => {});
if (
window.PublicKeyCredential &&
typeof window.PublicKeyCredential === 'function'
) {
setPasskeySupported(true);
}
}, []);
const handleLoginSuccess = (data: any) => {
setAuth(data.accessToken, data.user, data.organizations);
if (data.user?.isSuperadmin && data.organizations.length === 0) {
navigate('/admin');
} else if (data.organizations.length >= 1) {
navigate('/select-org');
} else {
navigate('/');
}
};
const handleSubmit = async (values: typeof form.values) => { const handleSubmit = async (values: typeof form.values) => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const { data } = await api.post('/auth/login', values); const { data } = await api.post('/auth/login', values);
setAuth(data.accessToken, data.user, data.organizations); if (data.mfaRequired) {
// Platform owner / superadmin with no orgs → admin panel setMfaToken(data.mfaToken);
if (data.user?.isSuperadmin && data.organizations.length === 0) { setLoginState('mfa');
navigate('/admin');
} else if (data.organizations.length >= 1) {
// Always go through org selection to ensure correct JWT with orgSchema
navigate('/select-org');
} else { } else {
navigate('/'); handleLoginSuccess(data);
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || 'Login failed'); setError(err.response?.data?.message || 'Login failed');
@@ -54,10 +101,197 @@ export function LoginPage() {
} }
}; };
const handleMfaVerify = async () => {
setLoading(true);
setError('');
try {
const token = useRecovery ? recoveryCode : mfaCode;
const { data } = await api.post('/auth/mfa/verify', {
mfaToken,
token,
isRecoveryCode: useRecovery,
});
handleLoginSuccess(data);
} catch (err: any) {
setError(err.response?.data?.message || 'MFA verification failed');
} finally {
setLoading(false);
}
};
const handlePasskeyLogin = async () => {
setLoading(true);
setError('');
try {
// Get authentication options
const { data: options } = await api.post('/auth/passkeys/login-options', {
email: form.values.email || undefined,
});
// Trigger browser WebAuthn prompt
const credential = await startAuthentication({ optionsJSON: options });
// Verify with server
const { data } = await api.post('/auth/passkeys/login', {
response: credential,
challenge: options.challenge,
});
handleLoginSuccess(data);
} catch (err: any) {
if (err.name === 'NotAllowedError') {
setError('Passkey authentication was cancelled');
} else {
setError(err.response?.data?.message || err.message || 'Passkey login failed');
}
} finally {
setLoading(false);
}
};
const hasSso = ssoProviders.google || ssoProviders.azure;
// MFA verification screen
if (loginState === 'mfa') {
return (
<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>
<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 ( return (
<Container size={420} my={80}> <Container size={420} my={80}>
<Center> <Center>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 60 }} /> <img
src={logoSrc}
alt="HOA LedgerIQ"
style={{
height: 60,
...(isDark
? {
filter:
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
}
: {}),
}}
/>
</Center> </Center>
<Text c="dimmed" size="sm" ta="center" mt={5}> <Text c="dimmed" size="sm" ta="center" mt={5}>
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
@@ -91,6 +325,53 @@ export function LoginPage() {
</Button> </Button>
</Stack> </Stack>
</form> </form>
{/* Passkey login */}
{passkeySupported && (
<>
<Divider label="or" labelPosition="center" my="md" />
<Button
variant="light"
fullWidth
leftSection={<IconFingerprint size={18} />}
onClick={handlePasskeyLogin}
loading={loading}
>
Sign in with Passkey
</Button>
</>
)}
{/* SSO providers */}
{hasSso && (
<>
<Divider label="or continue with" labelPosition="center" my="md" />
<Group grow>
{ssoProviders.google && (
<Button
variant="default"
leftSection={<IconBrandGoogle size={18} color="#4285F4" />}
onClick={() => {
window.location.href = '/api/auth/google';
}}
>
Google
</Button>
)}
{ssoProviders.azure && (
<Button
variant="default"
leftSection={<IconBrandWindows size={18} color="#0078D4" />}
onClick={() => {
window.location.href = '/api/auth/azure';
}}
>
Microsoft
</Button>
)}
</Group>
</>
)}
</Paper> </Paper>
</Container> </Container>
); );

View File

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

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,776 @@
import { useState, useEffect, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card, Alert, Modal,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
IconCheck, IconArrowBack, IconTrash, IconRefresh,
IconUpload, IconDownload,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface PlanLine {
id: string;
account_id: string;
account_number: string;
account_name: string;
account_type: string;
fund_type: string;
is_manually_adjusted: boolean;
jan: number; feb: number; mar: number; apr: number;
may: number; jun: number; jul: number; aug: number;
sep: number; oct: number; nov: number; dec_amt: number;
annual_total: number;
}
const monthKeys = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
function hydrateLine(row: any): PlanLine {
const line: any = { ...row };
for (const m of monthKeys) {
line[m] = Number(line[m]) || 0;
}
line.annual_total = monthKeys.reduce((sum, m) => sum + (line[m] || 0), 0);
return line as PlanLine;
}
function parseCurrencyValue(val: string): number {
if (!val) return 0;
let s = val.trim();
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
const isNegative = s.includes('(') && s.includes(')');
s = s.replace(/[$,\s()]/g, '');
if (!s || s === '-') return 0;
const num = parseFloat(s);
if (isNaN(num)) return 0;
return isNegative ? -num : num;
}
function parseCSV(text: string): Record<string, string>[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values: string[] = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < line.length; j++) {
const ch = line[j];
if (ch === '"') { inQuotes = !inQuotes; }
else if (ch === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += ch; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, idx) => { row[h] = values[idx] || ''; });
rows.push(row);
}
return rows;
}
const statusColors: Record<string, string> = {
planning: 'blue',
approved: 'yellow',
ratified: 'green',
};
export function BudgetPlanningPage() {
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const [selectedYear, setSelectedYear] = useState<string | null>(null);
const [lineData, setLineData] = useState<PlanLine[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [inflationInput, setInflationInput] = useState<number>(2.5);
const [confirmModal, setConfirmModal] = useState<{ action: string; title: string; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Available years
const { data: availableYears } = useQuery<any>({
queryKey: ['budget-plan-available-years'],
queryFn: async () => {
const { data } = await api.get('/board-planning/budget-plans/available-years');
return data;
},
});
// Set default year when available
useEffect(() => {
if (availableYears?.years?.length && !selectedYear) {
setSelectedYear(String(availableYears.years[0].year));
}
}, [availableYears, selectedYear]);
// Plan data for selected year
const { data: plan, isLoading } = useQuery<any>({
queryKey: ['budget-plan', selectedYear],
queryFn: async () => {
const { data } = await api.get(`/board-planning/budget-plans/${selectedYear}`);
return data;
},
enabled: !!selectedYear,
});
// Hydrate lines when plan changes
useEffect(() => {
if (plan?.lines) {
setLineData(plan.lines.map(hydrateLine));
setInflationInput(parseFloat(plan.inflation_rate) || 2.5);
setIsEditing(false);
} else {
setLineData([]);
}
}, [plan]);
const hasBaseBudget = !!availableYears?.latestBudgetYear;
const yearOptions = (availableYears?.years || []).map((y: any) => ({
value: String(y.year),
label: `${y.year}${y.hasPlan ? ` (${y.status})` : ''}`,
}));
// If no base budget at all, also offer the current year as an option
const currentYear = new Date().getFullYear();
const allYearOptions = !hasBaseBudget && !yearOptions.find((y: any) => y.value === String(currentYear))
? [{ value: String(currentYear), label: String(currentYear) }, ...yearOptions]
: yearOptions;
// Mutations
const createMutation = useMutation({
mutationFn: async () => {
const fiscalYear = parseInt(selectedYear!, 10);
const baseYear = availableYears?.latestBudgetYear || new Date().getFullYear();
const { data } = await api.post('/board-planning/budget-plans', {
fiscalYear,
baseYear,
inflationRate: inflationInput,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan'] });
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
notifications.show({ message: 'Budget plan created', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Create failed', color: 'red' });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
const payload = lineData.map((l) => ({
accountId: l.account_id,
fundType: l.fund_type,
jan: l.jan, feb: l.feb, mar: l.mar, apr: l.apr,
may: l.may, jun: l.jun, jul: l.jul, aug: l.aug,
sep: l.sep, oct: l.oct, nov: l.nov, dec: l.dec_amt,
}));
return api.put(`/board-planning/budget-plans/${selectedYear}/lines`, {
planId: plan.id,
lines: payload,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
setIsEditing(false);
notifications.show({ message: 'Budget plan saved', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Save failed', color: 'red' });
},
});
const importMutation = useMutation({
mutationFn: async (lines: Record<string, string>[]) => {
const parsed = lines.map((row) => ({
account_number: row.account_number || row.accountnumber || '',
account_name: row.account_name || row.accountname || '',
jan: parseCurrencyValue(row.jan),
feb: parseCurrencyValue(row.feb),
mar: parseCurrencyValue(row.mar),
apr: parseCurrencyValue(row.apr),
may: parseCurrencyValue(row.may),
jun: parseCurrencyValue(row.jun),
jul: parseCurrencyValue(row.jul),
aug: parseCurrencyValue(row.aug),
sep: parseCurrencyValue(row.sep),
oct: parseCurrencyValue(row.oct),
nov: parseCurrencyValue(row.nov),
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
}));
const { data } = await api.post(`/board-planning/budget-plans/${selectedYear}/import`, parsed);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
let msg = `Imported ${data.imported} budget line(s)`;
if (data.created?.length) msg += `. Created ${data.created.length} new account(s)`;
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
notifications.show({
message: msg,
color: data.errors?.length ? 'yellow' : 'green',
autoClose: 10000,
});
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
},
});
const inflationMutation = useMutation({
mutationFn: () => api.put(`/board-planning/budget-plans/${selectedYear}/inflation`, {
inflationRate: inflationInput,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
notifications.show({ message: 'Inflation rate applied', color: 'green' });
},
});
const statusMutation = useMutation({
mutationFn: (status: string) => api.put(`/board-planning/budget-plans/${selectedYear}/status`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
queryClient.invalidateQueries({ queryKey: ['budgets'] });
setConfirmModal(null);
notifications.show({ message: 'Status updated', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Status update failed', color: 'red' });
setConfirmModal(null);
},
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/board-planning/budget-plans/${selectedYear}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] });
setConfirmModal(null);
notifications.show({ message: 'Budget plan deleted', color: 'orange' });
},
});
const updateCell = (idx: number, month: string, value: number) => {
const updated = [...lineData];
(updated[idx] as any)[month] = value || 0;
updated[idx].annual_total = monthKeys.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
setLineData(updated);
};
const handleCancelEdit = () => {
setIsEditing(false);
queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] });
};
const handleDownloadTemplate = async () => {
try {
const yr = selectedYear || currentYear;
const response = await api.get(`/board-planning/budget-plans/${yr}/template`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `budget_template_${yr}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err: any) {
notifications.show({ message: 'Failed to download template', color: 'red' });
}
};
const handleImportCSV = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
const rows = parseCSV(text);
if (rows.length === 0) { notifications.show({ message: 'No data rows found in CSV', color: 'red' }); return; }
importMutation.mutate(rows);
};
reader.readAsText(file);
event.target.value = '';
};
const hasPlan = !!plan?.id;
const status = plan?.status || 'planning';
const cellsEditable = !isReadOnly && isEditing && status !== 'ratified';
const incomeLines = lineData.filter((b) => b.account_type === 'income');
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
const expenseLines = lineData.filter((b) => b.account_type === 'expense');
const totalOperatingIncome = operatingIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
const totalReserveIncome = reserveIncomeLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
const totalExpense = expenseLines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
return (
<Stack>
{/* Header */}
<Group justify="space-between" align="flex-start">
<Group align="center">
<Title order={2}>Budget Planning</Title>
{hasPlan && (
<Badge size="lg" color={statusColors[status]}>
{status}
</Badge>
)}
</Group>
<Group>
<Select
data={allYearOptions}
value={selectedYear}
onChange={setSelectedYear}
w={180}
placeholder="Select year"
/>
<Button
variant="outline"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
size="sm"
>
Download Template
</Button>
</Group>
</Group>
{/* Hidden file input for CSV import */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
{isLoading && <Center h={300}><Loader /></Center>}
{/* Empty state - no base budget exists at all */}
{!isLoading && !hasPlan && selectedYear && !hasBaseBudget && (
<Alert icon={<IconInfoCircle size={16} />} color="orange" variant="light">
<Stack gap="sm">
<Text fw={600}>No budget data found in the system</Text>
<Text size="sm">
To get started with budget planning, you need to load an initial budget.
You can either create a new budget from scratch or import an existing budget from a CSV file.
</Text>
<Text size="sm" c="dimmed">
Use <Text span fw={600}>Download Template</Text> above to get a CSV with your chart of accounts pre-populated,
fill in the monthly amounts, then import it below.
</Text>
<Group>
<Button
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import Budget from CSV
</Button>
<Button
variant="light"
onClick={() => createMutation.mutate()}
loading={createMutation.isPending}
>
Create Empty Budget Plan
</Button>
</Group>
</Stack>
</Alert>
)}
{/* Empty state - base budget exists but no plan for this year */}
{!isLoading && !hasPlan && selectedYear && hasBaseBudget && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
<Stack gap="sm">
<Text>No budget plan exists for {selectedYear}. Create one based on the {availableYears?.latestBudgetYear} budget with an inflation adjustment, or import a CSV directly.</Text>
<Group>
<NumberInput
label="Inflation Rate (%)"
value={inflationInput}
onChange={(v) => setInflationInput(Number(v) || 0)}
min={0}
max={50}
step={0.5}
decimalScale={2}
w={160}
size="sm"
/>
<Button
mt={24}
onClick={() => createMutation.mutate()}
loading={createMutation.isPending}
>
Create Budget Plan for {selectedYear}
</Button>
<Text mt={24} c="dimmed">or</Text>
<Button
mt={24}
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import from CSV
</Button>
</Group>
<Text size="xs" c="dimmed">
Base year: {availableYears?.latestBudgetYear}. Each monthly amount will be compounded annually by the specified inflation rate.
</Text>
</Stack>
</Alert>
)}
{/* Plan controls */}
{hasPlan && (
<>
<Group justify="space-between">
<Group>
<NumberInput
label="Inflation Rate (%)"
value={inflationInput}
onChange={(v) => setInflationInput(Number(v) || 0)}
min={0}
max={50}
step={0.5}
decimalScale={2}
w={140}
size="xs"
disabled={status === 'ratified' || isReadOnly}
/>
<Button
mt={24}
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => {
setConfirmModal({
action: 'inflation',
title: 'Apply Inflation Rate',
message: `This will recalculate all non-manually-adjusted lines using ${inflationInput}% inflation compounded annually from the base year (${plan.base_year}). Manually adjusted lines will be preserved.`,
});
}}
disabled={status === 'ratified' || isReadOnly}
>
Apply
</Button>
<Text size="xs" c="dimmed" mt={24}>Base year: {plan.base_year}</Text>
</Group>
<Group>
{!isReadOnly && (
<>
{/* Import CSV into existing plan */}
{status !== 'ratified' && (
<Button
size="sm"
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import CSV
</Button>
)}
{/* Status actions */}
{status === 'planning' && (
<>
<Button
size="sm"
variant="light"
color="yellow"
leftSection={<IconCheck size={16} />}
onClick={() => setConfirmModal({
action: 'approved',
title: 'Approve Budget Plan',
message: `Mark the ${selectedYear} budget plan as approved? This indicates the board has reviewed and accepted the plan.`,
})}
>
Approve
</Button>
<Button
size="sm"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => setConfirmModal({
action: 'delete',
title: 'Delete Budget Plan',
message: `Permanently delete the ${selectedYear} budget plan? This cannot be undone.`,
})}
>
Delete
</Button>
</>
)}
{status === 'approved' && (
<>
<Button
size="sm"
variant="light"
leftSection={<IconArrowBack size={16} />}
onClick={() => setConfirmModal({
action: 'planning',
title: 'Revert to Planning',
message: `Revert the ${selectedYear} budget plan back to planning status?`,
})}
>
Revert to Planning
</Button>
<Button
size="sm"
color="green"
leftSection={<IconCheck size={16} />}
onClick={() => setConfirmModal({
action: 'ratified',
title: 'Ratify Budget',
message: `Ratify the ${selectedYear} budget? This will create the official budget for ${selectedYear} in Financials, overwriting any existing budget data for that year.`,
})}
>
Ratify Budget
</Button>
</>
)}
{status === 'ratified' && (
<Button
size="sm"
variant="light"
color="orange"
leftSection={<IconArrowBack size={16} />}
onClick={() => setConfirmModal({
action: 'approved',
title: 'Revert from Ratified',
message: `Revert the ${selectedYear} budget from ratified to approved? This will remove the official budget for ${selectedYear} from Financials.`,
})}
>
Revert to Approved
</Button>
)}
{/* Edit/Save */}
{status !== 'ratified' && (
<>
{!isEditing ? (
<Button
size="sm"
variant="outline"
leftSection={<IconPencil size={16} />}
onClick={() => setIsEditing(true)}
>
Edit
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
color="gray"
leftSection={<IconX size={16} />}
onClick={handleCancelEdit}
>
Cancel
</Button>
<Button
size="sm"
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
>
Save
</Button>
</>
)}
</>
)}
</>
)}
</Group>
</Group>
{/* Summary cards */}
<Group>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Operating Income</Text>
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
</Card>
{totalReserveIncome > 0 && (
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Reserve Income</Text>
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
</Card>
)}
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Expenses</Text>
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
</Card>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Net (Operating)</Text>
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalOperatingIncome - totalExpense)}
</Text>
</Card>
</Group>
{/* Data table */}
<div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
{monthLabels.map((m) => (
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
))}
<Table.Th ta="right" style={{ minWidth: 110 }}>Annual</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lineData.length === 0 && (
<Table.Tr>
<Table.Td colSpan={15}>
<Text ta="center" c="dimmed" py="lg">No budget plan lines.</Text>
</Table.Td>
</Table.Tr>
)}
{['income', 'expense'].map((type) => {
const lines = lineData.filter((b) => b.account_type === type);
if (lines.length === 0) return null;
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
const sectionTotal = lines.reduce((sum, l) => sum + (l.annual_total || 0), 0);
return [
<Table.Tr key={`header-${type}`} style={{ background: sectionBg }}>
<Table.Td
colSpan={2}
fw={700}
tt="capitalize"
style={{ position: 'sticky', left: 0, background: sectionBg, zIndex: 2 }}
>
{type}
</Table.Td>
{monthLabels.map((m) => <Table.Td key={m} />)}
<Table.Td ta="right" fw={700} ff="monospace">{fmt(sectionTotal)}</Table.Td>
</Table.Tr>,
...lines.map((line) => {
const idx = lineData.indexOf(line);
return (
<Table.Tr key={line.id || `${line.account_id}-${line.fund_type}`}>
<Table.Td
style={{
position: 'sticky', left: 0, background: stickyBg,
zIndex: 1, borderRight: `1px solid ${stickyBorder}`,
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td>
<Table.Td
style={{
position: 'sticky', left: 120, background: stickyBg,
zIndex: 1, borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>{line.account_name}</Text>
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
{line.is_manually_adjusted && <Badge size="xs" color="orange" variant="dot">edited</Badge>}
</Group>
</Table.Td>
{monthKeys.map((m) => (
<Table.Td key={m} p={2}>
{cellsEditable ? (
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ta="right" ff="monospace">
{fmt((line as any)[m] || 0)}
</Text>
)}
</Table.Td>
))}
<Table.Td ta="right" fw={500} ff="monospace">
{fmt(line.annual_total || 0)}
</Table.Td>
</Table.Tr>
);
}),
];
})}
</Table.Tbody>
</Table>
</div>
</>
)}
{/* Confirmation modal */}
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal?.title || ''}
centered
>
<Stack>
<Text size="sm">{confirmModal?.message}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
color={confirmModal?.action === 'delete' ? 'red' : undefined}
loading={statusMutation.isPending || deleteMutation.isPending || inflationMutation.isPending}
onClick={() => {
if (!confirmModal) return;
if (confirmModal.action === 'delete') {
deleteMutation.mutate();
} else if (confirmModal.action === 'inflation') {
inflationMutation.mutate();
setConfirmModal(null);
} else {
statusMutation.mutate(confirmModal.action);
}
}}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

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

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