# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide > **Version:** 2026.03.18 > **Last updated:** March 18, 2026 > **Migrations:** `db/migrations/015-saas-onboarding-auth.sql`, `db/migrations/017-billing-enhancements.sql` --- ## Table of Contents 1. [High-Level Flow](#1-high-level-flow) 2. [Stripe Billing & Checkout](#2-stripe-billing--checkout) 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) --- ## 1. High-Level Flow ``` Visitor hits /pricing | v Selects plan (Starter / Professional / Enterprise) Chooses billing frequency (Monthly / Annual — 25% discount) Enters email + business name | v POST /api/billing/start-trial (no card required) | v Backend creates Stripe customer + subscription with trial_period_days=14 Backend provisions: org -> schema -> user -> invite token -> email | v Frontend navigates to /onboarding/pending?session_id=xxx (polls GET /api/billing/status every 3s) | v Status returns "active" -> user is redirected to /login | v User clicks activation link from email | v GET /activate?token=xxx -> validates token POST /activate -> sets password + name, issues session | v Redirect to /onboarding (4-step guided wizard) | v 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 & Pricing | 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 | ### Stripe Products & Prices 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 | Event | Action | |-------|--------| | `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). --- ## 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. --- ## 4. Monthly / Annual Billing ### 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 signup - `schema_name` = `tenant_{random_12_chars}` - `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()` 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) 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 ready - `active` -- ready (includes `trial` status) --- ## 6. Account Activation (Magic Link) ### Validate Token `GET /api/auth/activate?token=xxx` -- returns `{ valid, email, orgName, orgId, userId }` ### Activate Account `POST /api/auth/activate` -- body `{ token, password, fullName }` -- sets password, issues session --- ## 7. Guided Onboarding Checklist | 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 | --- ## 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 | | 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 | ### 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 session | | `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions | | `POST` | `/api/auth/switch-org` | JWT | Switch organization | --- ## 12. Multi-Factor Authentication (TOTP) ### MFA Endpoints | 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 | --- ## 13. Single Sign-On (SSO) | 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` | SSO providers are conditionally loaded based on env vars. --- ## 14. Passkeys (WebAuthn) | 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 | --- ## 15. Environment Variables Reference ### Stripe (Required for billing) | 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 | Legacy single-price vars (`STRIPE_STARTER_PRICE_ID`, etc.) are still supported as fallback for monthly prices. ### Trial Configuration | Variable | Default | Description | |----------|---------|-------------| | `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 | | `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin | ### Other | Variable | Default | Description | |----------|---------|-------------| | `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 | --- ## 16. Manual Intervention & Ops Tasks ### Stripe Dashboard Setup 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 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. **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`, `JWT_SECRET`, `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_ORIGIN` 5. **Configure SSO providers** (optional) ### Ongoing Ops - **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` ### Finding activation URLs (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; ``` --- ## 17. What's Stubbed vs. Production-Ready | Component | Status | Notes | |-----------|--------|-------| | 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 | --- ## 18. API Endpoint Reference ### Billing | Method | Path | Auth | Description | |--------|------|------|-------------| | `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 | | `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 | 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 email | | `GET` | `/api/auth/profile` | JWT | Get user profile | | `POST` | `/api/auth/switch-org` | JWT | Switch organization | ### Onboarding | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress | | `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete | --- ## Database Tables & Columns ### Tables Added (Migration 015) | Table | Purpose | |-------|---------| | `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 credentials | | `shared.email_log` | Email audit trail | ### 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`