Compare commits
11 Commits
claude/ten
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db8b520009 | |||
| e2d72223c8 | |||
| a996208cb8 | |||
| 5845334454 | |||
| 170461c359 | |||
| aacec1cce3 | |||
| 6b12fcd7d7 | |||
| 8e58d04568 | |||
| c2e52bee64 | |||
| 9cd641923d | |||
| 8abab40778 |
24
.env.example
24
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
| `profile` | Profile | Set up user profile |
|
||||||
| `workspace` | Workspace | Configure organization settings |
|
| `workspace` | Workspace | Configure organization settings |
|
||||||
| `invite_member` | Invite Member | Invite at least one team member |
|
| `invite_member` | Invite Member | Invite at least one team member |
|
||||||
| `first_workflow` | First Account | Create the first chart-of-accounts entry |
|
| `first_workflow` | First Account | Create the first chart-of-accounts entry |
|
||||||
|
|
||||||
### API
|
---
|
||||||
|
|
||||||
- `GET /api/onboarding/progress` (auth required): Returns `{ completedSteps[], completedAt, requiredSteps[] }`
|
## 8. Subscription Management & Upgrade/Downgrade
|
||||||
- `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).
|
### Stripe Customer Portal
|
||||||
|
|
||||||
### Frontend (OnboardingPage.tsx)
|
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
|
||||||
|
|
||||||
- Mantine Stepper with 4 steps
|
### What Users Can Do in the Portal
|
||||||
- Each step calls `PATCH /onboarding/progress` on completion
|
|
||||||
- Celebration screen shown when all steps are done
|
- **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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Authentication & Sessions
|
## 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
|
||||||
|
|
||||||
@@ -517,40 +546,10 @@ SSO providers are **conditionally loaded** -- they only appear on the login page
|
|||||||
| `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 |
|
||||||
@@ -560,17 +559,29 @@ SSO providers are **conditionally loaded** -- they only appear on the login page
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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`
|
||||||
|
|||||||
77
backend/package-lock.json
generated
77
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.11",
|
"version": "2026.3.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.11",
|
"version": "2026.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"resend": "^6.9.4",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"stripe": "^20.4.1",
|
"stripe": "^20.4.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
@@ -2791,6 +2792,12 @@
|
|||||||
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
||||||
@@ -5357,6 +5364,12 @@
|
|||||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fb-watchman": {
|
"node_modules/fb-watchman": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
||||||
@@ -8723,6 +8736,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
||||||
|
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/postgres-array": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -9207,6 +9226,27 @@
|
|||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz",
|
||||||
|
"integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postal-mime": "2.7.3",
|
||||||
|
"svix": "1.86.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -9779,6 +9819,16 @@
|
|||||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -10037,6 +10087,29 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svix": {
|
||||||
|
"version": "1.86.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
|
||||||
|
"integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"standardwebhooks": "1.0.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svix/node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/swagger-ui-dist": {
|
"node_modules/swagger-ui-dist": {
|
||||||
"version": "5.17.14",
|
"version": "5.17.14",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"resend": "^6.9.4",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"stripe": "^20.4.1",
|
"stripe": "^20.4.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|
||||||
|
// Try as checkout session first
|
||||||
|
let customerId: string | null = null;
|
||||||
|
try {
|
||||||
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
|
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
|
||||||
const customerId = session.customer as string;
|
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 {
|
||||||
|
|||||||
@@ -1,64 +1,195 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubbed email service — logs to console and stores in shared.email_log.
|
|
||||||
* Replace internals with Resend/SendGrid when ready for production.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
private readonly logger = new Logger(EmailService.name);
|
private readonly logger = new Logger(EmailService.name);
|
||||||
|
private resend: Resend | null = null;
|
||||||
|
private fromAddress: string;
|
||||||
|
private replyToAddress: string;
|
||||||
|
|
||||||
constructor(private dataSource: DataSource) {}
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
const apiKey = this.configService.get<string>('RESEND_API_KEY');
|
||||||
|
if (apiKey && !apiKey.includes('placeholder')) {
|
||||||
|
this.resend = new Resend(apiKey);
|
||||||
|
this.logger.log('Resend email service initialized');
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
|
||||||
|
}
|
||||||
|
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
|
||||||
|
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────────────────────────────
|
||||||
|
|
||||||
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
||||||
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
|
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
|
||||||
const body = [
|
const html = this.buildTemplate({
|
||||||
`Welcome to HOA LedgerIQ!`,
|
preheader: 'Your HOA LedgerIQ account is ready to activate.',
|
||||||
``,
|
heading: 'Welcome to HOA LedgerIQ!',
|
||||||
`Your organization "${businessName}" has been created.`,
|
body: `
|
||||||
`Please activate your account by clicking the link below:`,
|
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
|
||||||
``,
|
<p>Click the button below to set your password and activate your account:</p>
|
||||||
activationUrl,
|
`,
|
||||||
``,
|
ctaText: 'Activate My Account',
|
||||||
`This link expires in 72 hours.`,
|
ctaUrl: activationUrl,
|
||||||
].join('\n');
|
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
|
||||||
|
});
|
||||||
|
|
||||||
await this.log(email, subject, body, 'activation', { businessName, activationUrl });
|
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
|
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
|
||||||
|
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
|
||||||
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
|
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
|
||||||
const body = `Your account is active. Log in at http://localhost to get started.`;
|
const html = this.buildTemplate({
|
||||||
await this.log(email, subject, body, 'welcome', { businessName });
|
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
|
||||||
|
heading: `You're all set!`,
|
||||||
|
body: `
|
||||||
|
<p>Your account for <strong>${this.esc(businessName)}</strong> is now active.</p>
|
||||||
|
<p>Log in to start managing your HOA's finances, assessments, and investments — all in one place.</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Go to Dashboard',
|
||||||
|
ctaUrl: `${appUrl}/dashboard`,
|
||||||
|
footer: 'If you have any questions, just reply to this email and we\'ll help you get started.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'welcome', { businessName });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
|
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
|
||||||
const subject = `Payment failed for ${businessName} on HOA LedgerIQ`;
|
const subject = `Action required: Payment failed for ${businessName}`;
|
||||||
const body = `We were unable to process your payment. Please update your payment method.`;
|
const html = this.buildTemplate({
|
||||||
await this.log(email, subject, body, 'payment_failed', { businessName });
|
preheader: 'We were unable to process your payment.',
|
||||||
|
heading: 'Payment Failed',
|
||||||
|
body: `
|
||||||
|
<p>We were unable to process the latest payment for <strong>${this.esc(businessName)}</strong>.</p>
|
||||||
|
<p>Please update your payment method to avoid any interruption to your service.</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Update Payment Method',
|
||||||
|
ctaUrl: `${this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`,
|
||||||
|
footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'payment_failed', { businessName });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
|
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
|
||||||
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
|
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
|
||||||
const body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`;
|
const html = this.buildTemplate({
|
||||||
await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl });
|
preheader: `Join ${orgName} on HOA LedgerIQ.`,
|
||||||
|
heading: 'You\'re Invited!',
|
||||||
|
body: `
|
||||||
|
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
|
||||||
|
<p>Click below to accept the invitation and set up your account:</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Accept Invitation',
|
||||||
|
ctaUrl: inviteUrl,
|
||||||
|
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise<void> {
|
||||||
|
const subject = `Your free trial ends in ${daysRemaining} days — ${businessName}`;
|
||||||
|
const html = this.buildTemplate({
|
||||||
|
preheader: `Your HOA LedgerIQ trial for ${businessName} is ending soon.`,
|
||||||
|
heading: `Your Trial Ends in ${daysRemaining} Days`,
|
||||||
|
body: `
|
||||||
|
<p>Your free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ ends in <strong>${daysRemaining} days</strong>.</p>
|
||||||
|
<p>To continue using all features without interruption, add a payment method before your trial expires.</p>
|
||||||
|
<p>If you don't add a payment method, your account will become read-only and you won't be able to make changes to your data.</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Add Payment Method',
|
||||||
|
ctaUrl: settingsUrl,
|
||||||
|
footer: 'If you have any questions about plans or pricing, just reply to this email.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'trial_ending', { businessName, daysRemaining, settingsUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTrialExpiredEmail(email: string, businessName: string): Promise<void> {
|
||||||
|
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
|
||||||
|
const subject = `Your free trial has ended — ${businessName}`;
|
||||||
|
const html = this.buildTemplate({
|
||||||
|
preheader: `Your HOA LedgerIQ trial for ${businessName} has ended.`,
|
||||||
|
heading: 'Your Trial Has Ended',
|
||||||
|
body: `
|
||||||
|
<p>The free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ has ended.</p>
|
||||||
|
<p>Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Choose a Plan',
|
||||||
|
ctaUrl: `${appUrl}/pricing`,
|
||||||
|
footer: 'Your data will be preserved. You can reactivate your account at any time by subscribing to a plan.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'trial_expired', { businessName });
|
||||||
}
|
}
|
||||||
|
|
||||||
async 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 body = [
|
const html = this.buildTemplate({
|
||||||
`You requested a password reset for your HOA LedgerIQ account.`,
|
preheader: 'Password reset requested for your HOA LedgerIQ account.',
|
||||||
``,
|
heading: 'Password Reset',
|
||||||
`Click the link below to reset your password:`,
|
body: `
|
||||||
resetUrl,
|
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
|
||||||
``,
|
`,
|
||||||
`This link expires in 15 minutes. If you didn't request this, ignore this email.`,
|
ctaText: 'Reset Password',
|
||||||
].join('\n');
|
ctaUrl: resetUrl,
|
||||||
|
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
|
||||||
|
});
|
||||||
|
|
||||||
await this.log(email, subject, body, 'password_reset', { resetUrl });
|
await this.send(email, subject, html, 'password_reset', { resetUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Core send logic ────────────────────────────────────────
|
||||||
|
|
||||||
|
private async send(
|
||||||
|
toEmail: string,
|
||||||
|
subject: string,
|
||||||
|
html: string,
|
||||||
|
template: string,
|
||||||
|
metadata: Record<string, any>,
|
||||||
|
): Promise<void> {
|
||||||
|
// Always log to the database
|
||||||
|
await this.log(toEmail, subject, html, template, metadata);
|
||||||
|
|
||||||
|
if (!this.resend) {
|
||||||
|
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
|
||||||
|
this.logger.log(` Subject: ${subject}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromAddress,
|
||||||
|
to: [toEmail],
|
||||||
|
replyTo: this.replyToAddress || undefined,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'failed', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Database logging ───────────────────────────────────────
|
||||||
|
|
||||||
private async log(
|
private async log(
|
||||||
toEmail: string,
|
toEmail: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
@@ -66,10 +197,6 @@ export class EmailService {
|
|||||||
template: string,
|
template: string,
|
||||||
metadata: Record<string, any>,
|
metadata: Record<string, any>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`EMAIL STUB -> ${toEmail}`);
|
|
||||||
this.logger.log(` Subject: ${subject}`);
|
|
||||||
this.logger.log(` Body:\n${body}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
||||||
@@ -80,4 +207,119 @@ export class EmailService {
|
|||||||
this.logger.warn(`Failed to log email: ${err}`);
|
this.logger.warn(`Failed to log email: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.email_log
|
||||||
|
SET metadata = metadata || $1::jsonb
|
||||||
|
WHERE to_email = $2 AND template = $3
|
||||||
|
AND created_at = (
|
||||||
|
SELECT MAX(created_at) FROM shared.email_log
|
||||||
|
WHERE to_email = $2 AND template = $3
|
||||||
|
)`,
|
||||||
|
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best effort — don't block the flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTML email template ────────────────────────────────────
|
||||||
|
|
||||||
|
private esc(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTemplate(opts: {
|
||||||
|
preheader: string;
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
ctaText: string;
|
||||||
|
ctaUrl: string;
|
||||||
|
footer: string;
|
||||||
|
}): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${this.esc(opts.heading)}</title>
|
||||||
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<!-- Preheader (hidden preview text) -->
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;">${this.esc(opts.preheader)}</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;padding:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Logo bar -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:24px 0 16px;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#1a73e8;letter-spacing:-0.5px;">
|
||||||
|
HOA LedgerIQ
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||||
|
style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 32px;">
|
||||||
|
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#1a1a2e;">
|
||||||
|
${this.esc(opts.heading)}
|
||||||
|
</h1>
|
||||||
|
<div style="font-size:15px;line-height:1.6;color:#4a4a68;">
|
||||||
|
${opts.body}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color:#1a73e8;border-radius:6px;">
|
||||||
|
<a href="${opts.ctaUrl}"
|
||||||
|
target="_blank"
|
||||||
|
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
||||||
|
${this.esc(opts.ctaText)}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Fallback URL -->
|
||||||
|
<p style="font-size:12px;color:#999;word-break:break-all;margin-top:16px;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||||
|
<a href="${opts.ctaUrl}" style="color:#1a73e8;">${opts.ctaUrl}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 32px;text-align:center;">
|
||||||
|
<p style="font-size:12px;color:#999;line-height:1.5;margin:0;">
|
||||||
|
${this.esc(opts.footer)}
|
||||||
|
</p>
|
||||||
|
<p style="font-size:12px;color:#bbb;margin:12px 0 0;">
|
||||||
|
© ${new Date().getFullYear()} HOA LedgerIQ — Smart Financial Management for HOAs
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
db/migrations/017-billing-enhancements.sql
Normal file
27
db/migrations/017-billing-enhancements.sql
Normal 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'));
|
||||||
@@ -40,6 +40,32 @@ services:
|
|||||||
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||||
|
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
|
||||||
|
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
|
||||||
|
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
|
||||||
|
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
|
||||||
|
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
|
||||||
|
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
|
||||||
|
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
|
||||||
|
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
|
||||||
|
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}
|
||||||
|
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
|
||||||
|
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
|
||||||
|
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
|
||||||
|
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/azure/callback}
|
||||||
|
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-app.hoaledgeriq.com}
|
||||||
|
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-https://app.hoaledgeriq.com}
|
||||||
|
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-}
|
||||||
|
- APP_URL=${APP_URL:-https://app.hoaledgeriq.com}
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
|
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
|
||||||
|
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-sales@hoaledgeriq.com}
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -44,6 +51,10 @@ services:
|
|||||||
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
|
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
|
||||||
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
|
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
|
||||||
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
|
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
|
||||||
|
- APP_URL=${APP_URL:-http://localhost}
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
|
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
|
||||||
|
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||||
|
|||||||
@@ -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,6 +289,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
autosize
|
autosize
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
|
<SimpleGrid cols={2} mb="md">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Current Balance"
|
label="Current Balance"
|
||||||
description="Enter the current balance of this bank account"
|
description="Enter the current balance of this bank account"
|
||||||
@@ -381,8 +299,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
thousandSeparator=","
|
thousandSeparator=","
|
||||||
prefix="$"
|
prefix="$"
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
mb="md"
|
|
||||||
/>
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Balance As-Of Date"
|
||||||
|
description="Date this balance was accurate (e.g. last statement date)"
|
||||||
|
value={balanceDate}
|
||||||
|
onChange={setBalanceDate}
|
||||||
|
maxDate={new Date()}
|
||||||
|
clearable={false}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
<Button
|
<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'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're All Set!</Title>
|
<Title order={3} mb="xs">You'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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,11 +49,12 @@ const plans = [
|
|||||||
{
|
{
|
||||||
id: 'enterprise',
|
id: 'enterprise',
|
||||||
name: 'Enterprise',
|
name: 'Enterprise',
|
||||||
price: '$199',
|
monthlyPrice: 0,
|
||||||
period: '/month',
|
annualPrice: 0,
|
||||||
description: 'For large communities and management firms',
|
description: 'For large communities and management firms',
|
||||||
icon: IconCrown,
|
icon: IconCrown,
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
|
externalUrl: 'https://www.hoaledgeriq.com/#preview-signup',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Unlimited units', included: true },
|
{ text: 'Unlimited units', included: true },
|
||||||
{ text: 'Everything in Professional', included: true },
|
{ text: 'Everything in Professional', included: true },
|
||||||
@@ -63,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);
|
||||||
}
|
}
|
||||||
@@ -103,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>
|
||||||
@@ -128,7 +183,9 @@ 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) => {
|
||||||
|
const price = formatPrice(plan, billingInterval);
|
||||||
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
withBorder
|
withBorder
|
||||||
@@ -161,12 +218,23 @@ export function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
<Group align="baseline" gap={4}>
|
<Group align="baseline" gap={4}>
|
||||||
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: 36 }}>
|
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
|
||||||
{plan.price}
|
{plan.externalUrl ? 'Request Quote' : price.display}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">{plan.period}</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
{price.sub && (
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{price.sub}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!plan.externalUrl && billingInterval === 'year' && (
|
||||||
|
<Text size="xs" c="dimmed" td="line-through" mt={2}>
|
||||||
|
${plan.monthlyPrice}/mo without annual discount
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<List spacing="xs" size="sm" center>
|
<List spacing="xs" size="sm" center>
|
||||||
{plan.features.map((f, i) => (
|
{plan.features.map((f, i) => (
|
||||||
@@ -193,18 +261,23 @@ export function PricingPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
color={plan.color}
|
color={plan.color}
|
||||||
variant={plan.popular ? 'filled' : 'light'}
|
variant={plan.popular ? 'filled' : 'light'}
|
||||||
loading={loading === plan.id}
|
loading={!plan.externalUrl ? loading === plan.id : false}
|
||||||
onClick={() => handleSelectPlan(plan.id)}
|
onClick={() =>
|
||||||
|
plan.externalUrl
|
||||||
|
? window.open(plan.externalUrl, '_blank', 'noopener')
|
||||||
|
: handleStartTrial(plan.id)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Get Started
|
{plan.externalUrl ? 'Request Quote' : 'Start Free Trial'}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
788
scripts/cleanup-test-data.ts
Normal file
788
scripts/cleanup-test-data.ts
Normal 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();
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user