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