diff --git a/.env.example b/.env.example index 0592894..2f95929 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,30 @@ AI_MODEL=qwen/qwen3.5-397b-a17b # Set to 'true' to enable detailed AI prompt/response logging AI_DEBUG=false +# Stripe Billing +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret + +# Stripe Price IDs (Monthly) +STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly +STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=price_professional_monthly +STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly + +# Stripe Price IDs (Annual — 25% discount) +STRIPE_STARTER_ANNUAL_PRICE_ID=price_starter_annual +STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=price_professional_annual +STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=price_enterprise_annual + +# Trial configuration +REQUIRE_PAYMENT_METHOD_FOR_TRIAL=false + +# Email (Resend) +RESEND_API_KEY=re_your_resend_api_key + +# Application +APP_URL=http://localhost +INVITE_TOKEN_SECRET=dev-invite-secret + # New Relic APM — set ENABLED=true and provide your license key to activate NEW_RELIC_ENABLED=false NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here diff --git a/ONBOARDING-AND-AUTH.md b/ONBOARDING-AND-AUTH.md index d104476..2c5d9e3 100644 --- a/ONBOARDING-AND-AUTH.md +++ b/ONBOARDING-AND-AUTH.md @@ -1,8 +1,8 @@ # HOA LedgerIQ -- Payment, Onboarding & Authentication Guide -> **Version:** 2026.03.17 -> **Last updated:** March 17, 2026 -> **Migration:** `db/migrations/015-saas-onboarding-auth.sql` +> **Version:** 2026.03.18 +> **Last updated:** March 18, 2026 +> **Migrations:** `db/migrations/015-saas-onboarding-auth.sql`, `db/migrations/017-billing-enhancements.sql` --- @@ -10,17 +10,22 @@ 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) +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) --- @@ -30,28 +35,26 @@ Visitor hits /pricing | v -Selects a plan (Starter $29 / Professional $79 / Enterprise $199) +Selects plan (Starter / Professional / Enterprise) +Chooses billing frequency (Monthly / Annual — 25% discount) +Enters email + business name | v -POST /api/billing/create-checkout-session +POST /api/billing/start-trial (no card required) | v -Redirect to Stripe Checkout (hosted by Stripe) +Backend creates Stripe customer + subscription with trial_period_days=14 +Backend provisions: org -> schema -> user -> invite token -> email | v -Stripe fires `checkout.session.completed` webhook +Frontend navigates to /onboarding/pending?session_id=xxx + (polls GET /api/billing/status every 3s) | 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) +User clicks activation link from email | v GET /activate?token=xxx -> validates token @@ -61,184 +64,295 @@ POST /activate -> sets password + name, issues session Redirect to /onboarding (4-step guided wizard) | v -Dashboard +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 +### Plans & Pricing -| Plan ID | Name | Price | Unit Limit | -|---------------|--------------|---------|------------| -| `starter` | Starter | $29/mo | 50 units | -| `professional` | Professional | $79/mo | 200 units | -| `enterprise` | Enterprise | $199/mo | Unlimited | +| 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 | -### Checkout Flow +### Stripe Products & Prices -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`. +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 -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) | +| `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). -### 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. 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. --- -## 3. Provisioning Pipeline +## 4. Monthly / Annual Billing -When `checkout.session.completed` fires, the backend runs **inline provisioning** (no background queue): +### 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 checkout metadata + - `name` = business name from signup - `schema_name` = `tenant_{random_12_chars}` - - `status` = `active` + - `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()`: - - 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` +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) -Returns: `{ status }` where status is one of: +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 active yet -- `active` -- ready to go - -The `OnboardingPendingPage` polls this every 3 seconds and redirects to `/login` once active. +- `provisioning` -- org exists but not ready +- `active` -- ready (includes `trial` status) --- -## 4. Account Activation (Magic Link) +## 6. 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 }` +`GET /api/auth/activate?token=xxx` -- 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` +`POST /api/auth/activate` -- body `{ token, password, fullName }` -- sets password, issues session --- -## 5. Guided Onboarding Checklist +## 7. 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 +| 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 | --- -## 6. Authentication & Sessions +## 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 (memory/localStorage) | -| Refresh token | Opaque (base64url, 64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) | -| MFA challenge | JWT | 5 minutes | Frontend state (in-memory only) | +| 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 | -### 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 | @@ -246,107 +360,38 @@ Steps are stored as a PostgreSQL text array. When all 4 required steps are compl | `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) | +| `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 | --- -## 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) +## 12. Multi-Factor Authentication (TOTP) ### 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` +| `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 | --- -## 8. Single Sign-On (SSO) +## 13. Single Sign-On (SSO) -### Supported Providers +| 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` | -| 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 | +SSO providers are conditionally loaded based on env vars. --- -## 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 +## 14. Passkeys (WebAuthn) | Method | Path | Auth | Description | |--------|------|------|-------------| @@ -354,157 +399,141 @@ SSO providers are **conditionally loaded** -- they only appear on the login page | `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 | +| `GET` | `/api/auth/passkeys` | JWT | List user's 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 +## 15. 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 | +| 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 | -### SSO (Optional -- features hidden when not set) +Legacy single-price vars (`STRIPE_STARTER_PRICE_ID`, etc.) are still supported as fallback for monthly prices. -| Variable | Example | Description | +### Trial Configuration + +| Variable | Default | 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 | +| `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 (your domain) | +| `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 signing invite/activation JWTs. **Change in production.** | -| `APP_URL` | `http://localhost` | Base URL for generated links (activation emails, Stripe redirects) | +| `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 | --- -## 11. Manual Intervention & Ops Tasks +## 16. Manual Intervention & Ops Tasks -### Before Going Live +### Stripe Dashboard Setup -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. +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 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` +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. **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` +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` -- 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 +4. **Set production secrets**: `INVITE_TOKEN_SECRET`, `JWT_SECRET`, `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_ORIGIN` -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 +5. **Configure SSO providers** (optional) ### 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`) +- **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` -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`) +### Finding activation URLs (dev/testing) -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` +```sql +SELECT to_email, metadata->>'activationUrl' AS url, sent_at +FROM shared.email_log +WHERE template = 'activation' +ORDER BY sent_at DESC +LIMIT 10; +``` --- -## 12. What's Stubbed vs. Production-Ready +## 17. 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 | +| 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 | --- -## 13. API Endpoint Reference +## 18. API Endpoint Reference -### Billing (no auth unless noted) +### Billing | Method | Path | Auth | Description | |--------|------|------|-------------| -| `POST` | `/api/billing/create-checkout-session` | No | Create Stripe Checkout, returns `{ url }` | +| `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 | -| `POST` | `/api/billing/portal` | JWT | Stripe Customer Portal (stubbed) | +| `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 @@ -515,62 +544,44 @@ SSO providers are **conditionally loaded** -- they only appear on the login page | `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 | +| `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/resend-activation` | No | Resend activation email | +| `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 | +| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress | | `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete | --- -## Database Tables Added (Migration 015) +## Database Tables & Columns + +### 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.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 credential storage | -| `shared.email_log` | Stubbed email audit trail | +| `shared.user_passkeys` | WebAuthn credentials | +| `shared.email_log` | 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` +### 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` diff --git a/backend/src/common/guards/write-access.guard.ts b/backend/src/common/guards/write-access.guard.ts index f0b4e91..892fecb 100644 --- a/backend/src/common/guards/write-access.guard.ts +++ b/backend/src/common/guards/write-access.guard.ts @@ -30,6 +30,13 @@ export class WriteAccessGuard implements CanActivate { throw new ForbiddenException('Read-only users cannot modify data'); } + // Block writes for past_due organizations (grace period: read-only access) + if (request.orgPastDue) { + throw new ForbiddenException( + 'Your subscription is past due. Please update your payment method to continue making changes.', + ); + } + return true; } } diff --git a/backend/src/database/tenant.middleware.ts b/backend/src/database/tenant.middleware.ts index ea6c960..fae64f3 100644 --- a/backend/src/database/tenant.middleware.ts +++ b/backend/src/database/tenant.middleware.ts @@ -9,6 +9,7 @@ export interface TenantRequest extends Request { orgId?: string; userId?: string; userRole?: string; + orgPastDue?: boolean; } @Injectable() @@ -41,6 +42,10 @@ export class TenantMiddleware implements NestMiddleware { }); return; } + // past_due: allow through with read-only flag (WriteAccessGuard enforces) + if (orgInfo.status === 'past_due') { + req.orgPastDue = true; + } req.tenantSchema = orgInfo.schemaName; } req.orgId = decoded.orgId; diff --git a/backend/src/modules/billing/billing.controller.ts b/backend/src/modules/billing/billing.controller.ts index 46954a1..164a876 100644 --- a/backend/src/modules/billing/billing.controller.ts +++ b/backend/src/modules/billing/billing.controller.ts @@ -1,34 +1,63 @@ import { Controller, Post, + Put, Get, Body, + Param, Query, Req, UseGuards, RawBodyRequest, BadRequestException, + ForbiddenException, Request, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { Request as ExpressRequest } from 'express'; +import { DataSource } from 'typeorm'; import { BillingService } from './billing.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('billing') @Controller() export class BillingController { - constructor(private billingService: BillingService) {} + constructor( + private billingService: BillingService, + private dataSource: DataSource, + ) {} + + @Post('billing/start-trial') + @ApiOperation({ summary: 'Start a free trial (no card required)' }) + @Throttle({ default: { limit: 10, ttl: 60000 } }) + async startTrial( + @Body() body: { planId: string; billingInterval?: 'month' | 'year'; email: string; businessName: string }, + ) { + if (!body.planId) throw new BadRequestException('planId is required'); + if (!body.email) throw new BadRequestException('email is required'); + if (!body.businessName) throw new BadRequestException('businessName is required'); + return this.billingService.startTrial( + body.planId, + body.billingInterval || 'month', + body.email, + body.businessName, + ); + } @Post('billing/create-checkout-session') @ApiOperation({ summary: 'Create a Stripe Checkout Session' }) @Throttle({ default: { limit: 10, ttl: 60000 } }) async createCheckout( - @Body() body: { planId: string; email?: string; businessName?: string }, + @Body() body: { planId: string; billingInterval?: 'month' | 'year'; email?: string; businessName?: string }, ) { if (!body.planId) throw new BadRequestException('planId is required'); - return this.billingService.createCheckoutSession(body.planId, body.email, body.businessName); + return this.billingService.createCheckoutSession( + body.planId, + body.billingInterval || 'month', + body.email, + body.businessName, + ); } @Post('webhooks/stripe') @@ -42,22 +71,63 @@ export class BillingController { } @Get('billing/status') - @ApiOperation({ summary: 'Check provisioning status for a checkout session' }) + @ApiOperation({ summary: 'Check provisioning status for a checkout session or subscription' }) async getStatus(@Query('session_id') sessionId: string) { if (!sessionId) throw new BadRequestException('session_id required'); return this.billingService.getProvisioningStatus(sessionId); } + @Get('billing/subscription') + @ApiOperation({ summary: 'Get current subscription info' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async getSubscription(@Request() req: any) { + const orgId = req.user.orgId; + if (!orgId) throw new BadRequestException('No organization context'); + return this.billingService.getSubscriptionInfo(orgId); + } + @Post('billing/portal') @ApiOperation({ summary: 'Create Stripe Customer Portal session' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) async createPortal(@Request() req: any) { - // Lookup the org's stripe_customer_id - // Only allow president or superadmin const orgId = req.user.orgId; if (!orgId) throw new BadRequestException('No organization context'); - // For now, we'd look this up from the org - throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context'); + return this.billingService.createPortalSession(orgId); + } + + // ─── Admin: Switch Billing Method (ACH / Invoice) ────────── + + @Put('admin/organizations/:id/billing') + @ApiOperation({ summary: 'Switch organization billing method (superadmin only)' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async updateBillingMethod( + @Request() req: any, + @Param('id') id: string, + @Body() body: { collectionMethod: 'charge_automatically' | 'send_invoice'; daysUntilDue?: number }, + ) { + // Require superadmin + const userId = req.user.userId || req.user.sub; + const userRows = await this.dataSource.query( + `SELECT is_superadmin FROM shared.users WHERE id = $1`, + [userId], + ); + if (!userRows.length || !userRows[0].is_superadmin) { + throw new ForbiddenException('Superadmin access required'); + } + + if (!['charge_automatically', 'send_invoice'].includes(body.collectionMethod)) { + throw new BadRequestException('collectionMethod must be "charge_automatically" or "send_invoice"'); + } + + await this.billingService.switchToInvoiceBilling( + id, + body.collectionMethod, + body.daysUntilDue || 30, + ); + + return { success: true }; } } diff --git a/backend/src/modules/billing/billing.service.ts b/backend/src/modules/billing/billing.service.ts index a50602b..89f9223 100644 --- a/backend/src/modules/billing/billing.service.ts +++ b/backend/src/modules/billing/billing.service.ts @@ -14,12 +14,15 @@ const PLAN_FEATURES: Record = { enterprise: { name: 'Enterprise', unitLimit: 999999 }, }; +type BillingInterval = 'month' | 'year'; + @Injectable() export class BillingService { private readonly logger = new Logger(BillingService.name); private stripe: Stripe | null = null; private webhookSecret: string; - private priceMap: Record; + private priceMap: Record; + private requirePaymentForTrial: boolean; constructor( private configService: ConfigService, @@ -37,27 +40,118 @@ export class BillingService { } this.webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET') || ''; + this.requirePaymentForTrial = + this.configService.get('REQUIRE_PAYMENT_METHOD_FOR_TRIAL') === 'true'; + + // Build price map with backward-compat: new monthly vars fall back to old single vars this.priceMap = { - starter: this.configService.get('STRIPE_STARTER_PRICE_ID') || '', - professional: this.configService.get('STRIPE_PROFESSIONAL_PRICE_ID') || '', - enterprise: this.configService.get('STRIPE_ENTERPRISE_PRICE_ID') || '', + starter: { + monthly: this.configService.get('STRIPE_STARTER_MONTHLY_PRICE_ID') + || this.configService.get('STRIPE_STARTER_PRICE_ID') || '', + annual: this.configService.get('STRIPE_STARTER_ANNUAL_PRICE_ID') || '', + }, + professional: { + monthly: this.configService.get('STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID') + || this.configService.get('STRIPE_PROFESSIONAL_PRICE_ID') || '', + annual: this.configService.get('STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID') || '', + }, + enterprise: { + monthly: this.configService.get('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID') + || this.configService.get('STRIPE_ENTERPRISE_PRICE_ID') || '', + annual: this.configService.get('STRIPE_ENTERPRISE_ANNUAL_PRICE_ID') || '', + }, }; } + // ─── Price Resolution ──────────────────────────────────────── + + private getPriceId(planId: string, interval: BillingInterval): string { + const plan = this.priceMap[planId]; + if (!plan) throw new BadRequestException(`Invalid plan: ${planId}`); + const priceId = interval === 'year' ? plan.annual : plan.monthly; + if (!priceId || priceId.includes('placeholder')) { + throw new BadRequestException(`Price not configured for ${planId} (${interval})`); + } + return priceId; + } + + // ─── Trial Signup (No Card Required) ──────────────────────── + + /** + * Start a free trial without collecting payment. + * Creates a Stripe customer + subscription with trial_period_days, + * then provisions the organization immediately. + */ + async startTrial( + planId: string, + billingInterval: BillingInterval, + email: string, + businessName: string, + ): Promise<{ success: boolean; subscriptionId: string }> { + if (!this.stripe) throw new BadRequestException('Stripe not configured'); + if (!email) throw new BadRequestException('Email is required'); + if (!businessName) throw new BadRequestException('Business name is required'); + + const priceId = this.getPriceId(planId, billingInterval); + + // 1. Create Stripe customer + const customer = await this.stripe.customers.create({ + email, + metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval }, + }); + + // 2. Create subscription with 14-day trial (no payment method) + const subscription = await this.stripe.subscriptions.create({ + customer: customer.id, + items: [{ price: priceId }], + trial_period_days: 14, + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + trial_settings: { + end_behavior: { missing_payment_method: 'cancel' }, + }, + metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval }, + }); + + const trialEnd = subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); + + // 3. Provision organization immediately with trial status + await this.provisionOrganization( + customer.id, + subscription.id, + email, + planId, + businessName, + 'trial', + billingInterval, + trialEnd, + ); + + this.logger.log(`Trial started for ${email}, plan=${planId}, interval=${billingInterval}`); + return { success: true, subscriptionId: subscription.id }; + } + + // ─── Checkout Session (Card-required flow / post-trial) ───── + /** * Create a Stripe Checkout Session for a new subscription. + * Used when REQUIRE_PAYMENT_METHOD_FOR_TRIAL=true, or for + * post-trial conversion where the user adds a payment method. */ - async createCheckoutSession(planId: string, email?: string, businessName?: string): Promise<{ url: string }> { - if (!this.stripe) { - throw new BadRequestException('Stripe not configured'); - } + async createCheckoutSession( + planId: string, + billingInterval: BillingInterval = 'month', + email?: string, + businessName?: string, + ): Promise<{ url: string }> { + if (!this.stripe) throw new BadRequestException('Stripe not configured'); - const priceId = this.priceMap[planId]; - if (!priceId || priceId.includes('placeholder')) { - throw new BadRequestException(`Invalid plan: ${planId}`); - } + const priceId = this.getPriceId(planId, billingInterval); - const session = await this.stripe.checkout.sessions.create({ + const sessionConfig: Stripe.Checkout.SessionCreateParams = { mode: 'subscription', payment_method_types: ['card'], line_items: [{ price: priceId, quantity: 1 }], @@ -67,12 +161,28 @@ export class BillingService { metadata: { plan_id: planId, business_name: businessName || '', + billing_interval: billingInterval, }, - }); + }; + // If trial is card-required, add trial period to checkout + if (this.requirePaymentForTrial) { + sessionConfig.subscription_data = { + trial_period_days: 14, + metadata: { + plan_id: planId, + business_name: businessName || '', + billing_interval: billingInterval, + }, + }; + } + + const session = await this.stripe.checkout.sessions.create(sessionConfig); return { url: session.url! }; } + // ─── Webhook Handling ─────────────────────────────────────── + /** * Handle a Stripe webhook event. */ @@ -117,19 +227,39 @@ export class BillingService { case 'customer.subscription.deleted': await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription); break; + case 'customer.subscription.trial_will_end': + await this.handleTrialWillEnd(event.data.object as Stripe.Subscription); + break; + case 'customer.subscription.updated': + await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription); + break; default: this.logger.log(`Unhandled Stripe event: ${event.type}`); } } + // ─── Provisioning Status ──────────────────────────────────── + /** - * Get provisioning status for a checkout session. + * Get provisioning status for a checkout session OR subscription ID. */ async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> { if (!this.stripe) return { status: 'not_configured' }; - const session = await this.stripe.checkout.sessions.retrieve(sessionId); - const customerId = session.customer as string; + // Try as checkout session first + let customerId: string | null = null; + try { + const session = await this.stripe.checkout.sessions.retrieve(sessionId); + customerId = session.customer as string; + } catch { + // Not a checkout session — try looking up by subscription ID + try { + const subscription = await this.stripe.subscriptions.retrieve(sessionId); + customerId = subscription.customer as string; + } catch { + return { status: 'pending' }; + } + } if (!customerId) return { status: 'pending' }; @@ -139,25 +269,131 @@ export class BillingService { ); if (rows.length === 0) return { status: 'provisioning' }; - if (rows[0].status === 'active') return { status: 'active' }; + if (['active', 'trial'].includes(rows[0].status)) return { status: 'active' }; return { status: 'provisioning' }; } + // ─── Stripe Customer Portal ───────────────────────────────── + /** - * Create a Stripe Customer Portal session. + * Create a Stripe Customer Portal session for managing subscription. */ - async createPortalSession(customerId: string): Promise<{ url: string }> { + async createPortalSession(orgId: string): Promise<{ url: string }> { if (!this.stripe) throw new BadRequestException('Stripe not configured'); + const rows = await this.dataSource.query( + `SELECT stripe_customer_id FROM shared.organizations WHERE id = $1`, + [orgId], + ); + if (rows.length === 0 || !rows[0].stripe_customer_id) { + throw new BadRequestException('No Stripe customer found for this organization'); + } + const session = await this.stripe.billingPortal.sessions.create({ - customer: customerId, + customer: rows[0].stripe_customer_id, return_url: `${this.getAppUrl()}/settings`, }); return { url: session.url }; } - // ─── Provisioning (inline, no BullMQ for now — add queue later) ───── + // ─── Subscription Info ────────────────────────────────────── + + /** + * Get current subscription details for the Settings billing tab. + */ + async getSubscriptionInfo(orgId: string): Promise<{ + plan: string; + planName: string; + billingInterval: string; + status: string; + collectionMethod: string; + trialEndsAt: string | null; + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; + }> { + const rows = await this.dataSource.query( + `SELECT plan_level, billing_interval, status, collection_method, + trial_ends_at, stripe_subscription_id + FROM shared.organizations WHERE id = $1`, + [orgId], + ); + + if (rows.length === 0) throw new BadRequestException('Organization not found'); + + const org = rows[0]; + let currentPeriodEnd: string | null = null; + let cancelAtPeriodEnd = false; + + // Fetch live data from Stripe if available + if (this.stripe && org.stripe_subscription_id) { + try { + const sub = await this.stripe.subscriptions.retrieve(org.stripe_subscription_id, { + expand: ['items.data'], + }) as Stripe.Subscription; + // current_period_end is on the subscription item in newer Stripe API versions + const firstItem = sub.items?.data?.[0]; + if (firstItem?.current_period_end) { + currentPeriodEnd = new Date(firstItem.current_period_end * 1000).toISOString(); + } + cancelAtPeriodEnd = sub.cancel_at_period_end; + } catch { + // Non-critical — use DB data only + } + } + + return { + plan: org.plan_level || 'starter', + planName: PLAN_FEATURES[org.plan_level]?.name || org.plan_level || 'Starter', + billingInterval: org.billing_interval || 'month', + status: org.status || 'active', + collectionMethod: org.collection_method || 'charge_automatically', + trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null, + currentPeriodEnd, + cancelAtPeriodEnd, + }; + } + + // ─── Invoice / ACH Billing (Admin) ────────────────────────── + + /** + * Switch a customer's subscription to invoice collection (ACH/wire). + * Admin-only operation for enterprise customers. + */ + async switchToInvoiceBilling( + orgId: string, + collectionMethod: 'charge_automatically' | 'send_invoice', + daysUntilDue: number = 30, + ): Promise { + if (!this.stripe) throw new BadRequestException('Stripe not configured'); + + const rows = await this.dataSource.query( + `SELECT stripe_subscription_id, stripe_customer_id FROM shared.organizations WHERE id = $1`, + [orgId], + ); + if (rows.length === 0 || !rows[0].stripe_subscription_id) { + throw new BadRequestException('No Stripe subscription found for this organization'); + } + + const updateParams: Stripe.SubscriptionUpdateParams = { + collection_method: collectionMethod, + }; + if (collectionMethod === 'send_invoice') { + updateParams.days_until_due = daysUntilDue; + } + + await this.stripe.subscriptions.update(rows[0].stripe_subscription_id, updateParams); + + // Update DB + await this.dataSource.query( + `UPDATE shared.organizations SET collection_method = $1, updated_at = NOW() WHERE id = $2`, + [collectionMethod, orgId], + ); + + this.logger.log(`Billing method updated for org ${orgId}: ${collectionMethod}`); + } + + // ─── Webhook Handlers ────────────────────────────────────── private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise { const customerId = session.customer as string; @@ -165,11 +401,27 @@ export class BillingService { const email = session.customer_email || session.customer_details?.email || ''; const planId = session.metadata?.plan_id || 'starter'; const businessName = session.metadata?.business_name || 'My HOA'; + const billingInterval = (session.metadata?.billing_interval || 'month') as BillingInterval; this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`); try { - await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName); + // Determine if this is a trial checkout (card required for trial) + let status: 'active' | 'trial' = 'active'; + let trialEnd: Date | undefined; + + if (this.stripe && subscriptionId) { + const sub = await this.stripe.subscriptions.retrieve(subscriptionId); + if (sub.status === 'trialing' && sub.trial_end) { + status = 'trial'; + trialEnd = new Date(sub.trial_end * 1000); + } + } + + await this.provisionOrganization( + customerId, subscriptionId, email, planId, businessName, + status, billingInterval, trialEnd, + ); } catch (err: any) { this.logger.error(`Provisioning failed: ${err.message}`, err.stack); } @@ -177,10 +429,10 @@ export class BillingService { private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise { const customerId = invoice.customer as string; - // Activate tenant if it was pending + // Activate tenant if it was pending/trial await this.dataSource.query( `UPDATE shared.organizations SET status = 'active', updated_at = NOW() - WHERE stripe_customer_id = $1 AND status != 'active'`, + WHERE stripe_customer_id = $1 AND status IN ('trial', 'past_due')`, [customerId], ); } @@ -188,9 +440,17 @@ export class BillingService { private async handlePaymentFailed(invoice: Stripe.Invoice): Promise { const customerId = invoice.customer as string; const rows = await this.dataSource.query( - `SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`, + `SELECT email, name FROM shared.organizations WHERE stripe_customer_id = $1`, [customerId], ); + + // Set org to past_due for grace period (read-only access) + await this.dataSource.query( + `UPDATE shared.organizations SET status = 'past_due', updated_at = NOW() + WHERE stripe_customer_id = $1 AND status = 'active'`, + [customerId], + ); + if (rows.length > 0 && rows[0].email) { await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization'); } @@ -207,6 +467,91 @@ export class BillingService { this.logger.log(`Subscription cancelled for customer ${customerId}`); } + private async handleTrialWillEnd(subscription: Stripe.Subscription): Promise { + const customerId = subscription.customer as string; + const rows = await this.dataSource.query( + `SELECT id, email, name FROM shared.organizations WHERE stripe_customer_id = $1`, + [customerId], + ); + + if (rows.length === 0) return; + + const org = rows[0]; + const daysRemaining = 3; // This webhook fires 3 days before trial end + const settingsUrl = `${this.getAppUrl()}/settings`; + + if (org.email) { + await this.emailService.sendTrialEndingEmail( + org.email, + org.name || 'Your organization', + daysRemaining, + settingsUrl, + ); + } + + this.logger.log(`Trial ending soon for customer ${customerId}, org ${org.id}`); + } + + private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise { + const customerId = subscription.customer as string; + + // Determine new status + let newStatus: string; + switch (subscription.status) { + case 'trialing': + newStatus = 'trial'; + break; + case 'active': + newStatus = 'active'; + break; + case 'past_due': + newStatus = 'past_due'; + break; + case 'canceled': + case 'unpaid': + newStatus = 'archived'; + break; + default: + return; // Don't update for other statuses + } + + // Determine billing interval from the subscription items + let billingInterval: BillingInterval = 'month'; + if (subscription.items?.data?.[0]?.price?.recurring?.interval === 'year') { + billingInterval = 'year'; + } + + // Determine plan from price metadata or existing mapping + let planId: string | null = null; + const activePriceId = subscription.items?.data?.[0]?.price?.id; + if (activePriceId) { + for (const [plan, prices] of Object.entries(this.priceMap)) { + if (prices.monthly === activePriceId || prices.annual === activePriceId) { + planId = plan; + break; + } + } + } + + // Build update query dynamically + const updates: string[] = [`status = '${newStatus}'`, `billing_interval = '${billingInterval}'`, `updated_at = NOW()`]; + if (planId) { + updates.push(`plan_level = '${planId}'`); + } + if (subscription.collection_method) { + updates.push(`collection_method = '${subscription.collection_method}'`); + } + + await this.dataSource.query( + `UPDATE shared.organizations SET ${updates.join(', ')} WHERE stripe_customer_id = $1`, + [customerId], + ); + + this.logger.log(`Subscription updated for customer ${customerId}: status=${newStatus}, interval=${billingInterval}`); + } + + // ─── Provisioning ────────────────────────────────────────── + /** * Full provisioning flow: create org, schema, user, invite token, email. */ @@ -216,20 +561,26 @@ export class BillingService { email: string, planId: string, businessName: string, + status: 'active' | 'trial' = 'active', + billingInterval: BillingInterval = 'month', + trialEndsAt?: Date, ): Promise { // 1. Create or upsert organization const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`; const orgRows = await this.dataSource.query( - `INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email) - VALUES ($1, $2, 'active', $3, $4, $5, $6) + `INSERT INTO shared.organizations + (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email, billing_interval, trial_ends_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (stripe_customer_id) DO UPDATE SET stripe_subscription_id = EXCLUDED.stripe_subscription_id, plan_level = EXCLUDED.plan_level, - status = 'active', + status = EXCLUDED.status, + billing_interval = EXCLUDED.billing_interval, + trial_ends_at = EXCLUDED.trial_ends_at, updated_at = NOW() RETURNING id, schema_name`, - [businessName, schemaName, planId, customerId, subscriptionId, email], + [businessName, schemaName, status, planId, customerId, subscriptionId, email, billingInterval, trialEndsAt || null], ); const orgId = orgRows[0].id; @@ -285,7 +636,7 @@ export class BillingService { [orgId], ); - this.logger.log(`✅ Provisioning complete for org=${orgId}, user=${userId}`); + this.logger.log(`Provisioning complete for org=${orgId}, user=${userId}, status=${status}`); } private getAppUrl(): string { diff --git a/backend/src/modules/email/email.service.ts b/backend/src/modules/email/email.service.ts index 29931b8..aeb1caa 100644 --- a/backend/src/modules/email/email.service.ts +++ b/backend/src/modules/email/email.service.ts @@ -96,6 +96,42 @@ export class EmailService { await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl }); } + async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise { + const subject = `Your free trial ends in ${daysRemaining} days — ${businessName}`; + const html = this.buildTemplate({ + preheader: `Your HOA LedgerIQ trial for ${businessName} is ending soon.`, + heading: `Your Trial Ends in ${daysRemaining} Days`, + body: ` +

Your free trial for ${this.esc(businessName)} on HOA LedgerIQ ends in ${daysRemaining} days.

+

To continue using all features without interruption, add a payment method before your trial expires.

+

If you don't add a payment method, your account will become read-only and you won't be able to make changes to your data.

+ `, + ctaText: 'Add Payment Method', + ctaUrl: settingsUrl, + footer: 'If you have any questions about plans or pricing, just reply to this email.', + }); + + await this.send(email, subject, html, 'trial_ending', { businessName, daysRemaining, settingsUrl }); + } + + async sendTrialExpiredEmail(email: string, businessName: string): Promise { + const appUrl = this.configService.get('APP_URL') || 'https://app.hoaledgeriq.com'; + const subject = `Your free trial has ended — ${businessName}`; + const html = this.buildTemplate({ + preheader: `Your HOA LedgerIQ trial for ${businessName} has ended.`, + heading: 'Your Trial Has Ended', + body: ` +

The free trial for ${this.esc(businessName)} on HOA LedgerIQ has ended.

+

Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.

+ `, + ctaText: 'Choose a Plan', + ctaUrl: `${appUrl}/pricing`, + footer: 'Your data will be preserved. You can reactivate your account at any time by subscribing to a plan.', + }); + + await this.send(email, subject, html, 'trial_expired', { businessName }); + } + async sendPasswordResetEmail(email: string, resetUrl: string): Promise { const subject = 'Reset your HOA LedgerIQ password'; const html = this.buildTemplate({ diff --git a/db/migrations/017-billing-enhancements.sql b/db/migrations/017-billing-enhancements.sql new file mode 100644 index 0000000..8465c59 --- /dev/null +++ b/db/migrations/017-billing-enhancements.sql @@ -0,0 +1,27 @@ +-- Migration 017: Billing Enhancements +-- Adds support for annual billing, free trials, ACH/invoice billing, +-- and past_due grace period status. + +-- ============================================================================ +-- 1. Add billing_interval column (month or year) +-- ============================================================================ +ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS billing_interval VARCHAR(20) DEFAULT 'month'; + +-- ============================================================================ +-- 2. Add collection_method column (charge_automatically or send_invoice) +-- ============================================================================ +ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS collection_method VARCHAR(20) DEFAULT 'charge_automatically'; + +-- ============================================================================ +-- 3. Update status CHECK to include 'past_due' +-- ============================================================================ +ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_status_check; +ALTER TABLE shared.organizations ADD CONSTRAINT organizations_status_check + CHECK (status IN ('active', 'suspended', 'trial', 'archived', 'past_due')); + +-- ============================================================================ +-- 4. Ensure plan_level CHECK includes SaaS tiers (idempotent with 015) +-- ============================================================================ +ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_plan_level_check; +ALTER TABLE shared.organizations ADD CONSTRAINT organizations_plan_level_check + CHECK (plan_level IN ('standard', 'premium', 'enterprise', 'starter', 'professional')); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 587f164..ebee179 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -45,6 +45,13 @@ services: - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-} - STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-} + - STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-} + - STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-} + - STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-} + - STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-} + - STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-} + - STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-} + - REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback} diff --git a/docker-compose.yml b/docker-compose.yml index cd4048c..63cdc0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,13 @@ services: - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-} - STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-} + - STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-} + - STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-} + - STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-} + - STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-} + - STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-} + - STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-} + - REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback} diff --git a/frontend/src/pages/pricing/PricingPage.tsx b/frontend/src/pages/pricing/PricingPage.tsx index 9b67fe6..0b8cde7 100644 --- a/frontend/src/pages/pricing/PricingPage.tsx +++ b/frontend/src/pages/pricing/PricingPage.tsx @@ -1,19 +1,21 @@ import { useState } from 'react'; import { Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge, - Button, List, ThemeIcon, TextInput, Center, Alert, + Button, List, ThemeIcon, TextInput, Center, Alert, SegmentedControl, Box, } from '@mantine/core'; import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; import api from '../../services/api'; import logoSrc from '../../assets/logo.png'; +type BillingInterval = 'month' | 'year'; + const plans = [ { id: 'starter', name: 'Starter', - price: '$29', - period: '/month', + monthlyPrice: 29, + annualPrice: 261, // 29 * 12 * 0.75 description: 'For small communities getting started', icon: IconRocket, color: 'blue', @@ -29,8 +31,8 @@ const plans = [ { id: 'professional', name: 'Professional', - price: '$79', - period: '/month', + monthlyPrice: 79, + annualPrice: 711, // 79 * 12 * 0.75 description: 'For growing HOAs that need full features', icon: IconStar, color: 'violet', @@ -47,8 +49,8 @@ const plans = [ { id: 'enterprise', name: 'Enterprise', - price: 'Custom', - period: '', + monthlyPrice: 0, + annualPrice: 0, description: 'For large communities and management firms', icon: IconCrown, color: 'orange', @@ -64,29 +66,53 @@ const plans = [ }, ]; +function formatPrice(plan: typeof plans[0], interval: BillingInterval) { + if (plan.externalUrl) return { display: 'Custom', sub: '' }; + if (interval === 'year') { + const monthly = (plan.annualPrice / 12).toFixed(2); + return { + display: `$${monthly}`, + sub: `/mo billed annually ($${plan.annualPrice}/yr)`, + }; + } + return { display: `$${plan.monthlyPrice}`, sub: '/month' }; +} + export function PricingPage() { const navigate = useNavigate(); const [loading, setLoading] = useState(null); const [error, setError] = useState(''); const [email, setEmail] = useState(''); const [businessName, setBusinessName] = useState(''); + const [billingInterval, setBillingInterval] = useState('month'); + + const handleStartTrial = async (planId: string) => { + if (!email.trim()) { + setError('Email address is required to start a trial'); + return; + } + if (!businessName.trim()) { + setError('HOA / Business name is required to start a trial'); + return; + } - const handleSelectPlan = async (planId: string) => { setLoading(planId); setError(''); try { - const { data } = await api.post('/billing/create-checkout-session', { + const { data } = await api.post('/billing/start-trial', { planId, - email: email || undefined, - businessName: businessName || undefined, + billingInterval, + email: email.trim(), + businessName: businessName.trim(), }); - if (data.url) { - window.location.href = data.url; + if (data.subscriptionId) { + // Navigate to pending page with subscription ID for polling + navigate(`/onboarding/pending?session_id=${data.subscriptionId}`); } else { - setError('Unable to create checkout session'); + setError('Unable to start trial'); } } catch (err: any) { - setError(err.response?.data?.message || 'Failed to start checkout'); + setError(err.response?.data?.message || 'Failed to start trial'); } finally { setLoading(null); } @@ -104,20 +130,48 @@ export function PricingPage() { - {/* Optional pre-capture fields */} + {/* Monthly / Annual Toggle */} +
+ + setBillingInterval(val as BillingInterval)} + data={[ + { label: 'Monthly', value: 'month' }, + { label: 'Annual', value: 'year' }, + ]} + size="md" + radius="xl" + /> + {billingInterval === 'year' && ( + + Save 25% + + )} + +
+ + {/* Pre-capture fields (required for trial) */}
setEmail(e.currentTarget.value)} style={{ width: 220 }} + required /> setBusinessName(e.currentTarget.value)} style={{ width: 220 }} + required />
@@ -129,87 +183,101 @@ export function PricingPage() { )} - {plans.map((plan) => ( - - {plan.popular && ( - - Most Popular - - )} + {plans.map((plan) => { + const price = formatPrice(plan, billingInterval); + return ( + + {plan.popular && ( + + Most Popular + + )} + + + + + + +
+ {plan.name} + {plan.description} +
+
- - - - -
- {plan.name} - {plan.description} + + + {plan.externalUrl ? 'Request Quote' : price.display} + + + {price.sub && ( + + {price.sub} + + )} + {!plan.externalUrl && billingInterval === 'year' && ( + + ${plan.monthlyPrice}/mo without annual discount + + )}
-
- - - {plan.externalUrl ? 'Request Quote' : plan.price} - - {plan.period && {plan.period}} - + + {plan.features.map((f, i) => ( + + {f.included ? : } + + } + > + {f.text} + + ))} + - - {plan.features.map((f, i) => ( - - {f.included ? : } - - } - > - {f.text} - - ))} - - - -
-
- ))} + + +
+ ); + })}
- All plans include a 14-day free trial. No credit card required to start. + All plans include a 14-day free trial. No credit card required. ); diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 0992716..049e7f0 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -1,11 +1,11 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider, - Tabs, Button, Switch, + Tabs, Button, Switch, Loader, } from '@mantine/core'; import { IconBuilding, IconUser, IconSettings, IconShieldLock, - IconFingerprint, IconLink, IconLogout, + IconFingerprint, IconLink, IconLogout, IconCreditCard, } from '@tabler/icons-react'; import { notifications } from '@mantine/notifications'; import { useAuthStore } from '../../stores/authStore'; @@ -15,10 +15,39 @@ import { PasskeySettings } from './PasskeySettings'; import { LinkedAccounts } from './LinkedAccounts'; import api from '../../services/api'; +interface SubscriptionInfo { + plan: string; + planName: string; + billingInterval: string; + status: string; + collectionMethod: string; + trialEndsAt: string | null; + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; +} + +const statusColors: Record = { + active: 'green', + trial: 'blue', + past_due: 'orange', + archived: 'red', + suspended: 'red', +}; + export function SettingsPage() { const { user, currentOrg } = useAuthStore(); const { compactView, toggleCompactView } = usePreferencesStore(); const [loggingOutAll, setLoggingOutAll] = useState(false); + const [subscription, setSubscription] = useState(null); + const [subLoading, setSubLoading] = useState(true); + const [portalLoading, setPortalLoading] = useState(false); + + useEffect(() => { + api.get('/billing/subscription') + .then(({ data }) => setSubscription(data)) + .catch(() => { /* billing not configured or no subscription */ }) + .finally(() => setSubLoading(false)); + }, []); const handleLogoutEverywhere = async () => { setLoggingOutAll(true); @@ -32,6 +61,31 @@ export function SettingsPage() { } }; + const handleManageBilling = async () => { + setPortalLoading(true); + try { + const { data } = await api.post('/billing/portal'); + if (data.url) { + window.location.href = data.url; + } + } catch { + notifications.show({ message: 'Unable to open billing portal', color: 'red' }); + } finally { + setPortalLoading(false); + } + }; + + const formatInterval = (interval: string) => { + return interval === 'year' ? 'Annual' : 'Monthly'; + }; + + const formatDate = (iso: string | null) => { + if (!iso) return null; + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', + }); + }; + return (
@@ -63,6 +117,73 @@ export function SettingsPage() { + {/* Billing / Subscription */} + + + + + +
+ Billing + Subscription and payment +
+
+ {subLoading ? ( + + ) : subscription ? ( + + + Plan + + {subscription.planName} + {formatInterval(subscription.billingInterval)} + + + + Status + + {subscription.status === 'past_due' ? 'Past Due' : subscription.status} + {subscription.cancelAtPeriodEnd ? ' (Canceling)' : ''} + + + {subscription.trialEndsAt && subscription.status === 'trial' && ( + + Trial Ends + {formatDate(subscription.trialEndsAt)} + + )} + {subscription.currentPeriodEnd && subscription.status !== 'trial' && ( + + Current Period Ends + {formatDate(subscription.currentPeriodEnd)} + + )} + {subscription.collectionMethod === 'send_invoice' && ( + + Payment + Invoice / ACH + + )} + + + ) : ( + No active subscription + )} +
+ {/* User Profile */} @@ -108,7 +229,7 @@ export function SettingsPage() { Version - 2026.03.17 + 2026.03.18 API