19 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:12:35 -04:00
69 changed files with 8055 additions and 448 deletions

View File

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

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

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

22
PARKING-LOT.md Normal file
View File

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

1190
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,5 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module';
@@ -29,6 +30,9 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
import { BillingModule } from './modules/billing/billing.module';
import { EmailModule } from './modules/email/email.module';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
@@ -81,6 +85,9 @@ import { ScheduleModule } from '@nestjs/schedule';
InvestmentPlanningModule,
HealthScoresModule,
BoardPlanningModule,
BillingModule,
EmailModule,
OnboardingModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],
@@ -89,6 +96,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,
},
],
})
export class AppModule implements NestModule {

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
@@ -38,10 +39,15 @@ if (WORKERS > 1 && cluster.isPrimary) {
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
// Enable raw body for Stripe webhook signature verification
rawBody: true,
});
app.setGlobalPrefix('api');
// Cookie parser — needed for refresh token httpOnly cookies
app.use(cookieParser());
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
app.use(

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,21 +4,37 @@ import {
ConflictException,
ForbiddenException,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { randomBytes, createHash } from 'crypto';
import { UsersService } from '../users/users.service';
import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly inviteSecret: string;
private readonly appUrl: string;
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private configService: ConfigService,
private dataSource: DataSource,
) {}
private refreshTokenService: RefreshTokenService,
private emailService: EmailService,
) {
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
}
async register(dto: RegisterDto) {
const existing = await this.usersService.findByEmail(dto.email);
@@ -72,9 +88,27 @@ export class AuthService {
// Record login in history (org_id is null at initial login)
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
// If MFA is enabled, return a challenge token instead of full session
if (u.mfaEnabled && u.mfaSecret) {
const mfaToken = this.jwtService.sign(
{ sub: u.id, type: 'mfa_challenge' },
{ expiresIn: '5m' },
);
return { mfaRequired: true, mfaToken };
}
return this.generateTokenResponse(u);
}
/**
* Complete login after MFA verification — generate full session tokens.
*/
async completeMfaLogin(userId: string): Promise<any> {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) throw new UnauthorizedException('User not found');
return this.generateTokenResponse(user);
}
async getProfile(userId: string) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
@@ -85,6 +119,7 @@ export class AuthService {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
mfaEnabled: user.mfaEnabled || false,
organizations: user.userOrganizations?.map((uo) => ({
id: uo.organization.id,
name: uo.organization.name,
@@ -124,8 +159,12 @@ export class AuthService {
// Record org switch in login history
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
// Generate new refresh token for org switch
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
organization: {
id: membership.organization.id,
name: membership.organization.name,
@@ -135,10 +174,244 @@ export class AuthService {
};
}
/**
* Refresh an access token using a valid refresh token.
*/
async refreshAccessToken(rawRefreshToken: string) {
const userId = await this.refreshTokenService.validateRefreshToken(rawRefreshToken);
if (!userId) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
throw new UnauthorizedException('User not found');
}
// Generate a new access token (keep same org context if available)
const orgs = (user.userOrganizations || []).filter(
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
);
const defaultOrg = orgs[0];
const payload: Record<string, any> = {
sub: user.id,
email: user.email,
isSuperadmin: user.isSuperadmin || false,
};
if (defaultOrg) {
payload.orgId = defaultOrg.organizationId;
payload.role = defaultOrg.role;
}
return {
accessToken: this.jwtService.sign(payload),
};
}
/**
* Logout: revoke the refresh token.
*/
async logout(rawRefreshToken: string): Promise<void> {
if (rawRefreshToken) {
await this.refreshTokenService.revokeToken(rawRefreshToken);
}
}
/**
* Logout everywhere: revoke all refresh tokens for a user.
*/
async logoutEverywhere(userId: string): Promise<void> {
await this.refreshTokenService.revokeAllUserTokens(userId);
}
async markIntroSeen(userId: string): Promise<void> {
await this.usersService.markIntroSeen(userId);
}
// ─── Invite Token (Activation) Methods ──────────────────────────────
/**
* Validate an invite/activation token.
*/
async validateInviteToken(token: string) {
try {
const payload = this.jwtService.verify(token, { secret: this.inviteSecret });
if (payload.type !== 'invite') throw new Error('Not an invite token');
const tokenHash = createHash('sha256').update(token).digest('hex');
const rows = await this.dataSource.query(
`SELECT it.*, o.name as org_name FROM shared.invite_tokens it
JOIN shared.organizations o ON o.id = it.organization_id
WHERE it.token_hash = $1`,
[tokenHash],
);
if (rows.length === 0) throw new Error('Token not found');
const row = rows[0];
if (row.used_at) throw new BadRequestException('This activation link has already been used');
if (new Date(row.expires_at) < new Date()) throw new BadRequestException('This activation link has expired');
return { valid: true, email: payload.email, orgName: row.org_name, orgId: payload.orgId, userId: payload.userId };
} catch (err) {
if (err instanceof BadRequestException) throw err;
throw new BadRequestException('Invalid or expired activation link');
}
}
/**
* Activate a user from an invite token (set password, activate, issue session).
*/
async activateUser(token: string, password: string, fullName: string) {
const info = await this.validateInviteToken(token);
const passwordHash = await bcrypt.hash(password, 12);
const [firstName, ...rest] = fullName.trim().split(' ');
const lastName = rest.join(' ') || '';
// Update user record
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, first_name = $2, last_name = $3,
is_email_verified = true, updated_at = NOW()
WHERE id = $4`,
[passwordHash, firstName, lastName, info.userId],
);
// Mark invite token as used
const tokenHash = createHash('sha256').update(token).digest('hex');
await this.dataSource.query(
`UPDATE shared.invite_tokens SET used_at = NOW() WHERE token_hash = $1`,
[tokenHash],
);
// Issue session
const user = await this.usersService.findByIdWithOrgs(info.userId);
if (!user) throw new NotFoundException('User not found after activation');
return this.generateTokenResponse(user);
}
/**
* Generate a signed invite token for a user/org pair.
*/
async generateInviteToken(userId: string, orgId: string, email: string): Promise<string> {
const token = this.jwtService.sign(
{ type: 'invite', userId, orgId, email },
{ secret: this.inviteSecret, expiresIn: '72h' },
);
const tokenHash = createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000);
await this.dataSource.query(
`INSERT INTO shared.invite_tokens (organization_id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)`,
[orgId, userId, tokenHash, expiresAt],
);
return token;
}
// ─── Password Reset Flow ──────────────────────────────────────────
/**
* Request a password reset. Generates a token, stores its hash, and sends an email.
* Silently succeeds even if the email doesn't exist (prevents enumeration).
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.usersService.findByEmail(email);
if (!user) {
// Silently return — don't reveal whether the account exists
return;
}
// Invalidate any existing reset tokens for this user
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW()
WHERE user_id = $1 AND used_at IS NULL`,
[user.id],
);
// Generate a 64-byte random token
const rawToken = randomBytes(64).toString('base64url');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await this.dataSource.query(
`INSERT INTO shared.password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[user.id, tokenHash, expiresAt],
);
const resetUrl = `${this.appUrl}/reset-password?token=${rawToken}`;
await this.emailService.sendPasswordResetEmail(user.email, resetUrl);
}
/**
* Reset password using a valid reset token.
*/
async resetPassword(rawToken: string, newPassword: string): Promise<void> {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const rows = await this.dataSource.query(
`SELECT id, user_id, expires_at, used_at
FROM shared.password_reset_tokens
WHERE token_hash = $1`,
[tokenHash],
);
if (rows.length === 0) {
throw new BadRequestException('Invalid or expired reset token');
}
const record = rows[0];
if (record.used_at) {
throw new BadRequestException('This reset link has already been used');
}
if (new Date(record.expires_at) < new Date()) {
throw new BadRequestException('This reset link has expired');
}
// Update password
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, record.user_id],
);
// Mark token as used
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW() WHERE id = $1`,
[record.id],
);
}
/**
* Change password for an authenticated user (requires current password).
*/
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
const user = await this.usersService.findById(userId);
if (!user || !user.passwordHash) {
throw new UnauthorizedException('User not found');
}
const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, userId],
);
}
// ─── Private Helpers ──────────────────────────────────────────────
private async recordLoginHistory(
userId: string,
organizationId: string | null,
@@ -156,7 +429,7 @@ export class AuthService {
}
}
private generateTokenResponse(user: User, impersonatedBy?: string) {
async generateTokenResponse(user: User, impersonatedBy?: string) {
const allOrgs = user.userOrganizations || [];
// Filter out suspended/archived organizations
const orgs = allOrgs.filter(
@@ -179,8 +452,12 @@ export class AuthService {
payload.role = defaultOrg.role;
}
// Create refresh token
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
user: {
id: user.id,
email: user.email,
@@ -189,6 +466,7 @@ export class AuthService {
isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
mfaEnabled: user.mfaEnabled || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,

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

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

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { BillingService } from './billing.service';
import { BillingController } from './billing.controller';
import { AuthModule } from '../auth/auth.module';
import { DatabaseModule } from '../../database/database.module';
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}

View File

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

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { EmailService } from './email.service';
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@@ -0,0 +1,348 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { Resend } from 'resend';
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private resend: Resend | null = null;
private fromAddress: string;
private replyToAddress: string;
constructor(
private configService: ConfigService,
private dataSource: DataSource,
) {
const apiKey = this.configService.get<string>('RESEND_API_KEY');
if (apiKey && !apiKey.includes('placeholder')) {
this.resend = new Resend(apiKey);
this.logger.log('Resend email service initialized');
} else {
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
}
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
}
// ─── Public API ──────────────────────────────────────────────
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: 'Your HOA LedgerIQ account is ready to activate.',
heading: 'Welcome to HOA LedgerIQ!',
body: `
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
<p>Click the button below to set your password and activate your account:</p>
`,
ctaText: 'Activate My Account',
ctaUrl: activationUrl,
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
});
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
}
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
const html = this.buildTemplate({
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
heading: `You're all set!`,
body: `
<p>Your account for <strong>${this.esc(businessName)}</strong> is now active.</p>
<p>Log in to start managing your HOA's finances, assessments, and investments — all in one place.</p>
`,
ctaText: 'Go to Dashboard',
ctaUrl: `${appUrl}/dashboard`,
footer: 'If you have any questions, just reply to this email and we\'ll help you get started.',
});
await this.send(email, subject, html, 'welcome', { businessName });
}
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
const subject = `Action required: Payment failed for ${businessName}`;
const html = this.buildTemplate({
preheader: 'We were unable to process your payment.',
heading: 'Payment Failed',
body: `
<p>We were unable to process the latest payment for <strong>${this.esc(businessName)}</strong>.</p>
<p>Please update your payment method to avoid any interruption to your service.</p>
`,
ctaText: 'Update Payment Method',
ctaUrl: `${this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`,
footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.',
});
await this.send(email, subject, html, 'payment_failed', { businessName });
}
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: `Join ${orgName} on HOA LedgerIQ.`,
heading: 'You\'re Invited!',
body: `
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
<p>Click below to accept the invitation and set up your account:</p>
`,
ctaText: 'Accept Invitation',
ctaUrl: inviteUrl,
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
});
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
}
async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise<void> {
const subject = `Your free trial ends in ${daysRemaining} days — ${businessName}`;
const html = this.buildTemplate({
preheader: `Your HOA LedgerIQ trial for ${businessName} is ending soon.`,
heading: `Your Trial Ends in ${daysRemaining} Days`,
body: `
<p>Your free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ ends in <strong>${daysRemaining} days</strong>.</p>
<p>To continue using all features without interruption, add a payment method before your trial expires.</p>
<p>If you don't add a payment method, your account will become read-only and you won't be able to make changes to your data.</p>
`,
ctaText: 'Add Payment Method',
ctaUrl: settingsUrl,
footer: 'If you have any questions about plans or pricing, just reply to this email.',
});
await this.send(email, subject, html, 'trial_ending', { businessName, daysRemaining, settingsUrl });
}
async sendTrialExpiredEmail(email: string, businessName: string): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Your free trial has ended — ${businessName}`;
const html = this.buildTemplate({
preheader: `Your HOA LedgerIQ trial for ${businessName} has ended.`,
heading: 'Your Trial Has Ended',
body: `
<p>The free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ has ended.</p>
<p>Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.</p>
`,
ctaText: 'Choose a Plan',
ctaUrl: `${appUrl}/pricing`,
footer: 'Your data will be preserved. You can reactivate your account at any time by subscribing to a plan.',
});
await this.send(email, subject, html, 'trial_expired', { businessName });
}
async sendNewMemberWelcomeEmail(
email: string,
firstName: string,
orgName: string,
): Promise<void> {
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
const subject = `Welcome to ${orgName} on HOA LedgerIQ`;
const html = this.buildTemplate({
preheader: `Your account for ${orgName} on HOA LedgerIQ is ready.`,
heading: `Welcome, ${this.esc(firstName)}!`,
body: `
<p>You've been added as a member of <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
<p>Your account is ready to use. Log in with your email address and the temporary password provided by your administrator. You'll be able to change your password after logging in.</p>
<p>HOA LedgerIQ gives you access to your community's financial dashboard, budgets, reports, and more.</p>
`,
ctaText: 'Log In Now',
ctaUrl: `${appUrl}/login`,
footer: 'If you were not expecting this email, please contact your HOA administrator.',
});
await this.send(email, subject, html, 'new_member_welcome', { orgName, firstName });
}
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password';
const html = this.buildTemplate({
preheader: 'Password reset requested for your HOA LedgerIQ account.',
heading: 'Password Reset',
body: `
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
`,
ctaText: 'Reset Password',
ctaUrl: resetUrl,
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
});
await this.send(email, subject, html, 'password_reset', { resetUrl });
}
// ─── Core send logic ────────────────────────────────────────
private async send(
toEmail: string,
subject: string,
html: string,
template: string,
metadata: Record<string, any>,
): Promise<void> {
// Always log to the database
await this.log(toEmail, subject, html, template, metadata);
if (!this.resend) {
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
return;
}
try {
const result = await this.resend.emails.send({
from: this.fromAddress,
to: [toEmail],
replyTo: this.replyToAddress || undefined,
subject,
html,
});
if (result.error) {
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
} else {
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
}
} catch (err: any) {
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
await this.updateLogStatus(toEmail, template, 'failed', err.message);
}
}
// ─── Database logging ───────────────────────────────────────
private async log(
toEmail: string,
subject: string,
body: string,
template: string,
metadata: Record<string, any>,
): Promise<void> {
try {
await this.dataSource.query(
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
VALUES ($1, $2, $3, $4, $5)`,
[toEmail, subject, body, template, JSON.stringify(metadata)],
);
} catch (err) {
this.logger.warn(`Failed to log email: ${err}`);
}
}
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
try {
await this.dataSource.query(
`UPDATE shared.email_log
SET metadata = metadata || $1::jsonb
WHERE to_email = $2 AND template = $3
AND created_at = (
SELECT MAX(created_at) FROM shared.email_log
WHERE to_email = $2 AND template = $3
)`,
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
);
} catch {
// Best effort — don't block the flow
}
}
// ─── HTML email template ────────────────────────────────────
private esc(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
private buildTemplate(opts: {
preheader: string;
heading: string;
body: string;
ctaText: string;
ctaUrl: string;
footer: string;
}): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this.esc(opts.heading)}</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
</head>
<body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<!-- Preheader (hidden preview text) -->
<div style="display:none;max-height:0;overflow:hidden;">${this.esc(opts.preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
<!-- Logo bar -->
<tr>
<td align="center" style="padding:24px 0 16px;">
<span style="font-size:22px;font-weight:700;color:#1a73e8;letter-spacing:-0.5px;">
HOA LedgerIQ
</span>
</td>
</tr>
<!-- Main card -->
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<tr>
<td style="padding:40px 32px;">
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#1a1a2e;">
${this.esc(opts.heading)}
</h1>
<div style="font-size:15px;line-height:1.6;color:#4a4a68;">
${opts.body}
</div>
<!-- CTA Button -->
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">
<tr>
<td align="center" style="background-color:#1a73e8;border-radius:6px;">
<a href="${opts.ctaUrl}"
target="_blank"
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
${this.esc(opts.ctaText)}
</a>
</td>
</tr>
</table>
<!-- Fallback URL -->
<p style="font-size:12px;color:#999;word-break:break-all;margin-top:16px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="${opts.ctaUrl}" style="color:#1a73e8;">${opts.ctaUrl}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 32px;text-align:center;">
<p style="font-size:12px;color:#999;line-height:1.5;margin:0;">
${this.esc(opts.footer)}
</p>
<p style="font-size:12px;color:#bbb;margin:12px 0 0;">
&copy; ${new Date().getFullYear()} HOA LedgerIQ &mdash; Smart Financial Management for HOAs
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
}

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Patch, Body, UseGuards, Request, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { OnboardingService } from './onboarding.service';
@ApiTags('onboarding')
@Controller('onboarding')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class OnboardingController {
constructor(private onboardingService: OnboardingService) {}
@Get('progress')
@ApiOperation({ summary: 'Get onboarding progress for current org' })
@AllowViewer()
async getProgress(@Request() req: any) {
const orgId = req.user.orgId;
if (!orgId) throw new BadRequestException('No organization context');
return this.onboardingService.getProgress(orgId);
}
@Patch('progress')
@ApiOperation({ summary: 'Mark an onboarding step as complete' })
async markStep(@Request() req: any, @Body() body: { step: string }) {
const orgId = req.user.orgId;
if (!orgId) throw new BadRequestException('No organization context');
if (!body.step) throw new BadRequestException('step is required');
return this.onboardingService.markStepComplete(orgId, body.step);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OnboardingService } from './onboarding.service';
import { OnboardingController } from './onboarding.controller';
@Module({
controllers: [OnboardingController],
providers: [OnboardingService],
exports: [OnboardingService],
})
export class OnboardingModule {}

View File

@@ -0,0 +1,79 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
const REQUIRED_STEPS = ['profile', 'workspace', 'invite_member', 'first_workflow'];
@Injectable()
export class OnboardingService {
private readonly logger = new Logger(OnboardingService.name);
constructor(private dataSource: DataSource) {}
async getProgress(orgId: string) {
const rows = await this.dataSource.query(
`SELECT completed_steps, completed_at, updated_at
FROM shared.onboarding_progress
WHERE organization_id = $1`,
[orgId],
);
if (rows.length === 0) {
// Create a fresh record
await this.dataSource.query(
`INSERT INTO shared.onboarding_progress (organization_id)
VALUES ($1) ON CONFLICT DO NOTHING`,
[orgId],
);
return { completedSteps: [], completedAt: null, requiredSteps: REQUIRED_STEPS };
}
return {
completedSteps: rows[0].completed_steps || [],
completedAt: rows[0].completed_at,
requiredSteps: REQUIRED_STEPS,
};
}
async markStepComplete(orgId: string, step: string) {
// Add step to array (using array_append with dedup)
await this.dataSource.query(
`INSERT INTO shared.onboarding_progress (organization_id, completed_steps, updated_at)
VALUES ($1, ARRAY[$2::text], NOW())
ON CONFLICT (organization_id)
DO UPDATE SET
completed_steps = CASE
WHEN $2 = ANY(onboarding_progress.completed_steps) THEN onboarding_progress.completed_steps
ELSE array_append(onboarding_progress.completed_steps, $2::text)
END,
updated_at = NOW()`,
[orgId, step],
);
// Check if all required steps are done
const rows = await this.dataSource.query(
`SELECT completed_steps FROM shared.onboarding_progress WHERE organization_id = $1`,
[orgId],
);
const completedSteps = rows[0]?.completed_steps || [];
const allDone = REQUIRED_STEPS.every((s) => completedSteps.includes(s));
if (allDone) {
await this.dataSource.query(
`UPDATE shared.onboarding_progress SET completed_at = NOW() WHERE organization_id = $1 AND completed_at IS NULL`,
[orgId],
);
}
return this.getProgress(orgId);
}
async resetProgress(orgId: string) {
await this.dataSource.query(
`UPDATE shared.onboarding_progress SET completed_steps = '{}', completed_at = NULL, updated_at = NOW()
WHERE organization_id = $1`,
[orgId],
);
return this.getProgress(orgId);
}
}

View File

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

View File

@@ -1021,11 +1021,24 @@ export class ReportsService {
let runOpInv = opInv;
let runResInv = resInv;
// Determine which months have actual journal entries
// A month is "actual" only if it's not in the future AND has real journal entry data
const monthsWithActuals = new Set<string>();
for (const key of Object.keys(histIndex)) {
// histIndex keys are "year-month-fund_type", extract year-month
const parts = key.split('-');
const ym = `${parts[0]}-${parts[1]}`;
monthsWithActuals.add(ym);
}
for (let i = 0; i < months; i++) {
const year = startYear + Math.floor(i / 12);
const month = (i % 12) + 1;
const key = `${year}-${month}`;
const isHistorical = year < currentYear || (year === currentYear && month <= currentMonth);
// A month is historical (actual) only if it's in the past AND has journal entries
const isPastMonth = year < currentYear || (year === currentYear && month < currentMonth);
const hasActuals = monthsWithActuals.has(key);
const isHistorical = isPastMonth && hasActuals;
const label = `${monthLabels[month - 1]} ${year}`;
if (isHistorical) {

View File

@@ -0,0 +1,107 @@
-- Migration 015: SaaS Onboarding + Auth (Stripe, Refresh Tokens, MFA, SSO, Passkeys)
-- Adds tables for refresh tokens, stripe event tracking, invite tokens,
-- onboarding progress, and WebAuthn passkeys.
-- ============================================================================
-- 1. Modify shared.organizations — add Stripe billing columns
-- ============================================================================
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) UNIQUE;
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255) UNIQUE;
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
-- Update plan_level CHECK constraint to include new SaaS plan tiers
-- (Drop and re-add since ALTER CHECK is not supported in PG)
ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_plan_level_check;
ALTER TABLE shared.organizations ADD CONSTRAINT organizations_plan_level_check
CHECK (plan_level IN ('standard', 'premium', 'enterprise', 'starter', 'professional'));
-- ============================================================================
-- 2. New table: shared.refresh_tokens
-- ============================================================================
CREATE TABLE IF NOT EXISTS shared.refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON shared.refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON shared.refresh_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON shared.refresh_tokens(expires_at);
-- ============================================================================
-- 3. New table: shared.stripe_events (idempotency for webhook processing)
-- ============================================================================
CREATE TABLE IF NOT EXISTS shared.stripe_events (
id VARCHAR(255) PRIMARY KEY,
type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW(),
payload JSONB
);
-- ============================================================================
-- 4. New table: shared.invite_tokens (magic link activation)
-- ============================================================================
CREATE TABLE IF NOT EXISTS shared.invite_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_hash ON shared.invite_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_user ON shared.invite_tokens(user_id);
-- ============================================================================
-- 5. New table: shared.onboarding_progress
-- ============================================================================
CREATE TABLE IF NOT EXISTS shared.onboarding_progress (
organization_id UUID PRIMARY KEY REFERENCES shared.organizations(id) ON DELETE CASCADE,
completed_steps TEXT[] DEFAULT '{}',
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- 6. New table: shared.user_passkeys (WebAuthn)
-- ============================================================================
CREATE TABLE IF NOT EXISTS shared.user_passkeys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
counter BIGINT DEFAULT 0,
device_name VARCHAR(255),
transports TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user ON shared.user_passkeys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_cred ON shared.user_passkeys(credential_id);
-- ============================================================================
-- 7. Modify shared.users — add MFA/WebAuthn columns
-- ============================================================================
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS totp_verified_at TIMESTAMPTZ;
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS recovery_codes TEXT;
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS webauthn_challenge TEXT;
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
-- ============================================================================
-- 8. Stubbed email log table (for development — replaces real email sends)
-- ============================================================================
CREATE TABLE IF NOT EXISTS shared.email_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
to_email VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
body TEXT,
template VARCHAR(100),
metadata JSONB,
sent_at TIMESTAMPTZ DEFAULT NOW()
);

View File

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

View File

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

View File

@@ -40,6 +40,32 @@ services:
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/azure/callback}
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-app.hoaledgeriq.com}
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-https://app.hoaledgeriq.com}
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-}
- APP_URL=${APP_URL:-https://app.hoaledgeriq.com}
- RESEND_API_KEY=${RESEND_API_KEY:-}
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-sales@hoaledgeriq.com}
deploy:
resources:
limits:

View File

@@ -29,6 +29,32 @@ services:
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-http://localhost/api/auth/azure/callback}
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
- APP_URL=${APP_URL:-http://localhost}
- RESEND_API_KEY=${RESEND_API_KEY:-}
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-}
volumes:
- ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.03.10",
"version": "2026.3.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-frontend",
"version": "2026.03.10",
"version": "2026.3.17",
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3",
@@ -14,6 +14,7 @@
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@simplewebauthn/browser": "^13.3.0",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",
@@ -1289,6 +1290,12 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@tabler/icons": {
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",

View File

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

View File

@@ -4,6 +4,7 @@ import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/auth/LoginPage';
import { RegisterPage } from './pages/auth/RegisterPage';
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
import { ActivatePage } from './pages/auth/ActivatePage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { AccountsPage } from './pages/accounts/AccountsPage';
import { TransactionsPage } from './pages/transactions/TransactionsPage';
@@ -37,6 +38,9 @@ import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenar
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
import { PricingPage } from './pages/pricing/PricingPage';
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
@@ -77,6 +81,12 @@ function AuthRoute({ children }: { children: React.ReactNode }) {
export function App() {
return (
<Routes>
{/* Public routes (no auth required) */}
<Route path="/pricing" element={<PricingPage />} />
<Route path="/activate" element={<ActivatePage />} />
<Route path="/onboarding/pending" element={<OnboardingPendingPage />} />
{/* Auth routes (redirect if already logged in) */}
<Route
path="/login"
element={
@@ -101,6 +111,18 @@ export function App() {
</ProtectedRoute>
}
/>
{/* Onboarding (requires auth but not org selection) */}
<Route
path="/onboarding"
element={
<ProtectedRoute>
<OnboardingPage />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="/admin"
element={
@@ -111,6 +133,8 @@ export function App() {
>
<Route index element={<AdminPage />} />
</Route>
{/* Main app routes (require auth + org) */}
<Route
path="/*"
element={

View File

@@ -17,7 +17,6 @@ import {
IconChartAreaLine,
IconClipboardCheck,
IconSparkles,
IconHeartRateMonitor,
IconCalculator,
IconGitCompare,
IconScale,
@@ -46,14 +45,6 @@ const navSections = [
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
],
},
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
],
},
{
label: 'Board Planning',
items: [
@@ -67,12 +58,8 @@ const navSections = [
{
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
},
{
label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning',
children: [
{ label: 'Investment Scenarios', path: '/board-planning/investments' },
],
},
{ 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' },
],
},
@@ -82,6 +69,15 @@ const navSections = [
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
],
},
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
// { label: 'Payments', icon: IconCash, path: '/payments' },
],
},
{
label: 'Reports',
items: [

View File

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

View File

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

View File

@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import {
Container, Paper, Title, Text, TextInput, PasswordInput,
Button, Stack, Alert, Center, Loader, Progress, Anchor,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconAlertCircle, IconCheck, IconShieldCheck } from '@tabler/icons-react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import logoSrc from '../../assets/logo.png';
function getPasswordStrength(pw: string): number {
let score = 0;
if (pw.length >= 8) score += 25;
if (pw.length >= 12) score += 15;
if (/[A-Z]/.test(pw)) score += 20;
if (/[a-z]/.test(pw)) score += 10;
if (/[0-9]/.test(pw)) score += 15;
if (/[^A-Za-z0-9]/.test(pw)) score += 15;
return Math.min(score, 100);
}
function strengthColor(s: number): string {
if (s < 40) return 'red';
if (s < 70) return 'orange';
return 'green';
}
export function ActivatePage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const token = searchParams.get('token');
const [validating, setValidating] = useState(true);
const [tokenInfo, setTokenInfo] = useState<any>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const form = useForm({
initialValues: { fullName: '', password: '', confirmPassword: '' },
validate: {
fullName: (v) => (v.trim().length >= 2 ? null : 'Name is required'),
password: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'),
confirmPassword: (v, values) => (v === values.password ? null : 'Passwords do not match'),
},
});
useEffect(() => {
if (!token) {
setError('No activation token provided');
setValidating(false);
return;
}
api.get(`/auth/activate?token=${token}`)
.then(({ data }) => {
setTokenInfo(data);
setValidating(false);
})
.catch((err) => {
setError(err.response?.data?.message || 'Invalid or expired activation link');
setValidating(false);
});
}, [token]);
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
setError('');
try {
const { data } = await api.post('/auth/activate', {
token,
password: values.password,
fullName: values.fullName,
});
setAuth(data.accessToken, data.user, data.organizations);
navigate('/onboarding');
} catch (err: any) {
setError(err.response?.data?.message || 'Activation failed');
} finally {
setLoading(false);
}
};
const passwordStrength = getPasswordStrength(form.values.password);
if (validating) {
return (
<Container size={420} my={80}>
<Center><Loader size="lg" /></Center>
<Text ta="center" mt="md" c="dimmed">Validating activation link...</Text>
</Container>
);
}
if (error && !tokenInfo) {
return (
<Container size={420} my={80}>
<Center>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
</Center>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light" mb="md">
{error}
</Alert>
<Stack>
<Anchor component={Link} to="/login" size="sm" ta="center">
Go to Login
</Anchor>
</Stack>
</Paper>
</Container>
);
}
return (
<Container size={420} my={80}>
<Center>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
</Center>
<Text ta="center" mt={5} c="dimmed" size="sm">
Activate your account for <strong>{tokenInfo?.orgName || 'your organization'}</strong>
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label="Full Name"
placeholder="John Doe"
required
{...form.getInputProps('fullName')}
/>
<div>
<PasswordInput
label="Password"
placeholder="Create a strong password"
required
{...form.getInputProps('password')}
/>
{form.values.password && (
<Progress
value={passwordStrength}
color={strengthColor(passwordStrength)}
size="xs"
mt={4}
/>
)}
</div>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your password"
required
{...form.getInputProps('confirmPassword')}
/>
<Button
type="submit"
fullWidth
loading={loading}
leftSection={<IconShieldCheck size={16} />}
>
Activate Account
</Button>
</Stack>
</form>
</Paper>
</Container>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Center,
Container,
@@ -10,18 +10,41 @@ import {
Anchor,
Stack,
Alert,
Divider,
Group,
PinInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconAlertCircle } from '@tabler/icons-react';
import {
IconAlertCircle,
IconBrandGoogle,
IconBrandWindows,
IconFingerprint,
IconShieldLock,
} from '@tabler/icons-react';
import { useNavigate, Link } from 'react-router-dom';
import { startAuthentication } from '@simplewebauthn/browser';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import logoSrc from '../../assets/logo.png';
type LoginState = 'credentials' | 'mfa';
export function LoginPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [loginState, setLoginState] = useState<LoginState>('credentials');
const [mfaToken, setMfaToken] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [useRecovery, setUseRecovery] = useState(false);
const [recoveryCode, setRecoveryCode] = useState('');
const [ssoProviders, setSsoProviders] = useState<{ google: boolean; azure: boolean }>({
google: false,
azure: false,
});
const [passkeySupported, setPasskeySupported] = useState(false);
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
@@ -34,20 +57,42 @@ export function LoginPage() {
},
});
// Fetch SSO providers & check passkey support on mount
useEffect(() => {
api
.get('/auth/sso/providers')
.then(({ data }) => setSsoProviders(data))
.catch(() => {});
if (
window.PublicKeyCredential &&
typeof window.PublicKeyCredential === 'function'
) {
setPasskeySupported(true);
}
}, []);
const handleLoginSuccess = (data: any) => {
setAuth(data.accessToken, data.user, data.organizations);
if (data.user?.isSuperadmin && data.organizations.length === 0) {
navigate('/admin');
} else if (data.organizations.length >= 1) {
navigate('/select-org');
} else {
navigate('/');
}
};
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
setError('');
try {
const { data } = await api.post('/auth/login', values);
setAuth(data.accessToken, data.user, data.organizations);
// Platform owner / superadmin with no orgs → admin panel
if (data.user?.isSuperadmin && data.organizations.length === 0) {
navigate('/admin');
} else if (data.organizations.length >= 1) {
// Always go through org selection to ensure correct JWT with orgSchema
navigate('/select-org');
if (data.mfaRequired) {
setMfaToken(data.mfaToken);
setLoginState('mfa');
} else {
navigate('/');
handleLoginSuccess(data);
}
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
@@ -56,6 +101,57 @@ 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>
@@ -64,9 +160,136 @@ export function LoginPage() {
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))',
} : {}),
...(isDark
? {
filter:
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
}
: {}),
}}
/>
</Center>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<Stack>
<Group gap="xs" justify="center">
<IconShieldLock size={24} />
<Text fw={600} size="lg">
Two-Factor Authentication
</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
)}
{!useRecovery ? (
<>
<Text size="sm" c="dimmed" ta="center">
Enter the 6-digit code from your authenticator app
</Text>
<Center>
<PinInput
length={6}
type="number"
value={mfaCode}
onChange={setMfaCode}
oneTimeCode
autoFocus
size="lg"
/>
</Center>
<Button
fullWidth
loading={loading}
onClick={handleMfaVerify}
disabled={mfaCode.length !== 6}
>
Verify
</Button>
<Anchor
size="sm"
ta="center"
onClick={() => {
setUseRecovery(true);
setError('');
}}
style={{ cursor: 'pointer' }}
>
Use a recovery code instead
</Anchor>
</>
) : (
<>
<Text size="sm" c="dimmed" ta="center">
Enter one of your recovery codes
</Text>
<TextInput
placeholder="xxxxxxxx"
value={recoveryCode}
onChange={(e) => setRecoveryCode(e.currentTarget.value)}
autoFocus
ff="monospace"
/>
<Button
fullWidth
loading={loading}
onClick={handleMfaVerify}
disabled={!recoveryCode.trim()}
>
Verify Recovery Code
</Button>
<Anchor
size="sm"
ta="center"
onClick={() => {
setUseRecovery(false);
setError('');
}}
style={{ cursor: 'pointer' }}
>
Use authenticator code instead
</Anchor>
</>
)}
<Anchor
size="sm"
ta="center"
onClick={() => {
setLoginState('credentials');
setMfaToken('');
setMfaCode('');
setRecoveryCode('');
setError('');
}}
style={{ cursor: 'pointer' }}
>
Back to login
</Anchor>
</Stack>
</Paper>
</Container>
);
}
// Main login form
return (
<Container size={420} my={80}>
<Center>
<img
src={logoSrc}
alt="HOA LedgerIQ"
style={{
height: 60,
...(isDark
? {
filter:
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
}
: {}),
}}
/>
</Center>
@@ -102,6 +325,53 @@ export function LoginPage() {
</Button>
</Stack>
</form>
{/* Passkey login */}
{passkeySupported && (
<>
<Divider label="or" labelPosition="center" my="md" />
<Button
variant="light"
fullWidth
leftSection={<IconFingerprint size={18} />}
onClick={handlePasskeyLogin}
loading={loading}
>
Sign in with Passkey
</Button>
</>
)}
{/* SSO providers */}
{hasSso && (
<>
<Divider label="or continue with" labelPosition="center" my="md" />
<Group grow>
{ssoProviders.google && (
<Button
variant="default"
leftSection={<IconBrandGoogle size={18} color="#4285F4" />}
onClick={() => {
window.location.href = '/api/auth/google';
}}
>
Google
</Button>
)}
{ssoProviders.azure && (
<Button
variant="default"
leftSection={<IconBrandWindows size={18} color="#0078D4" />}
onClick={() => {
window.location.href = '/api/auth/azure';
}}
>
Microsoft
</Button>
)}
</Group>
</>
)}
</Paper>
</Container>
);

View File

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

View File

@@ -89,20 +89,20 @@ export function ProjectionChart({ datapoints, title = 'Financial Projection', su
<AreaChart data={chartData}>
<defs>
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
<stop offset="5%" stopColor="#228be6" stopOpacity={0.6} />
<stop offset="95%" stopColor="#228be6" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
</linearGradient>
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />

View File

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

View File

@@ -1,7 +1,7 @@
import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center, Divider, RingProgress, Tooltip, Button,
Popover, List,
Popover, List, Anchor,
} from '@mantine/core';
import {
IconCash,
@@ -18,6 +18,7 @@ import {
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api';
@@ -58,6 +59,28 @@ function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
return null;
}
// Map missing data items to navigation links
const missingDataLinks: Record<string, { label: string; path: string }> = {
'reserve fund account': { label: 'Set up a reserve account', path: '/accounts' },
'reserve account': { label: 'Set up a reserve account', path: '/accounts' },
'reserve projects': { label: 'Add reserve projects', path: '/projects' },
'capital projects': { label: 'Add capital projects', path: '/projects' },
'projects': { label: 'Add projects', path: '/projects' },
'budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'operating budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'reserve budget': { label: 'Set up a budget', path: '/board-planning/budgets' },
'assessment groups': { label: 'Create assessment groups', path: '/assessment-groups' },
'accounts': { label: 'Set up accounts', path: '/accounts' },
};
function getMissingDataLink(item: string): { label: string; path: string } | null {
const lower = item.toLowerCase();
for (const [key, value] of Object.entries(missingDataLinks)) {
if (lower.includes(key)) return value;
}
return null;
}
function HealthScoreCard({
score,
title,
@@ -65,6 +88,7 @@ function HealthScoreCard({
isRefreshing,
onRefresh,
lastFailed,
onNavigate,
}: {
score: HealthScore | null;
title: string;
@@ -72,6 +96,7 @@ function HealthScoreCard({
isRefreshing?: boolean;
onRefresh?: () => void;
lastFailed?: boolean;
onNavigate?: (path: string) => void;
}) {
// No score at all yet
if (!score) {
@@ -118,9 +143,19 @@ function HealthScoreCard({
<Stack align="center" gap="xs">
<Badge color="gray" variant="light" size="lg">Pending</Badge>
<Text size="xs" c="dimmed" ta="center">Missing data:</Text>
{missingItems.map((item: string, i: number) => (
{missingItems.map((item: string, i: number) => {
const link = getMissingDataLink(item);
return link ? (
<Anchor key={i} size="xs" href={link.path} onClick={(e: React.MouseEvent) => {
e.preventDefault();
onNavigate?.(link.path);
}}>
{item} &rarr; {link.label}
</Anchor>
) : (
<Text key={i} size="xs" c="dimmed" ta="center">{item}</Text>
))}
);
})}
</Stack>
</Center>
</Card>
@@ -315,6 +350,7 @@ export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient();
const navigate = useNavigate();
// Track whether a refresh is in progress (per score type) for async polling
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
@@ -429,6 +465,7 @@ export function DashboardPage() {
isRefreshing={operatingRefreshing}
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed}
onNavigate={navigate}
/>
<HealthScoreCard
score={healthScores?.reserve || null}
@@ -441,6 +478,7 @@ export function DashboardPage() {
isRefreshing={reserveRefreshing}
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed}
onNavigate={navigate}
/>
</SimpleGrid>
@@ -494,35 +532,6 @@ export function DashboardPage() {
</SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Card withBorder padding="lg" radius="md">
<Title order={4} mb="sm">Recent Transactions</Title>
{(data?.recent_transactions || []).length === 0 ? (
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
) : (
<Table striped highlightOnHover>
<Table.Tbody>
{(data?.recent_transactions || []).map((tx) => (
<Table.Tr key={tx.id}>
<Table.Td>
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={1}>{tx.description}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
{tx.entry_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>
{fmt(tx.amount)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<Card withBorder padding="lg" radius="md">
<Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs">
@@ -583,6 +592,35 @@ export function DashboardPage() {
</Group>
</Stack>
</Card>
<Card withBorder padding="lg" radius="md">
<Title order={4} mb="sm">Recent Transactions</Title>
{(data?.recent_transactions || []).length === 0 ? (
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
) : (
<Table striped highlightOnHover>
<Table.Tbody>
{(data?.recent_transactions || []).map((tx) => (
<Table.Tr key={tx.id}>
<Table.Td>
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={1}>{tx.description}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
{tx.entry_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>
{fmt(tx.amount)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
</SimpleGrid>
</>
)}

View File

@@ -559,6 +559,32 @@ export function InvestmentPlanningPage() {
}
}, []);
// Auto-refresh: if no recommendations exist or they are older than 30 days, trigger automatically
const autoRefreshTriggered = useRef(false);
useEffect(() => {
if (autoRefreshTriggered.current || isProcessing || isTriggering || isReadOnly) return;
if (savedRec === undefined) return; // still loading
const shouldAutoRefresh = (() => {
// No saved recommendation at all
if (!savedRec) return true;
// Error state with no cached data
if (savedRec.status === 'error' && (!savedRec.recommendations || savedRec.recommendations.length === 0)) return true;
// Recommendations older than 30 days
if (savedRec.created_at) {
const age = Date.now() - new Date(savedRec.created_at).getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (age > thirtyDays) return true;
}
return false;
})();
if (shouldAutoRefresh) {
autoRefreshTriggered.current = true;
handleTriggerAI();
}
}, [savedRec, isProcessing, isTriggering, isReadOnly, handleTriggerAI]);
// Build AI result from saved recommendation for display
const aiResult: AIResponse | null = hasResults
? {

View File

@@ -0,0 +1,241 @@
import { useState, useEffect } from 'react';
import {
Container, Title, Text, Stack, Card, Group, Button, TextInput,
Select, Stepper, ThemeIcon, Progress, Alert, Loader, Center, Anchor,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import {
IconUser, IconBuilding, IconUserPlus, IconListDetails,
IconCheck, IconPlayerPlay, IconConfetti,
} from '@tabler/icons-react';
import { useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
const STEPS = [
{ slug: 'profile', label: 'Complete Your Profile', icon: IconUser, description: 'Set up your name and contact' },
{ slug: 'workspace', label: 'Configure Your HOA', icon: IconBuilding, description: 'Organization name and settings' },
{ slug: 'invite_member', label: 'Invite a Team Member', icon: IconUserPlus, description: 'Add a board member or manager' },
{ slug: 'first_workflow', label: 'Set Up First Account', icon: IconListDetails, description: 'Create your chart of accounts' },
];
export function OnboardingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const [activeStep, setActiveStep] = useState(0);
const { data: progress, isLoading } = useQuery({
queryKey: ['onboarding-progress'],
queryFn: async () => {
const { data } = await api.get('/onboarding/progress');
return data;
},
});
const markStep = useMutation({
mutationFn: (step: string) => api.patch('/onboarding/progress', { step }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['onboarding-progress'] }),
});
const completedSteps = progress?.completedSteps || [];
const completedCount = completedSteps.length;
const allDone = progress?.completedAt != null;
// Profile form
const profileForm = useForm({
initialValues: {
firstName: user?.firstName || '',
lastName: user?.lastName || '',
phone: '',
},
});
// Workspace form
const workspaceForm = useForm({
initialValues: { orgName: '', address: '', fiscalYearStart: '1' },
});
// Invite form
const inviteForm = useForm({
initialValues: { email: '', role: 'treasurer' },
validate: { email: (v) => (/\S+@\S+/.test(v) ? null : 'Valid email required') },
});
useEffect(() => {
// Auto-advance to first incomplete step
const firstIncomplete = STEPS.findIndex((s) => !completedSteps.includes(s.slug));
if (firstIncomplete >= 0) setActiveStep(firstIncomplete);
}, [completedSteps]);
if (isLoading) {
return <Center h={400}><Loader size="lg" /></Center>;
}
if (allDone) {
return (
<Container size="sm" py={60}>
<Center>
<Stack align="center" gap="lg">
<ThemeIcon size={60} radius="xl" color="green" variant="light">
<IconConfetti size={30} />
</ThemeIcon>
<Title order={2}>You're all set!</Title>
<Text c="dimmed" ta="center">
Your workspace is ready. Let's get to work.
</Text>
<Button size="lg" onClick={() => navigate('/dashboard')}>
Go to Dashboard
</Button>
</Stack>
</Center>
</Container>
);
}
return (
<Container size="md" py={40}>
<Stack gap="lg">
<div>
<Title order={2}>Welcome to HOA LedgerIQ</Title>
<Text c="dimmed" size="sm">Complete these steps to set up your workspace</Text>
</div>
<Progress value={(completedCount / STEPS.length) * 100} size="lg" color="teal" />
<Text size="sm" c="dimmed" ta="center">{completedCount} of {STEPS.length} steps complete</Text>
<Stepper
active={activeStep}
onStepClick={setActiveStep}
orientation="vertical"
size="sm"
>
{/* Step 1: Profile */}
<Stepper.Step
label={STEPS[0].label}
description={STEPS[0].description}
icon={completedSteps.includes('profile') ? <IconCheck size={16} /> : <IconUser size={16} />}
completedIcon={<IconCheck size={16} />}
color={completedSteps.includes('profile') ? 'green' : undefined}
>
<Card withBorder p="lg" mt="sm">
<form onSubmit={profileForm.onSubmit(() => markStep.mutate('profile'))}>
<Stack>
<Group grow>
<TextInput label="First Name" {...profileForm.getInputProps('firstName')} />
<TextInput label="Last Name" {...profileForm.getInputProps('lastName')} />
</Group>
<TextInput label="Phone (optional)" {...profileForm.getInputProps('phone')} />
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
</Stack>
</form>
</Card>
</Stepper.Step>
{/* Step 2: Workspace */}
<Stepper.Step
label={STEPS[1].label}
description={STEPS[1].description}
icon={completedSteps.includes('workspace') ? <IconCheck size={16} /> : <IconBuilding size={16} />}
completedIcon={<IconCheck size={16} />}
color={completedSteps.includes('workspace') ? 'green' : undefined}
>
<Card withBorder p="lg" mt="sm">
<form onSubmit={workspaceForm.onSubmit(() => markStep.mutate('workspace'))}>
<Stack>
<TextInput label="Organization Name" placeholder="Sunset Village HOA" {...workspaceForm.getInputProps('orgName')} />
<TextInput label="Address" placeholder="123 Main St" {...workspaceForm.getInputProps('address')} />
<Select
label="Fiscal Year Start Month"
data={[
{ value: '1', label: 'January' },
{ value: '4', label: 'April' },
{ value: '7', label: 'July' },
{ value: '10', label: 'October' },
]}
{...workspaceForm.getInputProps('fiscalYearStart')}
/>
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
</Stack>
</form>
</Card>
</Stepper.Step>
{/* Step 3: Invite */}
<Stepper.Step
label={STEPS[2].label}
description={STEPS[2].description}
icon={completedSteps.includes('invite_member') ? <IconCheck size={16} /> : <IconUserPlus size={16} />}
completedIcon={<IconCheck size={16} />}
color={completedSteps.includes('invite_member') ? 'green' : undefined}
>
<Card withBorder p="lg" mt="sm">
<form onSubmit={inviteForm.onSubmit(() => markStep.mutate('invite_member'))}>
<Stack>
<TextInput label="Email Address" placeholder="teammate@example.com" {...inviteForm.getInputProps('email')} />
<Select
label="Role"
data={[
{ value: 'president', label: 'President' },
{ value: 'treasurer', label: 'Treasurer' },
{ value: 'secretary', label: 'Secretary' },
{ value: 'member_at_large', label: 'Member at Large' },
{ value: 'manager', label: 'Manager' },
{ value: 'viewer', label: 'Viewer' },
]}
{...inviteForm.getInputProps('role')}
/>
<Group>
<Button type="submit" loading={markStep.isPending}>Send Invite & Continue</Button>
<Button variant="subtle" onClick={() => markStep.mutate('invite_member')}>
Skip for now
</Button>
</Group>
</Stack>
</form>
</Card>
</Stepper.Step>
{/* Step 4: First Account */}
<Stepper.Step
label={STEPS[3].label}
description={STEPS[3].description}
icon={completedSteps.includes('first_workflow') ? <IconCheck size={16} /> : <IconListDetails size={16} />}
completedIcon={<IconCheck size={16} />}
color={completedSteps.includes('first_workflow') ? 'green' : undefined}
>
<Card withBorder p="lg" mt="sm">
<Stack>
<Text size="sm">
Your chart of accounts has been pre-configured with standard HOA accounts.
You can review and customize them now, or do it later.
</Text>
<Group>
<Button
leftSection={<IconListDetails size={16} />}
onClick={() => {
markStep.mutate('first_workflow');
navigate('/accounts');
}}
>
Review Accounts
</Button>
<Button variant="subtle" onClick={() => markStep.mutate('first_workflow')}>
Use defaults & Continue
</Button>
</Group>
</Stack>
</Card>
</Stepper.Step>
</Stepper>
<Group justify="center" mt="md">
<Button variant="subtle" color="gray" onClick={() => navigate('/dashboard')}>
Finish Later
</Button>
</Group>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Container, Center, Stack, Loader, Text, Title, Alert, Button } from '@mantine/core';
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import api from '../../services/api';
export function OnboardingPendingPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const sessionId = searchParams.get('session_id');
const [status, setStatus] = useState<string>('polling');
const [error, setError] = useState('');
useEffect(() => {
if (!sessionId) {
setError('No session ID provided');
return;
}
let cancelled = false;
const poll = async () => {
try {
const { data } = await api.get(`/billing/status?session_id=${sessionId}`);
if (cancelled) return;
if (data.status === 'active') {
setStatus('complete');
// Redirect to login page — user will get activation email
setTimeout(() => navigate('/login'), 3000);
} else if (data.status === 'not_configured') {
setError('Payment system is not configured. Please contact support.');
} else {
// Still provisioning — poll again
setTimeout(poll, 3000);
}
} catch (err: any) {
if (!cancelled) {
setError(err.response?.data?.message || 'Failed to check status');
}
}
};
poll();
return () => { cancelled = true; };
}, [sessionId, navigate]);
return (
<Container size="sm" py={80}>
<Center>
<Stack align="center" gap="lg">
{error ? (
<>
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
<Button variant="light" onClick={() => navigate('/pricing')}>
Back to Pricing
</Button>
</>
) : status === 'complete' ? (
<>
<IconCheck size={48} color="var(--mantine-color-green-6)" />
<Title order={2}>Your account is ready!</Title>
<Text c="dimmed" ta="center">
Check your email for an activation link to set your password and get started.
</Text>
<Text size="sm" c="dimmed">Redirecting to login...</Text>
</>
) : (
<>
<Loader size="xl" />
<Title order={2}>Setting up your account...</Title>
<Text c="dimmed" ta="center" maw={400}>
We're creating your HOA workspace. This usually takes just a few seconds.
</Text>
</>
)}
</Stack>
</Center>
</Container>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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