20 Commits

Author SHA1 Message Date
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
6b12fcd7d7 Merge branch 'claude/reverent-moore' 2026-03-17 18:04:14 -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
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
e3022f20c5 Merge pull request 'claude/SSOMFASTRIPE' (#5) from claude/reverent-moore into main
Reviewed-on: #5
2026-03-16 21:22:34 -04:00
9cd20a1867 Merge branch 'ai-improvements' 2026-03-16 16:34:11 -04:00
420227d70c Merge branch 'feature/invoice-billing-frequency'
# Conflicts:
#	frontend/src/pages/invoices/InvoicesPage.tsx
2026-03-16 16:34:04 -04:00
e893319cfe Merge branch 'fix/viewer-readonly-audit'
# Conflicts:
#	frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx
2026-03-16 16:33:24 -04:00
93eeacfe8f Merge branch 'claude/reverent-moore' into feature/board-planning 2026-03-16 16:28:52 -04:00
267d92933e chore: reorganize sidebar navigation and bump version to 2026.03.16
Remove the Planning section. Move Projects and Capital Planning (as
sub-item) into Board Planning. Move Investment Planning with Investment
Scenarios as sub-item into Board Planning. Move Vendors into new Board
Reference section. Board Planning order: Budget Planning, Projects >
Capital Planning, Assessment Scenarios, Investment Planning > Investment
Scenarios, Compare Scenarios. Sidebar now supports parent items with
their own route plus nested children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:21:58 -04:00
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

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

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

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware'; import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard'; import { WriteAccessGuard } from './common/guards/write-access.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module'; import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -95,6 +96,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD, provide: APP_GUARD,
useClass: WriteAccessGuard, useClass: WriteAccessGuard,
}, },
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,
},
], ],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {

View File

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

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

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ import {
Get, Get,
Res, Res,
Query, Query,
HttpCode,
ForbiddenException,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -23,6 +25,7 @@ import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
const COOKIE_NAME = 'ledgeriq_rt'; const COOKIE_NAME = 'ledgeriq_rt';
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
function setRefreshCookie(res: Response, token: string) { function setRefreshCookie(res: Response, token: string) {
res.cookie(COOKIE_NAME, token, { res.cookie(COOKIE_NAME, token, {
@@ -49,9 +52,14 @@ export class AuthController {
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register a new user' }) @ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
@Throttle({ default: { limit: 5, ttl: 60000 } }) @Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { 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); const result = await this.authService.register(dto);
if (result.refreshToken) { if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken); setRefreshCookie(res, result.refreshToken);
@@ -93,6 +101,7 @@ export class AuthController {
@Post('logout') @Post('logout')
@ApiOperation({ summary: 'Logout and revoke refresh token' }) @ApiOperation({ summary: 'Logout and revoke refresh token' })
@HttpCode(200)
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) { async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
const rawToken = req.cookies?.[COOKIE_NAME]; const rawToken = req.cookies?.[COOKIE_NAME];
if (rawToken) { if (rawToken) {
@@ -104,6 +113,7 @@ export class AuthController {
@Post('logout-everywhere') @Post('logout-everywhere')
@ApiOperation({ summary: 'Revoke all sessions' }) @ApiOperation({ summary: 'Revoke all sessions' })
@HttpCode(200)
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) { async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
@@ -183,4 +193,51 @@ export class AuthController {
// Stubbed — will be implemented when email service is ready // Stubbed — will be implemented when email service is ready
return { success: true, message: 'If an account exists, a new activation link has been sent.' }; 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

@@ -11,8 +11,9 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { createHash } from 'crypto'; import { randomBytes, createHash } from 'crypto';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service'; import { RefreshTokenService } from './refresh-token.service';
@@ -21,6 +22,7 @@ import { RefreshTokenService } from './refresh-token.service';
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
private readonly inviteSecret: string; private readonly inviteSecret: string;
private readonly appUrl: string;
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
@@ -28,8 +30,10 @@ export class AuthService {
private configService: ConfigService, private configService: ConfigService,
private dataSource: DataSource, private dataSource: DataSource,
private refreshTokenService: RefreshTokenService, private refreshTokenService: RefreshTokenService,
private emailService: EmailService,
) { ) {
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret'; this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
} }
async register(dto: RegisterDto) { async register(dto: RegisterDto) {
@@ -309,6 +313,105 @@ export class AuthService {
return token; return token;
} }
// ─── Password Reset Flow ──────────────────────────────────────────
/**
* Request a password reset. Generates a token, stores its hash, and sends an email.
* Silently succeeds even if the email doesn't exist (prevents enumeration).
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.usersService.findByEmail(email);
if (!user) {
// Silently return — don't reveal whether the account exists
return;
}
// Invalidate any existing reset tokens for this user
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW()
WHERE user_id = $1 AND used_at IS NULL`,
[user.id],
);
// Generate a 64-byte random token
const rawToken = randomBytes(64).toString('base64url');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await this.dataSource.query(
`INSERT INTO shared.password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[user.id, tokenHash, expiresAt],
);
const resetUrl = `${this.appUrl}/reset-password?token=${rawToken}`;
await this.emailService.sendPasswordResetEmail(user.email, resetUrl);
}
/**
* Reset password using a valid reset token.
*/
async resetPassword(rawToken: string, newPassword: string): Promise<void> {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const rows = await this.dataSource.query(
`SELECT id, user_id, expires_at, used_at
FROM shared.password_reset_tokens
WHERE token_hash = $1`,
[tokenHash],
);
if (rows.length === 0) {
throw new BadRequestException('Invalid or expired reset token');
}
const record = rows[0];
if (record.used_at) {
throw new BadRequestException('This reset link has already been used');
}
if (new Date(record.expires_at) < new Date()) {
throw new BadRequestException('This reset link has expired');
}
// Update password
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, record.user_id],
);
// Mark token as used
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW() WHERE id = $1`,
[record.id],
);
}
/**
* Change password for an authenticated user (requires current password).
*/
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
const user = await this.usersService.findById(userId);
if (!user || !user.passwordHash) {
throw new UnauthorizedException('User not found');
}
const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, userId],
);
}
// ─── Private Helpers ──────────────────────────────────────────────
private async recordLoginHistory( private async recordLoginHistory(
userId: string, userId: string,
organizationId: string | null, organizationId: string | null,

View File

@@ -1,34 +1,63 @@
import { import {
Controller, Controller,
Post, Post,
Put,
Get, Get,
Body, Body,
Param,
Query, Query,
Req, Req,
UseGuards, UseGuards,
RawBodyRequest, RawBodyRequest,
BadRequestException, BadRequestException,
ForbiddenException,
Request, Request,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { Request as ExpressRequest } from 'express'; import { Request as ExpressRequest } from 'express';
import { DataSource } from 'typeorm';
import { BillingService } from './billing.service'; import { BillingService } from './billing.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('billing') @ApiTags('billing')
@Controller() @Controller()
export class BillingController { export class BillingController {
constructor(private billingService: BillingService) {} 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') @Post('billing/create-checkout-session')
@ApiOperation({ summary: 'Create a Stripe Checkout Session' }) @ApiOperation({ summary: 'Create a Stripe Checkout Session' })
@Throttle({ default: { limit: 10, ttl: 60000 } }) @Throttle({ default: { limit: 10, ttl: 60000 } })
async createCheckout( async createCheckout(
@Body() body: { planId: string; email?: string; businessName?: string }, @Body() body: { planId: string; billingInterval?: 'month' | 'year'; email?: string; businessName?: string },
) { ) {
if (!body.planId) throw new BadRequestException('planId is required'); if (!body.planId) throw new BadRequestException('planId is required');
return this.billingService.createCheckoutSession(body.planId, body.email, body.businessName); return this.billingService.createCheckoutSession(
body.planId,
body.billingInterval || 'month',
body.email,
body.businessName,
);
} }
@Post('webhooks/stripe') @Post('webhooks/stripe')
@@ -42,22 +71,63 @@ export class BillingController {
} }
@Get('billing/status') @Get('billing/status')
@ApiOperation({ summary: 'Check provisioning status for a checkout session' }) @ApiOperation({ summary: 'Check provisioning status for a checkout session or subscription' })
async getStatus(@Query('session_id') sessionId: string) { async getStatus(@Query('session_id') sessionId: string) {
if (!sessionId) throw new BadRequestException('session_id required'); if (!sessionId) throw new BadRequestException('session_id required');
return this.billingService.getProvisioningStatus(sessionId); 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') @Post('billing/portal')
@ApiOperation({ summary: 'Create Stripe Customer Portal session' }) @ApiOperation({ summary: 'Create Stripe Customer Portal session' })
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
async createPortal(@Request() req: any) { async createPortal(@Request() req: any) {
// Lookup the org's stripe_customer_id
// Only allow president or superadmin
const orgId = req.user.orgId; const orgId = req.user.orgId;
if (!orgId) throw new BadRequestException('No organization context'); if (!orgId) throw new BadRequestException('No organization context');
// For now, we'd look this up from the org return this.billingService.createPortalSession(orgId);
throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context'); }
// ─── 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

@@ -14,12 +14,15 @@ const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
enterprise: { name: 'Enterprise', unitLimit: 999999 }, enterprise: { name: 'Enterprise', unitLimit: 999999 },
}; };
type BillingInterval = 'month' | 'year';
@Injectable() @Injectable()
export class BillingService { export class BillingService {
private readonly logger = new Logger(BillingService.name); private readonly logger = new Logger(BillingService.name);
private stripe: Stripe | null = null; private stripe: Stripe | null = null;
private webhookSecret: string; private webhookSecret: string;
private priceMap: Record<string, string>; private priceMap: Record<string, { monthly: string; annual: string }>;
private requirePaymentForTrial: boolean;
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
@@ -37,27 +40,118 @@ export class BillingService {
} }
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || ''; 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 = { this.priceMap = {
starter: this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '', starter: {
professional: this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '', monthly: this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID')
enterprise: this.configService.get<string>('STRIPE_ENTERPRISE_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. * 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, email?: string, businessName?: string): Promise<{ url: string }> { async createCheckoutSession(
if (!this.stripe) { planId: string,
throw new BadRequestException('Stripe not configured'); billingInterval: BillingInterval = 'month',
} email?: string,
businessName?: string,
): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const priceId = this.priceMap[planId]; const priceId = this.getPriceId(planId, billingInterval);
if (!priceId || priceId.includes('placeholder')) {
throw new BadRequestException(`Invalid plan: ${planId}`);
}
const session = await this.stripe.checkout.sessions.create({ const sessionConfig: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription', mode: 'subscription',
payment_method_types: ['card'], payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
@@ -67,12 +161,28 @@ export class BillingService {
metadata: { metadata: {
plan_id: planId, plan_id: planId,
business_name: businessName || '', 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! }; return { url: session.url! };
} }
// ─── Webhook Handling ───────────────────────────────────────
/** /**
* Handle a Stripe webhook event. * Handle a Stripe webhook event.
*/ */
@@ -117,19 +227,39 @@ export class BillingService {
case 'customer.subscription.deleted': case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription); await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break; 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: default:
this.logger.log(`Unhandled Stripe event: ${event.type}`); this.logger.log(`Unhandled Stripe event: ${event.type}`);
} }
} }
// ─── Provisioning Status ────────────────────────────────────
/** /**
* Get provisioning status for a checkout session. * Get provisioning status for a checkout session OR subscription ID.
*/ */
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> { async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
if (!this.stripe) return { status: 'not_configured' }; if (!this.stripe) return { status: 'not_configured' };
const session = await this.stripe.checkout.sessions.retrieve(sessionId); // Try as checkout session first
const customerId = session.customer as string; 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' }; if (!customerId) return { status: 'pending' };
@@ -139,15 +269,56 @@ export class BillingService {
); );
if (rows.length === 0) return { status: 'provisioning' }; if (rows.length === 0) return { status: 'provisioning' };
if (rows[0].status === 'active') return { status: 'active' }; if (['active', 'trial'].includes(rows[0].status)) return { status: 'active' };
return { status: 'provisioning' }; return { status: 'provisioning' };
} }
// ─── Stripe Customer Portal ─────────────────────────────────
/** /**
* Create a Stripe Customer Portal session. * Create a Stripe Customer Portal session for managing subscription.
*/ */
async createPortalSession(customerId: string): Promise<{ url: string }> { async createPortalSession(orgId: string): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured'); 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({ const session = await this.stripe.billingPortal.sessions.create({
customer: customerId, customer: customerId,
@@ -157,7 +328,105 @@ export class BillingService {
return { url: session.url }; return { url: session.url };
} }
// ─── Provisioning (inline, no BullMQ for now — add queue later) ───── // ─── 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> { private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const customerId = session.customer as string; const customerId = session.customer as string;
@@ -165,11 +434,27 @@ export class BillingService {
const email = session.customer_email || session.customer_details?.email || ''; const email = session.customer_email || session.customer_details?.email || '';
const planId = session.metadata?.plan_id || 'starter'; const planId = session.metadata?.plan_id || 'starter';
const businessName = session.metadata?.business_name || 'My HOA'; 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}`); this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
try { try {
await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName); // 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) { } catch (err: any) {
this.logger.error(`Provisioning failed: ${err.message}`, err.stack); this.logger.error(`Provisioning failed: ${err.message}`, err.stack);
} }
@@ -177,10 +462,10 @@ export class BillingService {
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> { private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string; const customerId = invoice.customer as string;
// Activate tenant if it was pending // Activate tenant if it was pending/trial
await this.dataSource.query( await this.dataSource.query(
`UPDATE shared.organizations SET status = 'active', updated_at = NOW() `UPDATE shared.organizations SET status = 'active', updated_at = NOW()
WHERE stripe_customer_id = $1 AND status != 'active'`, WHERE stripe_customer_id = $1 AND status IN ('trial', 'past_due')`,
[customerId], [customerId],
); );
} }
@@ -188,9 +473,17 @@ export class BillingService {
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> { private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string; const customerId = invoice.customer as string;
const rows = await this.dataSource.query( const rows = await this.dataSource.query(
`SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`, `SELECT email, name FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId], [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) { if (rows.length > 0 && rows[0].email) {
await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization'); await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization');
} }
@@ -207,6 +500,91 @@ export class BillingService {
this.logger.log(`Subscription cancelled for customer ${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. * Full provisioning flow: create org, schema, user, invite token, email.
*/ */
@@ -216,20 +594,26 @@ export class BillingService {
email: string, email: string,
planId: string, planId: string,
businessName: string, businessName: string,
status: 'active' | 'trial' = 'active',
billingInterval: BillingInterval = 'month',
trialEndsAt?: Date,
): Promise<void> { ): Promise<void> {
// 1. Create or upsert organization // 1. Create or upsert organization
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`; const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
const orgRows = await this.dataSource.query( const orgRows = await this.dataSource.query(
`INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email) `INSERT INTO shared.organizations
VALUES ($1, $2, 'active', $3, $4, $5, $6) (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 ON CONFLICT (stripe_customer_id) DO UPDATE SET
stripe_subscription_id = EXCLUDED.stripe_subscription_id, stripe_subscription_id = EXCLUDED.stripe_subscription_id,
plan_level = EXCLUDED.plan_level, plan_level = EXCLUDED.plan_level,
status = 'active', status = EXCLUDED.status,
billing_interval = EXCLUDED.billing_interval,
trial_ends_at = EXCLUDED.trial_ends_at,
updated_at = NOW() updated_at = NOW()
RETURNING id, schema_name`, RETURNING id, schema_name`,
[businessName, schemaName, planId, customerId, subscriptionId, email], [businessName, schemaName, status, planId, customerId, subscriptionId, email, billingInterval, trialEndsAt || null],
); );
const orgId = orgRows[0].id; const orgId = orgRows[0].id;
@@ -285,7 +669,7 @@ export class BillingService {
[orgId], [orgId],
); );
this.logger.log(`Provisioning complete for org=${orgId}, user=${userId}`); this.logger.log(`Provisioning complete for org=${orgId}, user=${userId}, status=${status}`);
} }
private getAppUrl(): string { private getAppUrl(): string {

View File

@@ -96,6 +96,42 @@ export class EmailService {
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl }); 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 sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> { async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password'; const subject = 'Reset your HOA LedgerIQ password';
const html = this.buildTemplate({ const html = this.buildTemplate({

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

@@ -45,6 +45,13 @@ services:
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-} - STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_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_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback} - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}

View File

@@ -34,6 +34,13 @@ services:
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-} - STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_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_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback} - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}

View File

@@ -1,14 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea, Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput, Select, Stack, Text, Title, Alert, ActionIcon, Table,
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box, Card, ThemeIcon, Divider, Badge, SimpleGrid, Box,
} from '@mantine/core'; } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { import {
IconBuildingBank, IconUsers, IconFileSpreadsheet, IconBuildingBank, IconUsers,
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket, IconPlus, IconTrash, IconCheck, IconRocket,
IconAlertCircle, IconAlertCircle, IconFileSpreadsheet,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import api from '../../services/api'; import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -24,27 +25,6 @@ interface UnitRow {
ownerEmail: string; ownerEmail: string;
} }
// ── CSV Parsing (reused from BudgetsPage pattern) ──
function parseCSV(text: string): Record<string, string>[] {
const lines = text.split('\n').filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
return lines.slice(1).map((line) => {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes; }
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += char; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, i) => { row[h] = values[i] || ''; });
return row;
});
}
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) { export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -57,22 +37,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
const [accountNumber, setAccountNumber] = useState('1000'); const [accountNumber, setAccountNumber] = useState('1000');
const [accountDescription, setAccountDescription] = useState(''); const [accountDescription, setAccountDescription] = useState('');
const [initialBalance, setInitialBalance] = useState<number | string>(0); const [initialBalance, setInitialBalance] = useState<number | string>(0);
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
// ── Step 2: Assessment Group State ── // ── Step 2: Assessment Group State ──
const [groupCreated, setGroupCreated] = useState(false); const [groupCreated, setGroupCreated] = useState(false);
const [groupName, setGroupName] = useState('Standard Assessment'); const [groupName, setGroupName] = useState('Standard Assessment');
const [regularAssessment, setRegularAssessment] = useState<number | string>(0); const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
const [frequency, setFrequency] = useState('monthly'); const [frequency, setFrequency] = useState('monthly');
const [unitCount, setUnitCount] = useState<number | string>(0);
const [units, setUnits] = useState<UnitRow[]>([]); const [units, setUnits] = useState<UnitRow[]>([]);
const [unitsCreated, setUnitsCreated] = useState(false); 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 Account ──
const handleCreateAccount = async () => { const handleCreateAccount = async () => {
if (!accountName.trim()) { if (!accountName.trim()) {
@@ -99,6 +74,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
accountType: 'asset', accountType: 'asset',
fundType: 'operating', fundType: 'operating',
initialBalance: balance, initialBalance: balance,
initialBalanceDate: balanceDate ? balanceDate.toISOString().split('T')[0] : undefined,
}); });
setAccountCreated(true); setAccountCreated(true);
notifications.show({ notifications.show({
@@ -126,6 +102,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
return; return;
} }
const count = typeof unitCount === 'string' ? parseInt(unitCount) : unitCount;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
@@ -133,6 +111,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
name: groupName.trim(), name: groupName.trim(),
regularAssessment: assessment, regularAssessment: assessment,
frequency, frequency,
unitCount: isNaN(count) ? 0 : count,
isDefault: true, isDefault: true,
}); });
setGroupCreated(true); setGroupCreated(true);
@@ -175,62 +154,6 @@ 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 ──
const handleFinish = async () => { const handleFinish = async () => {
setLoading(true); setLoading(true);
@@ -265,13 +188,12 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
const canGoNext = () => { const canGoNext = () => {
if (active === 0) return accountCreated; if (active === 0) return accountCreated;
if (active === 1) return groupCreated; if (active === 1) return groupCreated;
if (active === 2) return true; // Budget is optional
return false; return false;
}; };
const nextStep = () => { const nextStep = () => {
setError(null); setError(null);
if (active < 3) setActive(active + 1); if (active < 2) setActive(active + 1);
}; };
return ( return (
@@ -315,12 +237,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
icon={<IconUsers size={18} />} icon={<IconUsers size={18} />}
completedIcon={<IconCheck size={18} />} completedIcon={<IconCheck size={18} />}
/> />
<Stepper.Step
label="Budget"
description="Import your annual budget"
icon={<IconFileSpreadsheet size={18} />}
completedIcon={<IconCheck size={18} />}
/>
</Stepper> </Stepper>
{error && ( {error && (
@@ -343,6 +259,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Text fw={500}>{accountName} created successfully!</Text> <Text fw={500}>{accountName} created successfully!</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()} Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
{balanceDate && ` as of ${balanceDate.toLocaleDateString()}`}
</Text> </Text>
</Alert> </Alert>
) : ( ) : (
@@ -372,17 +289,26 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
autosize autosize
minRows={2} minRows={2}
/> />
<NumberInput <SimpleGrid cols={2} mb="md">
label="Current Balance" <NumberInput
description="Enter the current balance of this bank account" label="Current Balance"
placeholder="0.00" description="Enter the current balance of this bank account"
value={initialBalance} placeholder="0.00"
onChange={setInitialBalance} value={initialBalance}
thousandSeparator="," onChange={setInitialBalance}
prefix="$" thousandSeparator=","
decimalScale={2} prefix="$"
mb="md" decimalScale={2}
/> />
<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 <Button
onClick={handleCreateAccount} onClick={handleCreateAccount}
loading={loading} loading={loading}
@@ -415,7 +341,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Alert> </Alert>
) : ( ) : (
<> <>
<SimpleGrid cols={3} mb="md"> <SimpleGrid cols={2} mb="md">
<TextInput <TextInput
label="Group Name" label="Group Name"
placeholder="e.g. Standard Assessment" placeholder="e.g. Standard Assessment"
@@ -423,6 +349,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
onChange={(e) => setGroupName(e.currentTarget.value)} onChange={(e) => setGroupName(e.currentTarget.value)}
required 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 <NumberInput
label="Assessment Amount" label="Assessment Amount"
placeholder="0.00" placeholder="0.00"
@@ -520,71 +457,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Stack> </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 ── */} {/* ── Completion Screen ── */}
{active === 3 && ( {active === 2 && (
<Card withBorder p="xl" style={{ textAlign: 'center' }}> <Card withBorder p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md"> <ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
<IconCheck size={32} /> <IconCheck size={32} />
</ThemeIcon> </ThemeIcon>
<Title order={3} mb="xs">You&apos;re All Set!</Title> <Title order={3} mb="xs">You&apos;re All Set!</Title>
<Text c="dimmed" mb="lg" maw={400} mx="auto"> <Text c="dimmed" mb="lg" maw={400} mx="auto">
Your organization is configured and ready to go. You can always update your accounts, Your organization is configured and ready to go. You can always update your accounts
assessment groups, and budgets from the sidebar navigation. and assessment groups from the sidebar navigation.
</Text> </Text>
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto"> <SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
<Card withBorder p="sm" style={{ textAlign: 'center' }}> <Card withBorder p="sm" style={{ textAlign: 'center' }}>
@@ -605,12 +487,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}> <ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} /> <IconFileSpreadsheet size={16} />
</ThemeIcon> </ThemeIcon>
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm"> <Badge color="cyan" size="sm">Up Next</Badge>
{budgetUploaded ? 'Done' : 'Skipped'}
</Badge>
<Text size="xs" mt={4}>Budget</Text> <Text size="xs" mt={4}>Budget</Text>
</Card> </Card>
</SimpleGrid> </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">
Head to <Text span fw={600}>Budget Planning</Text> from the sidebar to download a CSV template,
fill in your monthly amounts, and upload your budget. You can do this at any time.
</Text>
</Alert>
<Button <Button
size="lg" size="lg"
onClick={handleFinish} onClick={handleFinish}
@@ -625,18 +512,13 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
)} )}
{/* ── Navigation Buttons ── */} {/* ── Navigation Buttons ── */}
{active < 3 && ( {active < 2 && (
<Group justify="flex-end" mt="xl"> <Group justify="flex-end" mt="xl">
{active === 2 && !budgetUploaded && (
<Button variant="subtle" onClick={nextStep}>
Skip for now
</Button>
)}
<Button <Button
onClick={nextStep} onClick={nextStep}
disabled={!canGoNext()} disabled={!canGoNext()}
> >
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'} Next Step
</Button> </Button>
</Group> </Group>
)} )}

View File

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

View File

@@ -1,13 +1,13 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, NumberInput, 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'; } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { import {
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
IconCheck, IconArrowBack, IconTrash, IconRefresh, IconCheck, IconArrowBack, IconTrash, IconRefresh,
IconUpload, IconDownload, IconUpload, IconDownload, IconFileSpreadsheet,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
@@ -659,7 +659,37 @@ export function BudgetPlanningPage() {
{lineData.length === 0 && ( {lineData.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={15}> <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.Td>
</Table.Tr> </Table.Tr>
)} )}

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api'; import api from '../../services/api';
interface HealthScore { interface HealthScore {
@@ -313,6 +313,7 @@ interface DashboardData {
export function DashboardPage() { export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg); const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling // Track whether a refresh is in progress (per score type) for async polling
@@ -426,7 +427,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={operatingRefreshing} isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating} onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed} lastFailed={!!healthScores?.operating_last_failed}
/> />
<HealthScoreCard <HealthScoreCard
@@ -438,7 +439,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={reserveRefreshing} isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve} onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed} lastFailed={!!healthScores?.reserve_last_failed}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -43,6 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ── // ── Types ──
@@ -384,6 +385,7 @@ export function InvestmentPlanningPage() {
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null); const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState(''); const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date()); const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
const isReadOnly = useIsReadOnly();
// Load investment scenarios for the "Add to Plan" modal // Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({ const { data: investmentScenarios } = useQuery<any[]>({
@@ -821,15 +823,17 @@ export function InvestmentPlanningPage() {
</Text> </Text>
</div> </div>
</Group> </Group>
<Button {!isReadOnly && (
leftSection={<IconSparkles size={16} />} <Button
onClick={handleTriggerAI} leftSection={<IconSparkles size={16} />}
loading={isProcessing} onClick={handleTriggerAI}
variant="gradient" loading={isProcessing}
gradient={{ from: 'grape', to: 'violet' }} variant="gradient"
> gradient={{ from: 'grape', to: 'violet' }}
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'} >
</Button> {aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
)}
</Group> </Group>
{/* Processing State - shown as banner when refreshing with existing results */} {/* Processing State - shown as banner when refreshing with existing results */}

View File

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

View File

@@ -1,19 +1,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge, Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
Button, List, ThemeIcon, TextInput, Center, Alert, Button, List, ThemeIcon, TextInput, Center, Alert, SegmentedControl, Box,
} from '@mantine/core'; } from '@mantine/core';
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react'; import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
import logoSrc from '../../assets/logo.png'; import logoSrc from '../../assets/logo.png';
type BillingInterval = 'month' | 'year';
const plans = [ const plans = [
{ {
id: 'starter', id: 'starter',
name: 'Starter', name: 'Starter',
price: '$29', monthlyPrice: 29,
period: '/month', annualPrice: 261, // 29 * 12 * 0.75
description: 'For small communities getting started', description: 'For small communities getting started',
icon: IconRocket, icon: IconRocket,
color: 'blue', color: 'blue',
@@ -29,8 +31,8 @@ const plans = [
{ {
id: 'professional', id: 'professional',
name: 'Professional', name: 'Professional',
price: '$79', monthlyPrice: 79,
period: '/month', annualPrice: 711, // 79 * 12 * 0.75
description: 'For growing HOAs that need full features', description: 'For growing HOAs that need full features',
icon: IconStar, icon: IconStar,
color: 'violet', color: 'violet',
@@ -47,8 +49,8 @@ const plans = [
{ {
id: 'enterprise', id: 'enterprise',
name: 'Enterprise', name: 'Enterprise',
price: 'Custom', monthlyPrice: 0,
period: '', annualPrice: 0,
description: 'For large communities and management firms', description: 'For large communities and management firms',
icon: IconCrown, icon: IconCrown,
color: 'orange', color: 'orange',
@@ -64,29 +66,53 @@ const plans = [
}, },
]; ];
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() { export function PricingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [businessName, setBusinessName] = 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;
}
const handleSelectPlan = async (planId: string) => {
setLoading(planId); setLoading(planId);
setError(''); setError('');
try { try {
const { data } = await api.post('/billing/create-checkout-session', { const { data } = await api.post('/billing/start-trial', {
planId, planId,
email: email || undefined, billingInterval,
businessName: businessName || undefined, email: email.trim(),
businessName: businessName.trim(),
}); });
if (data.url) { if (data.subscriptionId) {
window.location.href = data.url; // Navigate to pending page with subscription ID for polling
navigate(`/onboarding/pending?session_id=${data.subscriptionId}`);
} else { } else {
setError('Unable to create checkout session'); setError('Unable to start trial');
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || 'Failed to start checkout'); setError(err.response?.data?.message || 'Failed to start trial');
} finally { } finally {
setLoading(null); setLoading(null);
} }
@@ -104,20 +130,48 @@ export function PricingPage() {
</Text> </Text>
</Stack> </Stack>
{/* Optional pre-capture fields */} {/* 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"> <Center mb="xl">
<Group> <Group>
<TextInput <TextInput
placeholder="Email address" placeholder="Email address *"
value={email} value={email}
onChange={(e) => setEmail(e.currentTarget.value)} onChange={(e) => setEmail(e.currentTarget.value)}
style={{ width: 220 }} style={{ width: 220 }}
required
/> />
<TextInput <TextInput
placeholder="HOA / Business name" placeholder="HOA / Business name *"
value={businessName} value={businessName}
onChange={(e) => setBusinessName(e.currentTarget.value)} onChange={(e) => setBusinessName(e.currentTarget.value)}
style={{ width: 220 }} style={{ width: 220 }}
required
/> />
</Group> </Group>
</Center> </Center>
@@ -129,87 +183,101 @@ export function PricingPage() {
)} )}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{plans.map((plan) => ( {plans.map((plan) => {
<Card const price = formatPrice(plan, billingInterval);
key={plan.id} return (
withBorder <Card
shadow={plan.popular ? 'lg' : 'sm'} key={plan.id}
radius="md" withBorder
p="xl" shadow={plan.popular ? 'lg' : 'sm'}
style={plan.popular ? { radius="md"
border: '2px solid var(--mantine-color-violet-5)', p="xl"
position: 'relative', style={plan.popular ? {
} : undefined} border: '2px solid var(--mantine-color-violet-5)',
> position: 'relative',
{plan.popular && ( } : undefined}
<Badge >
color="violet" {plan.popular && (
variant="filled" <Badge
style={{ position: 'absolute', top: -10, right: 20 }} color="violet"
> variant="filled"
Most Popular style={{ position: 'absolute', top: -10, right: 20 }}
</Badge> >
)} 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>
<Stack gap="md">
<Group>
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
<plan.icon size={20} />
</ThemeIcon>
<div> <div>
<Text fw={700} size="lg">{plan.name}</Text> <Group align="baseline" gap={4}>
<Text size="xs" c="dimmed">{plan.description}</Text> <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> </div>
</Group>
<Group align="baseline" gap={4}> <List spacing="xs" size="sm" center>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}> {plan.features.map((f, i) => (
{plan.externalUrl ? 'Request Quote' : plan.price} <List.Item
</Text> key={i}
{plan.period && <Text size="sm" c="dimmed">{plan.period}</Text>} icon={
</Group> <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>
<List spacing="xs" size="sm" center> <Button
{plan.features.map((f, i) => ( fullWidth
<List.Item size="md"
key={i} color={plan.color}
icon={ variant={plan.popular ? 'filled' : 'light'}
<ThemeIcon loading={!plan.externalUrl ? loading === plan.id : false}
size={20} onClick={() =>
radius="xl" plan.externalUrl
color={f.included ? 'teal' : 'gray'} ? window.open(plan.externalUrl, '_blank', 'noopener')
variant={f.included ? 'filled' : 'light'} : handleStartTrial(plan.id)
> }
{f.included ? <IconCheck size={12} /> : <IconX size={12} />} >
</ThemeIcon> {plan.externalUrl ? 'Request Quote' : 'Start Free Trial'}
} </Button>
> </Stack>
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text> </Card>
</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')
: handleSelectPlan(plan.id)
}
>
{plan.externalUrl ? 'Request Quote' : 'Get Started'}
</Button>
</Stack>
</Card>
))}
</SimpleGrid> </SimpleGrid>
<Text ta="center" size="sm" c="dimmed" mt="xl"> <Text ta="center" size="sm" c="dimmed" mt="xl">
All plans include a 14-day free trial. No credit card required to start. All plans include a 14-day free trial. No credit card required.
</Text> </Text>
</Container> </Container>
); );

View File

@@ -1,11 +1,11 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider, Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
Tabs, Button, Switch, Tabs, Button, Switch, Loader,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconBuilding, IconUser, IconSettings, IconShieldLock, IconBuilding, IconUser, IconSettings, IconShieldLock,
IconFingerprint, IconLink, IconLogout, IconFingerprint, IconLink, IconLogout, IconCreditCard,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -15,10 +15,40 @@ import { PasskeySettings } from './PasskeySettings';
import { LinkedAccounts } from './LinkedAccounts'; import { LinkedAccounts } from './LinkedAccounts';
import api from '../../services/api'; 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() { export function SettingsPage() {
const { user, currentOrg } = useAuthStore(); const { user, currentOrg } = useAuthStore();
const { compactView, toggleCompactView } = usePreferencesStore(); const { compactView, toggleCompactView } = usePreferencesStore();
const [loggingOutAll, setLoggingOutAll] = useState(false); 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 () => { const handleLogoutEverywhere = async () => {
setLoggingOutAll(true); setLoggingOutAll(true);
@@ -32,6 +62,32 @@ export function SettingsPage() {
} }
}; };
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 ( return (
<Stack> <Stack>
<div> <div>
@@ -63,6 +119,79 @@ export function SettingsPage() {
</Stack> </Stack>
</Card> </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 */} {/* User Profile */}
<Card withBorder padding="lg"> <Card withBorder padding="lg">
<Group mb="md"> <Group mb="md">
@@ -108,7 +237,7 @@ export function SettingsPage() {
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text> <Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.03.17</Badge> <Badge variant="light">2026.03.18</Badge>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">API</Text> <Text size="sm" c="dimmed">API</Text>

View File

@@ -12,6 +12,9 @@
# #
# Replace "app.yourdomain.com" with your actual hostname throughout this file. # Replace "app.yourdomain.com" with your actual hostname throughout this file.
# Hide nginx version from Server header
server_tokens off;
# --- Rate limiting --- # --- Rate limiting ---
# 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs) # 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; limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
@@ -49,6 +52,12 @@ server {
ssl_session_timeout 10m; ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; 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 defaults ---
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -8,6 +8,9 @@ upstream frontend {
keepalive 16; keepalive 16;
} }
# Hide nginx version from Server header
server_tokens off;
# Shared proxy settings # Shared proxy settings
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ""; # enable keepalive to upstreams proxy_set_header Connection ""; # enable keepalive to upstreams
@@ -30,6 +33,12 @@ server {
listen 80; listen 80;
server_name _; 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 --- # --- API routes → backend ---
location /api/ { location /api/ {
limit_req zone=api_limit burst=30 nodelay; 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, "private": true,
"description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)", "description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)",
"scripts": { "scripts": {
"fetch-cd-rates": "tsx fetch-cd-rates.ts" "fetch-cd-rates": "tsx fetch-cd-rates.ts",
"cleanup": "tsx cleanup-test-data.ts"
}, },
"dependencies": { "dependencies": {
"dotenv": "^16.4.7", "dotenv": "^16.4.7",