- 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>
22 KiB
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
- High-Level Flow
- Stripe Billing & Checkout
- 14-Day Free Trial
- Monthly / Annual Billing
- Provisioning Pipeline
- Account Activation (Magic Link)
- Guided Onboarding Checklist
- Subscription Management & Upgrade/Downgrade
- ACH / Invoice Billing
- Access Control & Grace Periods
- 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 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
- User visits
/pricing, selects a plan and billing frequency - User enters email + business name (required)
- Clicks "Start Free Trial"
- Backend creates Stripe customer (no payment method)
- Backend creates subscription with
trial_period_days: 14 - Backend provisions org with
status = 'trial'immediately - 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:
-
Create organization in
shared.organizationswith:name= business name from signupschema_name=tenant_{random_12_chars}status=trial(for trial) oractive(for card checkout)plan_level= selected planbilling_interval=monthoryearstripe_customer_id+stripe_subscription_idtrial_ends_at(if trial)- Uses
ON CONFLICT (stripe_customer_id)for idempotency
-
Create tenant schema via
TenantSchemaService.createTenantSchema() -
Create or find user in
shared.usersby email -
Create membership in
shared.user_organizations(role:president) -
Generate invite token (JWT, 72-hour expiry)
-
Send activation email with link to set password
-
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 uppending-- no customer ID yetprovisioning-- org exists but not readyactive-- ready (includestrialstatus)
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/portalwhich 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:
{
"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
- Admin calls
PUT /api/admin/organizations/:id/billingwith:{ "collectionMethod": "send_invoice", "daysUntilDue": 30 } - Stripe subscription is updated:
collection_method = 'send_invoice',days_until_due = 30 - At each billing cycle, Stripe generates an invoice and emails it to the customer
- Customer pays via ACH / wire / bank transfer
- 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:
{ "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): Blockssuspendedandarchivedwith 403. Setsreq.orgPastDue = trueforpast_due. - WriteAccessGuard (
write-access.guard.ts): Blocks POST/PUT/PATCH/DELETE forpast_dueorgs 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_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
-
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
-
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
- URL:
-
Configure Stripe Customer Portal:
- Enable plan switching (allow switching between monthly and annual prices)
- Enable payment method updates
- Enable cancellation
- Enable invoice history
-
Set production secrets:
INVITE_TOKEN_SECRET,JWT_SECRET,WEBAUTHN_RP_ID,WEBAUTHN_RP_ORIGIN -
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)
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