feat: add annual billing, free trial, upgrade/downgrade, and ACH invoice support

- 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>
This commit is contained in:
2026-03-18 08:04:51 -04:00
parent 5845334454
commit a996208cb8
12 changed files with 1241 additions and 507 deletions

View File

@@ -13,6 +13,30 @@ AI_MODEL=qwen/qwen3.5-397b-a17b
# Set to 'true' to enable detailed AI prompt/response logging # Set to 'true' to enable detailed AI prompt/response logging
AI_DEBUG=false 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 APM — set ENABLED=true and provide your license key to activate
NEW_RELIC_ENABLED=false NEW_RELIC_ENABLED=false
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here

View File

@@ -1,8 +1,8 @@
# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide # HOA LedgerIQ -- Payment, Onboarding & Authentication Guide
> **Version:** 2026.03.17 > **Version:** 2026.03.18
> **Last updated:** March 17, 2026 > **Last updated:** March 18, 2026
> **Migration:** `db/migrations/015-saas-onboarding-auth.sql` > **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) 1. [High-Level Flow](#1-high-level-flow)
2. [Stripe Billing & Checkout](#2-stripe-billing--checkout) 2. [Stripe Billing & Checkout](#2-stripe-billing--checkout)
3. [Provisioning Pipeline](#3-provisioning-pipeline) 3. [14-Day Free Trial](#3-14-day-free-trial)
4. [Account Activation (Magic Link)](#4-account-activation-magic-link) 4. [Monthly / Annual Billing](#4-monthly--annual-billing)
5. [Guided Onboarding Checklist](#5-guided-onboarding-checklist) 5. [Provisioning Pipeline](#5-provisioning-pipeline)
6. [Authentication & Sessions](#6-authentication--sessions) 6. [Account Activation (Magic Link)](#6-account-activation-magic-link)
7. [Multi-Factor Authentication (TOTP)](#7-multi-factor-authentication-totp) 7. [Guided Onboarding Checklist](#7-guided-onboarding-checklist)
8. [Single Sign-On (SSO)](#8-single-sign-on-sso) 8. [Subscription Management & Upgrade/Downgrade](#8-subscription-management--upgradedowngrade)
9. [Passkeys (WebAuthn)](#9-passkeys-webauthn) 9. [ACH / Invoice Billing](#9-ach--invoice-billing)
10. [Environment Variables Reference](#10-environment-variables-reference) 10. [Access Control & Grace Periods](#10-access-control--grace-periods)
11. [Manual Intervention & Ops Tasks](#11-manual-intervention--ops-tasks) 11. [Authentication & Sessions](#11-authentication--sessions)
12. [What's Stubbed vs. Production-Ready](#12-whats-stubbed-vs-production-ready) 12. [Multi-Factor Authentication (TOTP)](#12-multi-factor-authentication-totp)
13. [API Endpoint Reference](#13-api-endpoint-reference) 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 Visitor hits /pricing
| |
v 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 v
POST /api/billing/create-checkout-session POST /api/billing/start-trial (no card required)
| |
v 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 v
Stripe fires `checkout.session.completed` webhook Frontend navigates to /onboarding/pending?session_id=xxx
(polls GET /api/billing/status every 3s)
| |
v 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 Status returns "active" -> user is redirected to /login
| |
v v
User clicks activation link from "email" (logged to console + DB) User clicks activation link from email
| |
v v
GET /activate?token=xxx -> validates token GET /activate?token=xxx -> validates token
@@ -61,184 +64,295 @@ POST /activate -> sets password + name, issues session
Redirect to /onboarding (4-step guided wizard) Redirect to /onboarding (4-step guided wizard)
| |
v 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 ## 2. Stripe Billing & Checkout
### Plans ### Plans & Pricing
| Plan ID | Name | Price | Unit Limit | | Plan | Monthly | Annual (25% off) | Unit Limit |
|---------------|--------------|---------|------------| |------|---------|-------------------|------------|
| `starter` | Starter | $29/mo | 50 units | | Starter | $29/mo | $261/yr ($21.75/mo) | 50 units |
| `professional` | Professional | $79/mo | 200 units | | Professional | $79/mo | $711/yr ($59.25/mo) | 200 units |
| `enterprise` | Enterprise | $199/mo | Unlimited | | Enterprise | Custom | Custom | Unlimited |
### Checkout Flow ### Stripe Products & Prices
1. **Frontend** (`PricingPage.tsx`): User enters email + business name, selects a plan. Each plan has **two Stripe Prices** (monthly and annual):
2. **API call**: `POST /api/billing/create-checkout-session`
- Body: `{ planId, email?, businessName? }` | Env Variable | Description |
- Returns: `{ url }` (Stripe hosted checkout URL) |-------------|-------------|
- No auth required. | `STRIPE_STARTER_MONTHLY_PRICE_ID` | Starter monthly recurring price |
3. **Redirect**: Frontend does `window.location.href = url` to send user to Stripe. | `STRIPE_STARTER_ANNUAL_PRICE_ID` | Starter annual recurring price |
4. **On success**: Stripe redirects to `/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`. | `STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID` | Professional monthly recurring price |
5. **On cancel**: Stripe redirects back to `/pricing`. | `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 ### Webhook Events Handled
The webhook endpoint is `POST /api/webhooks/stripe`.
| Event | Action | | Event | Action |
|-------|--------| |-------|--------|
| `checkout.session.completed` | Triggers full provisioning pipeline | | `checkout.session.completed` | Triggers full provisioning pipeline (card-required flow) |
| `invoice.payment_succeeded` | Sets org status to `active` (handles reactivation after failed payment) | | `invoice.payment_succeeded` | Sets org status to `active` (reactivation after trial/past_due) |
| `invoice.payment_failed` | Sends payment-failed "email" (stubbed) | | `invoice.payment_failed` | Sets org to `past_due`, sends payment-failed email |
| `customer.subscription.deleted` | Sets org status to `archived` | | `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). 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: 1. **Create organization** in `shared.organizations` with:
- `name` = business name from checkout metadata - `name` = business name from signup
- `schema_name` = `tenant_{random_12_chars}` - `schema_name` = `tenant_{random_12_chars}`
- `status` = `active` - `status` = `trial` (for trial) or `active` (for card checkout)
- `plan_level` = selected plan - `plan_level` = selected plan
- `billing_interval` = `month` or `year`
- `stripe_customer_id` + `stripe_subscription_id` - `stripe_customer_id` + `stripe_subscription_id`
- `trial_ends_at` (if trial)
- Uses `ON CONFLICT (stripe_customer_id)` for idempotency - Uses `ON CONFLICT (stripe_customer_id)` for idempotency
2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`: 2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`
- Runs the full tenant DDL (accounts, journal entries, etc.) 3. **Create or find user** in `shared.users` by email
- Skips if schema already exists 4. **Create membership** in `shared.user_organizations` (role: `president`)
5. **Generate invite token** (JWT, 72-hour expiry)
3. **Create or find user** in `shared.users` by email: 6. **Send activation email** with link to set password
- New users are created with `is_email_verified = false` and no password 7. **Initialize onboarding** progress row
- Existing users are reused (linked to new org)
4. **Create membership** in `shared.user_organizations`:
- Role: `president`
- Idempotent via `ON CONFLICT DO NOTHING`
5. **Generate invite token** (JWT signed with `INVITE_TOKEN_SECRET`, 72-hour expiry):
- SHA-256 hash stored in `shared.invite_tokens`
- Raw token used in activation URL
6. **Send activation "email"** (stubbed -- see section 12):
- Logged to console and `shared.email_log` table
- Contains activation URL: `{APP_URL}/activate?token={jwt}`
7. **Initialize onboarding** progress row in `shared.onboarding_progress`
### Provisioning Status Polling ### Provisioning Status Polling
`GET /api/billing/status?session_id=xxx` (no auth required) `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 - `not_configured` -- Stripe not set up
- `pending` -- no customer ID yet - `pending` -- no customer ID yet
- `provisioning` -- org exists but not active yet - `provisioning` -- org exists but not ready
- `active` -- ready to go - `active` -- ready (includes `trial` status)
The `OnboardingPendingPage` polls this every 3 seconds and redirects to `/login` once active.
--- ---
## 4. Account Activation (Magic Link) ## 6. Account Activation (Magic Link)
### Validate Token ### Validate Token
`GET /api/auth/activate?token=xxx` (no auth required) `GET /api/auth/activate?token=xxx` -- returns `{ valid, email, orgName, orgId, userId }`
- Verifies JWT signature (using `INVITE_TOKEN_SECRET`)
- Checks `shared.invite_tokens` for existence, expiration, and prior use
- Returns: `{ valid, email, orgName, orgId, userId }`
### Activate Account ### Activate Account
`POST /api/auth/activate` (no auth required) `POST /api/auth/activate` -- body `{ token, password, fullName }` -- sets password, issues session
- Body: `{ token, password, fullName }`
- Password must be >= 8 characters
- Sets `password_hash`, `first_name`, `last_name`, `is_email_verified = true`
- Marks invite token as used (`used_at = NOW()`)
- Issues full session (access token + refresh token cookie)
- Frontend redirects to `/onboarding`
### Frontend (ActivatePage.tsx)
- Validates token on mount
- Shows password setup form with strength indicator (color-coded bar)
- On success: stores auth in Zustand and navigates to `/onboarding`
--- ---
## 5. Guided Onboarding Checklist ## 7. Guided Onboarding Checklist
### Required Steps
| Step Key | UI Label | Description | | Step Key | UI Label | Description |
|-----------------|----------------|-------------| |----------|----------|-------------|
| `profile` | Profile | Set up user profile | | `profile` | Profile | Set up user profile |
| `workspace` | Workspace | Configure organization settings | | `workspace` | Workspace | Configure organization settings |
| `invite_member` | Invite Member | Invite at least one team member | | `invite_member` | Invite Member | Invite at least one team member |
| `first_workflow` | First Account | Create the first chart-of-accounts entry | | `first_workflow` | First Account | Create the first chart-of-accounts entry |
### API ---
- `GET /api/onboarding/progress` (auth required): Returns `{ completedSteps[], completedAt, requiredSteps[] }` ## 8. Subscription Management & Upgrade/Downgrade
- `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). ### Stripe Customer Portal
### Frontend (OnboardingPage.tsx) 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
- Mantine Stepper with 4 steps ### What Users Can Do in the Portal
- Each step calls `PATCH /onboarding/progress` on completion
- Celebration screen shown when all steps are done - **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
}
```
--- ---
## 6. Authentication & Sessions ## 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 Architecture
| Token | Type | Lifetime | Storage | | Token | Type | Lifetime | Storage |
|-------|------|----------|---------| |-------|------|----------|---------|
| Access token | JWT | 1 hour | Frontend Zustand store (memory/localStorage) | | Access token | JWT | 1 hour | Frontend Zustand store |
| Refresh token | Opaque (base64url, 64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) | | Refresh token | Opaque (64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) |
| MFA challenge | JWT | 5 minutes | Frontend state (in-memory only) | | MFA challenge | JWT | 5 minutes | Frontend state |
| Invite/activation | JWT | 72 hours | URL query parameter | | 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 ### Session Endpoints
| Method | Path | Auth | Description | | 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/login` | No | Email + password login |
| `POST` | `/api/auth/register` | No | Create account | | `POST` | `/api/auth/register` | No | Create account |
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token | | `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
| `POST` | `/api/auth/logout` | Cookie | Revoke current refresh token | | `POST` | `/api/auth/logout` | Cookie | Revoke current session |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all user sessions | | `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
| `POST` | `/api/auth/switch-org` | JWT | Switch org context (new tokens) | | `POST` | `/api/auth/switch-org` | JWT | Switch organization |
--- ---
## 7. Multi-Factor Authentication (TOTP) ## 12. Multi-Factor Authentication (TOTP)
### Setup Flow
1. User goes to Settings > Security > Two-Factor Auth tab
2. `POST /api/auth/mfa/setup` -- returns `{ qrCodeDataUrl, secret, uri }`
3. User scans QR code in authenticator app (Google Authenticator, Authy, etc.)
4. User enters 6-digit code to confirm
5. `POST /api/auth/mfa/enable` with `{ token }` -- returns `{ recoveryCodes[] }`
6. **User must save their 10 recovery codes** (displayed once, bcrypt-hashed in DB)
### Login with MFA
1. `POST /api/auth/login` returns `{ mfaRequired: true, mfaToken }` instead of session
2. Frontend shows 6-digit PIN input (or recovery code input)
3. `POST /api/auth/mfa/verify` with `{ mfaToken, token, useRecovery? }`
4. On success: full session issued (access token + refresh cookie)
### Recovery Codes
- 10 codes generated on MFA enable
- Each code is single-use (removed from array after verification)
- Codes are bcrypt-hashed in `shared.users.recovery_codes` (JSON array)
### MFA Endpoints ### MFA Endpoints
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR + secret | | `POST` | `/api/auth/mfa/setup` | JWT | Generate QR code + secret |
| `POST` | `/api/auth/mfa/enable` | JWT | Verify code and enable MFA | | `POST` | `/api/auth/mfa/enable` | JWT | Enable MFA with TOTP code |
| `POST` | `/api/auth/mfa/verify` | No (uses mfaToken) | Verify during login | | `POST` | `/api/auth/mfa/verify` | mfaToken | Verify during login |
| `POST` | `/api/auth/mfa/disable` | JWT | Disable MFA (requires password) | | `POST` | `/api/auth/mfa/disable` | JWT | Disable (requires password) |
| `GET` | `/api/auth/mfa/status` | JWT | Check if MFA is enabled | | `GET` | `/api/auth/mfa/status` | JWT | Check MFA status |
### Tech Stack
- Library: `otplib` v4 (`generateSecret`, `generateURI`, `verifySync`)
- QR codes: `qrcode` package (data URL output)
- Recovery codes: `crypto.randomBytes` + `bcryptjs`
--- ---
## 8. Single Sign-On (SSO) ## 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 | SSO providers are conditionally loaded based on env vars.
|----------|---------|-------------------|
| Google | `passport-google-oauth20` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` |
| Microsoft/Azure AD | `passport-azure-ad` | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_CALLBACK_URL` |
SSO providers are **conditionally loaded** -- they only appear on the login page if their env vars are configured. The `GET /api/auth/sso/providers` endpoint returns `{ google: boolean, azure: boolean }`.
### SSO Login Flow
1. Frontend redirects to `/api/auth/google` or `/api/auth/azure`
2. Passport handles OAuth redirect to provider
3. Provider redirects back to `/api/auth/{provider}/callback`
4. Backend creates or links user via `SsoService.findOrCreateSsoUser()`
5. Session tokens issued, redirect to `/sso-callback?token={accessToken}`
### Account Linking
- SSO fields stored on `shared.users`: `sso_provider`, `sso_id`
- If email matches existing user, SSO is auto-linked on first login
- Users can unlink: `DELETE /api/auth/sso/unlink/:provider`
### SSO Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/auth/sso/providers` | No | List configured providers |
| `GET` | `/api/auth/google/callback` | No (OAuth) | Google callback |
| `GET` | `/api/auth/azure/callback` | No (OAuth) | Azure callback |
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO provider |
--- ---
## 9. Passkeys (WebAuthn) ## 14. Passkeys (WebAuthn)
### Registration Flow (authenticated user)
1. `POST /api/auth/passkeys/register-options` -- returns WebAuthn creation options
2. Browser `navigator.credentials.create()` via `@simplewebauthn/browser`
3. `POST /api/auth/passkeys/register` with `{ response, deviceName? }`
4. Credential stored in `shared.user_passkeys`
### Login Flow (unauthenticated)
1. `POST /api/auth/passkeys/login-options` with `{ email? }` -- returns assertion options
2. Browser `navigator.credentials.get()` via `@simplewebauthn/browser`
3. `POST /api/auth/passkeys/login` with `{ response, challenge }`
4. Full session issued on success
### Passkey Endpoints
| Method | Path | Auth | Description | | 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/register` | JWT | Complete registration |
| `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options | | `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options |
| `POST` | `/api/auth/passkeys/login` | No | Authenticate with passkey | | `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 | | `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) ### Stripe (Required for billing)
| Variable | Example | Description | | Variable | Description |
|----------|---------|-------------| |----------|-------------|
| `STRIPE_SECRET_KEY` | `sk_test_...` | Stripe secret key. Must NOT contain "placeholder" to activate. | | `STRIPE_SECRET_KEY` | Stripe secret key. Must NOT contain "placeholder" to activate. |
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Webhook endpoint signing secret | | `STRIPE_WEBHOOK_SECRET` | Webhook endpoint signing secret |
| `STRIPE_STARTER_PRICE_ID` | `price_...` | Stripe Price ID for Starter plan | | `STRIPE_STARTER_MONTHLY_PRICE_ID` | Stripe Price ID for Starter monthly |
| `STRIPE_PROFESSIONAL_PRICE_ID` | `price_...` | Stripe Price ID for Professional plan | | `STRIPE_STARTER_ANNUAL_PRICE_ID` | Stripe Price ID for Starter annual |
| `STRIPE_ENTERPRISE_PRICE_ID` | `price_...` | Stripe Price ID for Enterprise plan | | `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 | | `REQUIRE_PAYMENT_METHOD_FOR_TRIAL` | `false` | Set to `true` to require card upfront via Stripe Checkout |
| `GOOGLE_CLIENT_SECRET` | `GOCSPX-...` | Google OAuth client secret |
| `GOOGLE_CALLBACK_URL` | `http://localhost/api/auth/google/callback` | OAuth redirect URI | ### SSO (Optional)
| `AZURE_CLIENT_ID` | `uuid` | Azure AD application (client) ID |
| `AZURE_CLIENT_SECRET` | `...` | Azure AD client secret | | Variable | Description |
| `AZURE_TENANT_ID` | `uuid` | Azure AD tenant (directory) ID | |----------|-------------|
| `AZURE_CALLBACK_URL` | `http://localhost/api/auth/azure/callback` | OAuth redirect URI | | `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 ### WebAuthn / Passkeys
| Variable | Default | Description | | 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 | | `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin |
### Other ### Other
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for signing invite/activation JWTs. **Change in production.** | | `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for invite/activation JWTs |
| `APP_URL` | `http://localhost` | Base URL for generated links (activation emails, Stripe redirects) | | `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: 1. **Create Products and Prices** for each plan:
- Create 3 products (Starter, Professional, Enterprise) - Starter: monthly ($29/mo recurring) + annual ($261/yr recurring)
- Create monthly recurring prices for each - Professional: monthly ($79/mo recurring) + annual ($711/yr recurring)
- Copy the Price IDs into `STRIPE_STARTER_PRICE_ID`, etc. - Enterprise: monthly + annual (custom pricing)
- Copy all Price IDs to env vars
2. **Configure the Stripe webhook** in the Stripe Dashboard: 2. **Configure Stripe Webhook** endpoint:
- Endpoint URL: `https://yourdomain.com/api/webhooks/stripe` - URL: `https://yourdomain.com/api/webhooks/stripe`
- Events to subscribe: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted` - Events: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`, `customer.subscription.trial_will_end`, `customer.subscription.updated`
- Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`
3. **Replace the email stub** with a real provider: 3. **Configure Stripe Customer Portal**:
- `backend/src/modules/email/email.service.ts` currently logs to console + DB - Enable plan switching (allow switching between monthly and annual prices)
- Swap in Resend, SendGrid, SES, or your preferred provider - Enable payment method updates
- The four email methods to implement: `sendActivationEmail`, `sendWelcomeEmail`, `sendPaymentFailedEmail`, `sendInviteMemberEmail` - Enable cancellation
- Enable invoice history
4. **Set production secrets**: 4. **Set production secrets**: `INVITE_TOKEN_SECRET`, `JWT_SECRET`, `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_ORIGIN`
- `INVITE_TOKEN_SECRET` -- use a strong random string (not `dev-invite-secret`)
- `JWT_SECRET` -- already required, verify it's strong
- `WEBAUTHN_RP_ID` and `WEBAUTHN_RP_ORIGIN` -- set to your production domain
5. **Configure SSO providers** (if desired): 5. **Configure SSO providers** (optional)
- Register apps in Google Cloud Console and/or Azure AD
- Set the callback URLs to your production domain
- Add client IDs and secrets to env vars
6. **Set up the Stripe Customer Portal** in Stripe Dashboard:
- Configure allowed actions (cancel, upgrade/downgrade, payment method updates)
- The `/api/billing/portal` endpoint needs the org-context customer ID lookup completed
### Ongoing Ops ### 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: - **Refresh token cleanup**: Schedule `RefreshTokenService.cleanupExpired()` periodically
- Add a cron job / scheduled task that calls it periodically - **Monitor `shared.email_log`**: Check for failed email deliveries
- Or add a NestJS `@Cron()` decorator (requires `@nestjs/schedule`) - **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: ### Finding activation URLs (dev/testing)
- 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`)
9. **Finding activation URLs manually** (dev/testing): ```sql
```sql SELECT to_email, metadata->>'activationUrl' AS url, sent_at
SELECT to_email, metadata->>'activationUrl' AS url, sent_at FROM shared.email_log
FROM shared.email_log WHERE template = 'activation'
WHERE template = 'activation' ORDER BY sent_at DESC
ORDER BY sent_at DESC LIMIT 10;
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`
--- ---
## 12. What's Stubbed vs. Production-Ready ## 17. What's Stubbed vs. Production-Ready
| Component | Status | Notes | | Component | Status | Notes |
|-----------|--------|-------| |-----------|--------|-------|
| Stripe Checkout | **Ready** (test mode) | Switch to live keys for production | | Stripe Checkout (card-required flow) | **Ready** (test mode) | Switch to live keys for production |
| Stripe Webhooks | **Ready** | Signature verification, idempotency, event dispatch all implemented | | Stripe Trial (no-card flow) | **Ready** (test mode) | Creates customer + subscription server-side |
| Stripe Customer Portal | **Stubbed** | Endpoint exists but needs org-context customer ID lookup | | Stripe Webhooks | **Ready** | All 6 events handled with idempotency |
| Provisioning (org + schema + user) | **Ready** | Inline (synchronous). Consider BullMQ queue for production scale. | | Stripe Customer Portal | **Ready** | Full org-context customer ID lookup implemented |
| Email service | **Stubbed** | Logs to console + `shared.email_log`. Replace with real SMTP/API provider. | | Monthly/Annual Pricing | **Ready** | Toggle on pricing page, 6 Stripe Price IDs |
| Activation (magic link) | **Ready** | Works end-to-end (token generation, validation, password set, session issue) | | ACH/Invoice Billing | **Ready** | Admin endpoint switches collection method |
| Onboarding checklist | **Ready** | Server-side progress tracking, step completion, UI wizard | | Provisioning | **Ready** | Inline, supports both trial and active status |
| Refresh tokens | **Ready** | Creation, validation, revocation, cleanup method (needs scheduling) | | Email service | **Ready** (with Resend) | Falls back to stub logging if not configured |
| TOTP MFA | **Ready** | Setup, enable, verify, recovery codes, disable | | Trial emails | **Ready** | Trial-ending and trial-expired templates |
| SSO (Google) | **Ready** (needs keys) | Conditional loading, user creation/linking | | Access control (past_due) | **Ready** | Read-only grace period for failed payments |
| SSO (Azure AD) | **Ready** (needs keys) | Uses deprecated `passport-azure-ad` (works, consider `@azure/msal-node`) | | Activation (magic link) | **Ready** | Full end-to-end flow |
| Passkeys (WebAuthn) | **Ready** | Registration, authentication, removal with lockout protection | | Onboarding checklist | **Ready** | Server-side progress tracking |
| Resend activation | **Stubbed** | Always returns success, no actual email sent | | 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 | | 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 | | `POST` | `/api/webhooks/stripe` | Stripe sig | Webhook receiver |
| `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status | | `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status |
| `POST` | `/api/billing/portal` | JWT | Stripe Customer Portal (stubbed) | | `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 ### Auth
@@ -517,40 +546,10 @@ SSO providers are **conditionally loaded** -- they only appear on the login page
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions | | `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/activate` | No | Set password + activate |
| `POST` | `/api/auth/resend-activation` | No | Resend activation (stubbed) | | `POST` | `/api/auth/resend-activation` | No | Resend activation email |
| `GET` | `/api/auth/profile` | JWT | Get user profile | | `GET` | `/api/auth/profile` | JWT | Get user profile |
| `POST` | `/api/auth/switch-org` | JWT | Switch organization | | `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 ### Onboarding
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
@@ -560,17 +559,29 @@ SSO providers are **conditionally loaded** -- they only appear on the login page
--- ---
## Database Tables Added (Migration 015) ## Database Tables & Columns
### Tables Added (Migration 015)
| Table | Purpose | | Table | Purpose |
|-------|---------| |-------|---------|
| `shared.refresh_tokens` | Stores SHA-256 hashed refresh tokens with expiry/revocation | | `shared.refresh_tokens` | Hashed refresh tokens with expiry/revocation |
| `shared.stripe_events` | Idempotency ledger for Stripe webhook events | | `shared.stripe_events` | Idempotency ledger for Stripe webhooks |
| `shared.invite_tokens` | Tracks activation/invite magic links | | `shared.invite_tokens` | Activation/invite magic links |
| `shared.onboarding_progress` | Per-org onboarding step completion | | `shared.onboarding_progress` | Per-org onboarding step completion |
| `shared.user_passkeys` | WebAuthn credential storage | | `shared.user_passkeys` | WebAuthn credentials |
| `shared.email_log` | Stubbed email audit trail | | `shared.email_log` | Email audit trail |
Columns added to existing tables: ### Columns Added to `shared.organizations`
- `shared.organizations`: `stripe_customer_id`, `stripe_subscription_id`, `trial_ends_at`
- `shared.users`: `totp_verified_at`, `recovery_codes`, `webauthn_challenge` | 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`

View File

@@ -30,6 +30,13 @@ export class WriteAccessGuard implements CanActivate {
throw new ForbiddenException('Read-only users cannot modify data'); 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; return true;
} }
} }

View File

@@ -9,6 +9,7 @@ export interface TenantRequest extends Request {
orgId?: string; orgId?: string;
userId?: string; userId?: string;
userRole?: string; userRole?: string;
orgPastDue?: boolean;
} }
@Injectable() @Injectable()
@@ -41,6 +42,10 @@ export class TenantMiddleware implements NestMiddleware {
}); });
return; return;
} }
// past_due: allow through with read-only flag (WriteAccessGuard enforces)
if (orgInfo.status === 'past_due') {
req.orgPastDue = true;
}
req.tenantSchema = orgInfo.schemaName; req.tenantSchema = orgInfo.schemaName;
} }
req.orgId = decoded.orgId; req.orgId = decoded.orgId;

View File

@@ -1,34 +1,63 @@
import { import {
Controller, Controller,
Post, Post,
Put,
Get, Get,
Body, Body,
Param,
Query, Query,
Req, Req,
UseGuards, UseGuards,
RawBodyRequest, RawBodyRequest,
BadRequestException, BadRequestException,
ForbiddenException,
Request, Request,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { Request as ExpressRequest } from 'express'; import { Request as ExpressRequest } from 'express';
import { DataSource } from 'typeorm';
import { BillingService } from './billing.service'; import { BillingService } from './billing.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('billing') @ApiTags('billing')
@Controller() @Controller()
export class BillingController { 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') @Post('billing/create-checkout-session')
@ApiOperation({ summary: 'Create a Stripe Checkout Session' }) @ApiOperation({ summary: 'Create a Stripe Checkout Session' })
@Throttle({ default: { limit: 10, ttl: 60000 } }) @Throttle({ default: { limit: 10, ttl: 60000 } })
async createCheckout( 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'); 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') @Post('webhooks/stripe')
@@ -42,22 +71,63 @@ export class BillingController {
} }
@Get('billing/status') @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) { async getStatus(@Query('session_id') sessionId: string) {
if (!sessionId) throw new BadRequestException('session_id required'); if (!sessionId) throw new BadRequestException('session_id required');
return this.billingService.getProvisioningStatus(sessionId); 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') @Post('billing/portal')
@ApiOperation({ summary: 'Create Stripe Customer Portal session' }) @ApiOperation({ summary: 'Create Stripe Customer Portal session' })
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
async createPortal(@Request() req: any) { async createPortal(@Request() req: any) {
// Lookup the org's stripe_customer_id
// Only allow president or superadmin
const orgId = req.user.orgId; const orgId = req.user.orgId;
if (!orgId) throw new BadRequestException('No organization context'); if (!orgId) throw new BadRequestException('No organization context');
// For now, we'd look this up from the org return this.billingService.createPortalSession(orgId);
throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context'); }
// ─── 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 };
} }
} }

View File

@@ -14,12 +14,15 @@ const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
enterprise: { name: 'Enterprise', unitLimit: 999999 }, enterprise: { name: 'Enterprise', unitLimit: 999999 },
}; };
type BillingInterval = 'month' | 'year';
@Injectable() @Injectable()
export class BillingService { export class BillingService {
private readonly logger = new Logger(BillingService.name); private readonly logger = new Logger(BillingService.name);
private stripe: Stripe | null = null; private stripe: Stripe | null = null;
private webhookSecret: string; private webhookSecret: string;
private priceMap: Record<string, string>; private priceMap: Record<string, { monthly: string; annual: string }>;
private requirePaymentForTrial: boolean;
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
@@ -37,27 +40,118 @@ export class BillingService {
} }
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || ''; this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
this.requirePaymentForTrial =
this.configService.get<string>('REQUIRE_PAYMENT_METHOD_FOR_TRIAL') === 'true';
// Build price map with backward-compat: new monthly vars fall back to old single vars
this.priceMap = { this.priceMap = {
starter: this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '', starter: {
professional: this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '', monthly: this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID')
enterprise: this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '', || this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
annual: this.configService.get<string>('STRIPE_STARTER_ANNUAL_PRICE_ID') || '',
},
professional: {
monthly: this.configService.get<string>('STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID')
|| this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
annual: this.configService.get<string>('STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID') || '',
},
enterprise: {
monthly: this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID')
|| this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
annual: this.configService.get<string>('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. * 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 }> { async createCheckoutSession(
if (!this.stripe) { planId: string,
throw new BadRequestException('Stripe not configured'); billingInterval: BillingInterval = 'month',
} email?: string,
businessName?: string,
): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const priceId = this.priceMap[planId]; const priceId = this.getPriceId(planId, billingInterval);
if (!priceId || priceId.includes('placeholder')) {
throw new BadRequestException(`Invalid plan: ${planId}`);
}
const session = await this.stripe.checkout.sessions.create({ const sessionConfig: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription', mode: 'subscription',
payment_method_types: ['card'], payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
@@ -67,12 +161,28 @@ export class BillingService {
metadata: { metadata: {
plan_id: planId, plan_id: planId,
business_name: businessName || '', 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! }; return { url: session.url! };
} }
// ─── Webhook Handling ───────────────────────────────────────
/** /**
* Handle a Stripe webhook event. * Handle a Stripe webhook event.
*/ */
@@ -117,19 +227,39 @@ export class BillingService {
case 'customer.subscription.deleted': case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription); await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break; 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: default:
this.logger.log(`Unhandled Stripe event: ${event.type}`); 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 }> { async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
if (!this.stripe) return { status: 'not_configured' }; if (!this.stripe) return { status: 'not_configured' };
// Try as checkout session first
let customerId: string | null = null;
try {
const session = await this.stripe.checkout.sessions.retrieve(sessionId); const session = await this.stripe.checkout.sessions.retrieve(sessionId);
const customerId = session.customer as string; 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' }; if (!customerId) return { status: 'pending' };
@@ -139,25 +269,131 @@ export class BillingService {
); );
if (rows.length === 0) return { status: 'provisioning' }; 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' }; 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'); 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({ const session = await this.stripe.billingPortal.sessions.create({
customer: customerId, customer: rows[0].stripe_customer_id,
return_url: `${this.getAppUrl()}/settings`, return_url: `${this.getAppUrl()}/settings`,
}); });
return { url: session.url }; 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<void> {
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<void> { private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const customerId = session.customer as string; const customerId = session.customer as string;
@@ -165,11 +401,27 @@ export class BillingService {
const email = session.customer_email || session.customer_details?.email || ''; const email = session.customer_email || session.customer_details?.email || '';
const planId = session.metadata?.plan_id || 'starter'; const planId = session.metadata?.plan_id || 'starter';
const businessName = session.metadata?.business_name || 'My HOA'; 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}`); this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
try { 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) { } catch (err: any) {
this.logger.error(`Provisioning failed: ${err.message}`, err.stack); this.logger.error(`Provisioning failed: ${err.message}`, err.stack);
} }
@@ -177,10 +429,10 @@ export class BillingService {
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> { private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string; const customerId = invoice.customer as string;
// Activate tenant if it was pending // Activate tenant if it was pending/trial
await this.dataSource.query( await this.dataSource.query(
`UPDATE shared.organizations SET status = 'active', updated_at = NOW() `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], [customerId],
); );
} }
@@ -188,9 +440,17 @@ export class BillingService {
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> { private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string; const customerId = invoice.customer as string;
const rows = await this.dataSource.query( 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], [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) { if (rows.length > 0 && rows[0].email) {
await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization'); 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}`); this.logger.log(`Subscription cancelled for customer ${customerId}`);
} }
private async handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
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<void> {
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. * Full provisioning flow: create org, schema, user, invite token, email.
*/ */
@@ -216,20 +561,26 @@ export class BillingService {
email: string, email: string,
planId: string, planId: string,
businessName: string, businessName: string,
status: 'active' | 'trial' = 'active',
billingInterval: BillingInterval = 'month',
trialEndsAt?: Date,
): Promise<void> { ): Promise<void> {
// 1. Create or upsert organization // 1. Create or upsert organization
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`; const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
const orgRows = await this.dataSource.query( const orgRows = await this.dataSource.query(
`INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email) `INSERT INTO shared.organizations
VALUES ($1, $2, 'active', $3, $4, $5, $6) (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 ON CONFLICT (stripe_customer_id) DO UPDATE SET
stripe_subscription_id = EXCLUDED.stripe_subscription_id, stripe_subscription_id = EXCLUDED.stripe_subscription_id,
plan_level = EXCLUDED.plan_level, 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() updated_at = NOW()
RETURNING id, schema_name`, 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; const orgId = orgRows[0].id;
@@ -285,7 +636,7 @@ export class BillingService {
[orgId], [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 { private getAppUrl(): string {

View File

@@ -96,6 +96,42 @@ export class EmailService {
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl }); await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
} }
async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise<void> {
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: `
<p>Your free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ ends in <strong>${daysRemaining} days</strong>.</p>
<p>To continue using all features without interruption, add a payment method before your trial expires.</p>
<p>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.</p>
`,
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<void> {
const appUrl = this.configService.get<string>('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: `
<p>The free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ has ended.</p>
<p>Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.</p>
`,
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<void> { async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password'; const subject = 'Reset your HOA LedgerIQ password';
const html = this.buildTemplate({ const html = this.buildTemplate({

View File

@@ -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'));

View File

@@ -45,6 +45,13 @@ services:
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-} - STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_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_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback} - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}

View File

@@ -34,6 +34,13 @@ services:
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-} - STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_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_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback} - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}

View File

@@ -1,19 +1,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge, 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'; } from '@mantine/core';
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react'; import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
import logoSrc from '../../assets/logo.png'; import logoSrc from '../../assets/logo.png';
type BillingInterval = 'month' | 'year';
const plans = [ const plans = [
{ {
id: 'starter', id: 'starter',
name: 'Starter', name: 'Starter',
price: '$29', monthlyPrice: 29,
period: '/month', annualPrice: 261, // 29 * 12 * 0.75
description: 'For small communities getting started', description: 'For small communities getting started',
icon: IconRocket, icon: IconRocket,
color: 'blue', color: 'blue',
@@ -29,8 +31,8 @@ const plans = [
{ {
id: 'professional', id: 'professional',
name: 'Professional', name: 'Professional',
price: '$79', monthlyPrice: 79,
period: '/month', annualPrice: 711, // 79 * 12 * 0.75
description: 'For growing HOAs that need full features', description: 'For growing HOAs that need full features',
icon: IconStar, icon: IconStar,
color: 'violet', color: 'violet',
@@ -47,8 +49,8 @@ const plans = [
{ {
id: 'enterprise', id: 'enterprise',
name: 'Enterprise', name: 'Enterprise',
price: 'Custom', monthlyPrice: 0,
period: '', annualPrice: 0,
description: 'For large communities and management firms', description: 'For large communities and management firms',
icon: IconCrown, icon: IconCrown,
color: 'orange', 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() { export function PricingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [businessName, setBusinessName] = useState(''); const [businessName, setBusinessName] = useState('');
const [billingInterval, setBillingInterval] = useState<BillingInterval>('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); setLoading(planId);
setError(''); setError('');
try { try {
const { data } = await api.post('/billing/create-checkout-session', { const { data } = await api.post('/billing/start-trial', {
planId, planId,
email: email || undefined, billingInterval,
businessName: businessName || undefined, email: email.trim(),
businessName: businessName.trim(),
}); });
if (data.url) { if (data.subscriptionId) {
window.location.href = data.url; // Navigate to pending page with subscription ID for polling
navigate(`/onboarding/pending?session_id=${data.subscriptionId}`);
} else { } else {
setError('Unable to create checkout session'); setError('Unable to start trial');
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || 'Failed to start checkout'); setError(err.response?.data?.message || 'Failed to start trial');
} finally { } finally {
setLoading(null); setLoading(null);
} }
@@ -104,20 +130,48 @@ export function PricingPage() {
</Text> </Text>
</Stack> </Stack>
{/* Optional pre-capture fields */} {/* Monthly / Annual Toggle */}
<Center mb="xl">
<Box pos="relative">
<SegmentedControl
value={billingInterval}
onChange={(val) => setBillingInterval(val as BillingInterval)}
data={[
{ label: 'Monthly', value: 'month' },
{ label: 'Annual', value: 'year' },
]}
size="md"
radius="xl"
/>
{billingInterval === 'year' && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: -10, right: -40 }}
>
Save 25%
</Badge>
)}
</Box>
</Center>
{/* Pre-capture fields (required for trial) */}
<Center mb="xl"> <Center mb="xl">
<Group> <Group>
<TextInput <TextInput
placeholder="Email address" placeholder="Email address *"
value={email} value={email}
onChange={(e) => setEmail(e.currentTarget.value)} onChange={(e) => setEmail(e.currentTarget.value)}
style={{ width: 220 }} style={{ width: 220 }}
required
/> />
<TextInput <TextInput
placeholder="HOA / Business name" placeholder="HOA / Business name *"
value={businessName} value={businessName}
onChange={(e) => setBusinessName(e.currentTarget.value)} onChange={(e) => setBusinessName(e.currentTarget.value)}
style={{ width: 220 }} style={{ width: 220 }}
required
/> />
</Group> </Group>
</Center> </Center>
@@ -129,7 +183,9 @@ export function PricingPage() {
)} )}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{plans.map((plan) => ( {plans.map((plan) => {
const price = formatPrice(plan, billingInterval);
return (
<Card <Card
key={plan.id} key={plan.id}
withBorder withBorder
@@ -162,12 +218,23 @@ export function PricingPage() {
</div> </div>
</Group> </Group>
<div>
<Group align="baseline" gap={4}> <Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}> <Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
{plan.externalUrl ? 'Request Quote' : plan.price} {plan.externalUrl ? 'Request Quote' : price.display}
</Text> </Text>
{plan.period && <Text size="sm" c="dimmed">{plan.period}</Text>}
</Group> </Group>
{price.sub && (
<Text size="xs" c="dimmed" mt={2}>
{price.sub}
</Text>
)}
{!plan.externalUrl && billingInterval === 'year' && (
<Text size="xs" c="dimmed" td="line-through" mt={2}>
${plan.monthlyPrice}/mo without annual discount
</Text>
)}
</div>
<List spacing="xs" size="sm" center> <List spacing="xs" size="sm" center>
{plan.features.map((f, i) => ( {plan.features.map((f, i) => (
@@ -198,18 +265,19 @@ export function PricingPage() {
onClick={() => onClick={() =>
plan.externalUrl plan.externalUrl
? window.open(plan.externalUrl, '_blank', 'noopener') ? window.open(plan.externalUrl, '_blank', 'noopener')
: handleSelectPlan(plan.id) : handleStartTrial(plan.id)
} }
> >
{plan.externalUrl ? 'Request Quote' : 'Get Started'} {plan.externalUrl ? 'Request Quote' : 'Start Free Trial'}
</Button> </Button>
</Stack> </Stack>
</Card> </Card>
))} );
})}
</SimpleGrid> </SimpleGrid>
<Text ta="center" size="sm" c="dimmed" mt="xl"> <Text ta="center" size="sm" c="dimmed" mt="xl">
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.
</Text> </Text>
</Container> </Container>
); );

View File

@@ -1,11 +1,11 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider, Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
Tabs, Button, Switch, Tabs, Button, Switch, Loader,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconBuilding, IconUser, IconSettings, IconShieldLock, IconBuilding, IconUser, IconSettings, IconShieldLock,
IconFingerprint, IconLink, IconLogout, IconFingerprint, IconLink, IconLogout, IconCreditCard,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -15,10 +15,39 @@ import { PasskeySettings } from './PasskeySettings';
import { LinkedAccounts } from './LinkedAccounts'; import { LinkedAccounts } from './LinkedAccounts';
import api from '../../services/api'; 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<string, string> = {
active: 'green',
trial: 'blue',
past_due: 'orange',
archived: 'red',
suspended: 'red',
};
export function SettingsPage() { export function SettingsPage() {
const { user, currentOrg } = useAuthStore(); const { user, currentOrg } = useAuthStore();
const { compactView, toggleCompactView } = usePreferencesStore(); const { compactView, toggleCompactView } = usePreferencesStore();
const [loggingOutAll, setLoggingOutAll] = useState(false); const [loggingOutAll, setLoggingOutAll] = useState(false);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(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 () => { const handleLogoutEverywhere = async () => {
setLoggingOutAll(true); 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 ( return (
<Stack> <Stack>
<div> <div>
@@ -63,6 +117,73 @@ export function SettingsPage() {
</Stack> </Stack>
</Card> </Card>
{/* Billing / Subscription */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="teal" variant="light" size={40} radius="md">
<IconCreditCard size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Billing</Text>
<Text c="dimmed" size="sm">Subscription and payment</Text>
</div>
</Group>
{subLoading ? (
<Group justify="center" py="md"><Loader size="sm" /></Group>
) : subscription ? (
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Plan</Text>
<Group gap={4}>
<Badge variant="light">{subscription.planName}</Badge>
<Badge variant="light" color="gray" size="sm">{formatInterval(subscription.billingInterval)}</Badge>
</Group>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Status</Text>
<Badge
color={statusColors[subscription.status] || 'gray'}
variant="light"
>
{subscription.status === 'past_due' ? 'Past Due' : subscription.status}
{subscription.cancelAtPeriodEnd ? ' (Canceling)' : ''}
</Badge>
</Group>
{subscription.trialEndsAt && subscription.status === 'trial' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Trial Ends</Text>
<Text size="sm" fw={500}>{formatDate(subscription.trialEndsAt)}</Text>
</Group>
)}
{subscription.currentPeriodEnd && subscription.status !== 'trial' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Current Period Ends</Text>
<Text size="sm" fw={500}>{formatDate(subscription.currentPeriodEnd)}</Text>
</Group>
)}
{subscription.collectionMethod === 'send_invoice' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Payment</Text>
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
</Group>
)}
<Button
variant="light"
color="teal"
size="sm"
leftSection={<IconCreditCard size={16} />}
onClick={handleManageBilling}
loading={portalLoading}
mt="xs"
>
Manage Billing
</Button>
</Stack>
) : (
<Text size="sm" c="dimmed">No active subscription</Text>
)}
</Card>
{/* User Profile */} {/* User Profile */}
<Card withBorder padding="lg"> <Card withBorder padding="lg">
<Group mb="md"> <Group mb="md">
@@ -108,7 +229,7 @@ export function SettingsPage() {
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text> <Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.03.17</Badge> <Badge variant="light">2026.03.18</Badge>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">API</Text> <Text size="sm" c="dimmed">API</Text>