feat: add annual billing, free trial, upgrade/downgrade, and ACH invoice support

- Add monthly/annual billing toggle with 25% annual discount on pricing page
- Implement 14-day no-card free trial (server-side Stripe subscription creation)
- Enable upgrade/downgrade via Stripe Customer Portal
- Add admin-initiated ACH/invoice billing for enterprise customers
- Add billing card to Settings page with plan info and Manage Billing button
- Handle past_due status with read-only grace period access
- Add trial ending and trial expired email templates
- Add DB migration for billing_interval and collection_method columns
- Update ONBOARDING-AND-AUTH.md documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 08:04:51 -04:00
parent 5845334454
commit a996208cb8
12 changed files with 1241 additions and 507 deletions

View File

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