Files
HOA_Financial_Platform/ONBOARDING-AND-AUTH.md
olsch01 20438b7ef5 docs: add payment, onboarding, and auth reference guide
Covers Stripe billing flow, provisioning pipeline, activation magic links,
onboarding checklist, refresh tokens, MFA, SSO, passkeys, env var reference,
manual intervention checklist, and full API endpoint reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:24:59 -04:00

23 KiB

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
  2. Stripe Billing & Checkout
  3. Provisioning Pipeline
  4. Account Activation (Magic Link)
  5. Guided Onboarding Checklist
  6. Authentication & Sessions
  7. Multi-Factor Authentication (TOTP)
  8. Single Sign-On (SSO)
  9. Passkeys (WebAuthn)
  10. Environment Variables Reference
  11. Manual Intervention & Ops Tasks
  12. What's Stubbed vs. Production-Ready
  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.


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)
  • 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

  1. 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)
  2. 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)
  3. Finding activation URLs manually (dev/testing):

    SELECT to_email, metadata->>'activationUrl' AS url, sent_at
    FROM shared.email_log
    WHERE template = 'activation'
    ORDER BY sent_at DESC
    LIMIT 10;
    
  4. Resend an activation email: POST /api/auth/resend-activation with { email } is stubbed (always returns success). To manually generate a new token:

    -- 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.

  5. 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