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>
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
- High-Level Flow
- Stripe Billing & Checkout
- Provisioning Pipeline
- Account Activation (Magic Link)
- Guided Onboarding Checklist
- Authentication & Sessions
- Multi-Factor Authentication (TOTP)
- Single Sign-On (SSO)
- Passkeys (WebAuthn)
- Environment Variables Reference
- Manual Intervention & Ops Tasks
- What's Stubbed vs. Production-Ready
- 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
- Frontend (
PricingPage.tsx): User enters email + business name, selects a plan. - API call:
POST /api/billing/create-checkout-session- Body:
{ planId, email?, businessName? } - Returns:
{ url }(Stripe hosted checkout URL) - No auth required.
- Body:
- Redirect: Frontend does
window.location.href = urlto send user to Stripe. - On success: Stripe redirects to
/onboarding/pending?session_id={CHECKOUT_SESSION_ID}. - 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):
-
Create organization in
shared.organizationswith:name= business name from checkout metadataschema_name=tenant_{random_12_chars}status=activeplan_level= selected planstripe_customer_id+stripe_subscription_id- Uses
ON CONFLICT (stripe_customer_id)for idempotency
-
Create tenant schema via
TenantSchemaService.createTenantSchema():- Runs the full tenant DDL (accounts, journal entries, etc.)
- Skips if schema already exists
-
Create or find user in
shared.usersby email:- New users are created with
is_email_verified = falseand no password - Existing users are reused (linked to new org)
- New users are created with
-
Create membership in
shared.user_organizations:- Role:
president - Idempotent via
ON CONFLICT DO NOTHING
- Role:
-
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
- SHA-256 hash stored in
-
Send activation "email" (stubbed -- see section 12):
- Logged to console and
shared.email_logtable - Contains activation URL:
{APP_URL}/activate?token={jwt}
- Logged to console and
-
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 uppending-- no customer ID yetprovisioning-- org exists but not active yetactive-- 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_tokensfor 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/progresson 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
- Access token expires (401 from any API call)
- Axios interceptor catches 401, calls
POST /api/auth/refresh - Refresh token is sent automatically via httpOnly cookie
- Server validates token hash in
shared.refresh_tokenstable - New access token issued (refresh token is NOT rotated)
- Original failed request is replayed with new token
- Concurrent requests are queued during refresh (no thundering herd)
Cookie Configuration
- Name:
ledgeriq_rt - Path:
/api/auth - httpOnly:
true - Secure:
truein production,falsein 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
- User goes to Settings > Security > Two-Factor Auth tab
POST /api/auth/mfa/setup-- returns{ qrCodeDataUrl, secret, uri }- User scans QR code in authenticator app (Google Authenticator, Authy, etc.)
- User enters 6-digit code to confirm
POST /api/auth/mfa/enablewith{ token }-- returns{ recoveryCodes[] }- User must save their 10 recovery codes (displayed once, bcrypt-hashed in DB)
Login with MFA
POST /api/auth/loginreturns{ mfaRequired: true, mfaToken }instead of session- Frontend shows 6-digit PIN input (or recovery code input)
POST /api/auth/mfa/verifywith{ mfaToken, token, useRecovery? }- 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:
otplibv4 (generateSecret,generateURI,verifySync) - QR codes:
qrcodepackage (data URL output) - Recovery codes:
crypto.randomBytes+bcryptjs
8. Single Sign-On (SSO)
Supported Providers
| Provider | Library | Env Vars Required |
|---|---|---|
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
- Frontend redirects to
/api/auth/googleor/api/auth/azure - Passport handles OAuth redirect to provider
- Provider redirects back to
/api/auth/{provider}/callback - Backend creates or links user via
SsoService.findOrCreateSsoUser() - 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)
POST /api/auth/passkeys/register-options-- returns WebAuthn creation options- Browser
navigator.credentials.create()via@simplewebauthn/browser POST /api/auth/passkeys/registerwith{ response, deviceName? }- Credential stored in
shared.user_passkeys
Login Flow (unauthenticated)
POST /api/auth/passkeys/login-optionswith{ email? }-- returns assertion options- Browser
navigator.credentials.get()via@simplewebauthn/browser POST /api/auth/passkeys/loginwith{ response, challenge }- 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.,localhostfor dev,yourdomain.comfor prod)WEBAUTHN_RP_ORIGIN-- Expected origin (e.g.,http://localhostorhttps://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
-
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.
-
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
- Endpoint URL:
-
Replace the email stub with a real provider:
backend/src/modules/email/email.service.tscurrently logs to console + DB- Swap in Resend, SendGrid, SES, or your preferred provider
- The four email methods to implement:
sendActivationEmail,sendWelcomeEmail,sendPaymentFailedEmail,sendInviteMemberEmail
-
Set production secrets:
INVITE_TOKEN_SECRET-- use a strong random string (notdev-invite-secret)JWT_SECRET-- already required, verify it's strongWEBAUTHN_RP_IDandWEBAUTHN_RP_ORIGIN-- set to your production domain
-
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
-
Set up the Stripe Customer Portal in Stripe Dashboard:
- Configure allowed actions (cancel, upgrade/downgrade, payment method updates)
- The
/api/billing/portalendpoint needs the org-context customer ID lookup completed
Ongoing Ops
-
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)
-
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_logtable (query:SELECT * FROM shared.email_log ORDER BY sent_at DESC)
- Backend console logs (look for lines starting with
-
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; -
Resend an activation email:
POST /api/auth/resend-activationwith{ 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. -
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
statusback toactiveinshared.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_atshared.users:totp_verified_at,recovery_codes,webauthn_challenge