From 5508e15da14ca47ba8295dc42435b0f2c974568c Mon Sep 17 00:00:00 2001 From: pguerrerox Date: Fri, 22 May 2026 22:55:04 +0000 Subject: [PATCH] feat: launch Stripe billing flows with lifecycle hardening and analytics add Stripe checkout, portal, webhook ingestion, and idempotent event persistence add billing lifecycle state (grace/sync/timeline/admin visibility) and stronger entitlement handling add analytics event tracking and admin summary APIs plus account/pricing UI integration --- .env.example | 1 + CHANGELOG.md | 5 + README.md | 29 ++ TODO-pricing.md | 87 ++-- db/migrations/0004_stripe_payments.sql | 28 ++ .../0005_billing_lifecycle_hardening.sql | 47 +++ db/migrations/0006_analytics_events.sql | 20 + package-lock.json | 18 + package.json | 1 + server/src/account/repository.ts | 2 + server/src/analytics/repository.ts | 105 +++++ server/src/analytics/service.ts | 53 +++ server/src/app.ts | 4 + server/src/billing/enforcement-service.ts | 46 ++ server/src/billing/repository.ts | 284 ++++++++++++- server/src/billing/service.ts | 47 ++- server/src/config/env.ts | 27 ++ server/src/payments/catalog.ts | 106 +++++ server/src/payments/repository.ts | 166 ++++++++ server/src/payments/service.ts | 395 ++++++++++++++++++ server/src/payments/stripe-client.ts | 27 ++ server/src/payments/webhooks.ts | 298 +++++++++++++ server/src/routes/analytics.ts | 92 ++++ server/src/routes/billing.ts | 198 +++++++++ server/src/routes/deep-research.ts | 43 ++ server/src/routes/search-jobs.ts | 43 ++ shared/analytics/events.ts | 30 ++ shared/billing/entitlements.ts | 10 +- shared/billing/lifecycle.ts | 126 ++++++ shared/types.ts | 102 +++++ src/App.tsx | 28 +- src/components/AccountPage.tsx | 362 +++++++++++++++- src/components/PricingCards.tsx | 16 +- src/lib/account.ts | 43 +- src/lib/analytics.ts | 12 + 35 files changed, 2851 insertions(+), 50 deletions(-) create mode 100644 db/migrations/0004_stripe_payments.sql create mode 100644 db/migrations/0005_billing_lifecycle_hardening.sql create mode 100644 db/migrations/0006_analytics_events.sql create mode 100644 server/src/analytics/repository.ts create mode 100644 server/src/analytics/service.ts create mode 100644 server/src/payments/catalog.ts create mode 100644 server/src/payments/repository.ts create mode 100644 server/src/payments/service.ts create mode 100644 server/src/payments/stripe-client.ts create mode 100644 server/src/payments/webhooks.ts create mode 100644 server/src/routes/analytics.ts create mode 100644 server/src/routes/billing.ts create mode 100644 shared/analytics/events.ts create mode 100644 shared/billing/lifecycle.ts create mode 100644 src/lib/analytics.ts diff --git a/.env.example b/.env.example index 32ad99c..dfbfb22 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ APP_PORT="4000" APP_ORIGIN="http://localhost:3000" SESSION_TTL_DAYS="30" GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY" +BILLING_ADMIN_EMAILS="ops@example.com" # Docker Compose database env vars POSTGRES_DB="leads4less" diff --git a/CHANGELOG.md b/CHANGELOG.md index 830d184..5171532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Added workspace-scoped billing foundation storage, repository/service layers, and add-on catalog definitions for billing accounts, usage periods, usage counters, purchases, and balances. - Added backend entitlement enforcement for basic search and deep research routes using shared workspace billing state and reusable usage-cost estimation. - Added workspace-readiness reference material plus billing-aware account usage, add-on, and upgrade surfaces to keep plan promises honest during the workspace migration phase. +- Added Stripe payments foundations with checkout, billing-portal, and webhook processing routes plus idempotent webhook event storage for subscription sync and export-pack fulfillment. +- Added billing lifecycle hardening data and support workflows, including Stripe sync metadata, grace-period support, workspace billing timeline events, and billing-admin workspace visibility endpoints. +- Added billing and revenue instrumentation via a shared analytics event pipeline, API/webhook event emitters, and an admin analytics summary endpoint for pricing conversion, quota pressure, churn, and expansion signals. ### Changed - Normalized the product UI around shared design primitives for buttons, cards, alerts, tabs, and page shells to keep public, auth, research, results, dashboard, map, and account surfaces visually aligned. @@ -21,6 +24,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Expanded the pricing experience with monthly/annual plan toggles, interval-specific pricing cards, and a comparison table derived from canonical billing metadata. - Bootstrapped existing and new workspaces into a pre-payments Starter billing state so usage tracking and enforcement can run before subscription automation exists. - Updated dashboard, map, and results copy to describe saved businesses and research outputs instead of lead-focused terminology. +- Replaced account-page billing placeholders with Stripe-backed upgrade, add-on purchase, and billing-management actions while keeping enterprise on a manual sales path. +- Hardened entitlement enforcement to treat past-due subscriptions as grace-window access before blocking chargeable actions, with clearer lifecycle messaging in account and API responses. ## [2026-05-01] diff --git a/README.md b/README.md index 1e2d881..df7ad5f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ LocaleScope is a React + Vite app for researching local markets, saving business - `DATABASE_URL` - `COOKIE_SECRET` - `GOOGLE_MAPS_SERVER_KEY` + - Stripe env vars below if you want to test billing locally 3. Run the frontend: `npm run dev:web` @@ -35,6 +36,34 @@ If you open the app from another machine on your LAN, set `VITE_API_BASE_URL` an 4. Start the worker: `npm run dev:worker` +## Stripe Billing Setup + +Stripe is now the active payments integration for self-serve subscriptions and one-time export packs. + +Configure these server-side env vars to enable billing routes: + +- `STRIPE_SECRET_KEY` +- `STRIPE_WEBHOOK_SECRET` +- `STRIPE_PRICE_STARTER_MONTHLY` +- `STRIPE_PRICE_STARTER_ANNUAL` +- `STRIPE_PRICE_GROWTH_MONTHLY` +- `STRIPE_PRICE_GROWTH_ANNUAL` +- `STRIPE_PRICE_PRO_MONTHLY` +- `STRIPE_PRICE_PRO_ANNUAL` +- `STRIPE_PRICE_EXPORT_PACK_10K` +- `STRIPE_PRICE_EXPORT_PACK_50K` +- `STRIPE_BILLING_PORTAL_CONFIGURATION_ID` optional +- `BILLING_ADMIN_EMAILS` optional comma-separated allowlist for internal billing admin access + +Notes: + +- The internal catalog in `shared/billing/plans.ts` and `shared/billing/addons.ts` remains canonical. Stripe price IDs are environment-specific mappings only. +- Apply migrations before testing Stripe webhooks so `billing_webhook_events` exists: `npm run migrate` +- Enterprise stays on a manual sales/invoicing path and does not use self-serve checkout. +- Stripe lifecycle hardening now treats `past_due` subscriptions as a grace-window state before chargeable actions hard-block. The default grace window is 7 days. +- Billing return notices now appear on the account page for completed and canceled checkout flows. +- Internal billing support visibility is available through `/api/admin/billing/workspaces` for allowlisted admin emails. + ## Docker Deployment 1. Copy `.env.example` to `.env` and set at least: diff --git a/TODO-pricing.md b/TODO-pricing.md index e877488..51f43a4 100644 --- a/TODO-pricing.md +++ b/TODO-pricing.md @@ -243,7 +243,7 @@ - [x] Add quota warning UX before hard exhaustion. - [x] Keep migration-dependent collaboration messaging honest by surfacing included-but-not-ready capabilities as `Coming soon` instead of pretending they are fully live. - [ ] Future note: the pricing comparison table should stay aligned with workspace-readiness decisions as collaboration and shared asset features move toward workspace ownership. -- [ ] Future note: upgrade CTAs are present, but actual checkout/subscription management should remain tied to the future payments integration step. +- [ ] Future note: upgrade CTAs are present, but actual checkout/subscription management should remain tied to the post-payments hardening step. - [ ] Future note: pricing and account UX should keep users included, workspace limits, and collaboration-adjacent promises explicitly soft-gated until workspace-owned shared data and hard enforcement are ready. - [ ] Future note: replace placeholder upgrade CTAs in the account billing UI with a real upgrade path, pricing-page jump, contact-sales flow, or explicit `coming soon` behavior before broader rollout. @@ -262,32 +262,54 @@ - [ ] Future note: recurring feature add-ons should not be sold until the underlying capabilities and subscription management flows exist end-to-end. ## 10) Payments Integration -- [ ] Choose billing provider (likely Stripe). -- [ ] Map internal SKUs to external billing products/prices. -- [ ] Support subscriptions, annual billing, add-ons, and enterprise/manual invoicing. -- [ ] Define webhook handling for subscription state changes. -- [ ] Define downgrade, cancellation, retry, and grace-period behavior. -- [ ] Add internal admin visibility for billing state. +- [x] Choose billing provider (Stripe). +- [x] Map internal SKUs to external billing products/prices. +- [x] Support subscriptions, annual billing, add-ons, and enterprise/manual invoicing. +- [x] Define webhook handling for subscription state changes. +- [ ] Future note: Stripe is now the active integration path; keep the internal plan/add-on catalog as the canonical packaging source and treat Stripe price IDs as environment-specific mappings. +- [ ] Future note: the current customer-facing integration supports self-serve subscriptions, export-pack checkout, and the Stripe billing portal, while enterprise invoicing remains a manual sales workflow. +- [ ] Future note: webhook idempotency currently relies on the `billing_webhook_events` store; keep Stripe event processing centralized there as billing lifecycle coverage expands. +- [ ] Future note: post-payments hardening should tighten downgrade, cancellation, retry, and grace-period policy before broad rollout so Stripe portal actions and runtime entitlements stay aligned. -## 11) Founder / LTD Strategy -- [ ] Decide whether to launch founder LTD at all. -- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers). -- [ ] Define founder SKUs: - - Founder Plan = $249 one-time - - Founder Pro = $499 one-time -- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API. -- [ ] Define which future features are excluded from LTD plans. +## 11) Post-Payments Hardening & Admin Visibility +- [ ] Define explicit downgrade behavior: + - effective timing for scheduled vs immediate plan changes + - entitlement/usage treatment when the target plan is below current usage + - account messaging for pending downgrade state +- [x] Define explicit cancellation behavior: + - end-of-period vs immediate access policy + - post-cancellation account state and reactivation path + - handling for active add-on balances and usage windows +- [x] Define explicit payment retry and grace-period behavior: + - which Stripe states map to degraded vs blocked access + - grace-period duration and user-facing messaging + - when usage actions should warn, soft-block, or hard-block +- [x] Wire pricing-page CTAs to real billing actions: + - self-serve paid plans -> Stripe checkout + - active paid workspaces -> plan-change or billing-portal path + - enterprise plans -> contact-sales path +- [x] Add account redirect notices after Stripe return: + - successful checkout/portal return confirmation + - canceled or incomplete checkout messaging + - failed or unresolved billing-return guidance +- [x] Expand internal admin billing visibility for operational maintenance and debugging: + - show workspace billing summary with current plan, interval, status, renewal date, cancel-at-period-end, and trial/grace-period state + - show current usage period and counters for research, exports, add-ons, and remaining balances + - show recent Stripe/customer/subscription identifiers and latest webhook processing outcomes + - show recent billing timeline entries for checkout, subscription changes, payment failures, cancellations, and add-on purchases + - flag workspaces with stale billing sync, failed webhook processing, or status mismatches needing support follow-up + - document the minimum admin view(s) needed so support can verify billing state without direct database inspection ## 12) Analytics, Ops, and Revenue Instrumentation -- [ ] Track pricing-page conversion by plan. -- [ ] Track quota exhaustion events. -- [ ] Track upgrade triggers: +- [x] Track pricing-page conversion by plan. +- [x] Track quota exhaustion events. +- [x] Track upgrade triggers: - export limit hit - research limit hit - feature-gate encounter -- [ ] Track add-on attach rate. -- [ ] Track plan mix, churn, expansion revenue, and annual conversion. -- [ ] Add internal dashboards for billing and usage health. +- [x] Track add-on attach rate. +- [x] Track plan mix, churn, expansion revenue, and annual conversion. +- [x] Add internal dashboards for billing and usage health. ## 13) Operational Enforcement Follow-Up - [ ] Add queue prioritization by plan tier. @@ -299,23 +321,34 @@ - [ ] Future note: export enforcement remains deferred until CSV/export generation moves to a backend endpoint. - [ ] Future note: enrichment-route enforcement remains deferred until enrichment actions/routes are implemented. -## 14) Rollout Plan +## 14) Founder / LTD Strategy +- [ ] Decide whether to launch founder LTD at all. +- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers). +- [ ] Define founder SKUs: + - Founder Plan = $249 one-time + - Founder Pro = $499 one-time +- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API. +- [ ] Define which future features are excluded from LTD plans. + +## 15) Rollout Plan - [ ] Phase 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model. - [ ] Phase 2: implement usage ledger and backend enforcement. - [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises. - [ ] Phase 4: update pricing page and account/billing UI based on the workspace/collaboration readiness decisions. - [ ] Phase 5: finalize add-on strategy before wiring payment products. - [ ] Phase 6: integrate payments and subscription lifecycle handling. -- [ ] Phase 7: decide and implement founder/LTD strategy only after the core subscription path is stable. +- [ ] Phase 7: harden post-payments lifecycle handling, wire real billing CTAs, and add pragmatic admin billing visibility before broader commercialization work. - [ ] Phase 8: expand analytics, ops, and revenue instrumentation around the live billing and upgrade flows. - [ ] Phase 9: launch collaboration, API, enrichment, and enterprise features as architecture matures. - [ ] Phase 10: complete deferred operational enforcement work such as queue prioritization, throttling, and backend export enforcement when runtime scale justifies it. +- [ ] Phase 11: decide and implement founder/LTD strategy only after the app/site, billing lifecycle, admin/support visibility, analytics, and broader product maturity work are in place. ## Recommended Execution Order -- [ ] Next: `#10 Payments Integration` -- [ ] Then: `#11 Founder / LTD Strategy` -- [ ] Then: `#12 Analytics, Ops, and Revenue Instrumentation` -- [ ] Keep `#13 Operational Enforcement Follow-Up` deferred until async worker routing, backend exports, or higher-volume execution patterns make it necessary. +- [ ] Next: `#11 Post-Payments Hardening & Admin Visibility` +- [ ] Then: `#13 Analytics, Ops, and Revenue Instrumentation` +- [ ] Then: collaboration, API, enrichment, and enterprise feature maturation from `#15 Rollout Plan` +- [ ] Keep `#14 Operational Enforcement Follow-Up` deferred until async worker routing, backend exports, or higher-volume execution patterns make it necessary. +- [ ] Last: `#12 Founder / LTD Strategy` once the app/site, billing lifecycle, admin/support visibility, analytics, and broader product maturity work are in place. ## Open Questions - [ ] Will research capacity be marketed as runs, credits, or both? diff --git a/db/migrations/0004_stripe_payments.sql b/db/migrations/0004_stripe_payments.sql new file mode 100644 index 0000000..6c07428 --- /dev/null +++ b/db/migrations/0004_stripe_payments.sql @@ -0,0 +1,28 @@ +create table if not exists public.billing_webhook_events ( + id uuid primary key default gen_random_uuid(), + provider text not null check (provider in ('stripe')), + external_event_id text not null, + event_type text not null, + status text not null check (status in ('received', 'processed', 'failed', 'ignored')) default 'received', + workspace_id uuid references public.workspaces (id) on delete set null, + external_customer_ref text, + external_subscription_ref text, + payload_json jsonb not null, + error_message text, + received_at timestamptz not null default now(), + processed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint billing_webhook_events_provider_event_key unique (provider, external_event_id) +); + +create index if not exists billing_webhook_events_status_idx on public.billing_webhook_events (status); +create index if not exists billing_webhook_events_workspace_id_idx on public.billing_webhook_events (workspace_id); +create index if not exists billing_webhook_events_customer_ref_idx on public.billing_webhook_events (external_customer_ref); +create index if not exists billing_webhook_events_subscription_ref_idx on public.billing_webhook_events (external_subscription_ref); + +drop trigger if exists set_billing_webhook_events_updated_at on public.billing_webhook_events; +create trigger set_billing_webhook_events_updated_at +before update on public.billing_webhook_events +for each row +execute function public.set_updated_at(); diff --git a/db/migrations/0005_billing_lifecycle_hardening.sql b/db/migrations/0005_billing_lifecycle_hardening.sql new file mode 100644 index 0000000..a8a2ced --- /dev/null +++ b/db/migrations/0005_billing_lifecycle_hardening.sql @@ -0,0 +1,47 @@ +alter table public.workspace_billing_accounts + add column if not exists grace_period_ends_at timestamptz, + add column if not exists pending_plan_code text, + add column if not exists pending_plan_effective_at timestamptz, + add column if not exists billing_sync_status text not null default 'ok' check (billing_sync_status in ('ok', 'stale', 'error')), + add column if not exists last_stripe_sync_at timestamptz; + +create table if not exists public.workspace_billing_timeline_events ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces (id) on delete cascade, + billing_account_id uuid references public.workspace_billing_accounts (id) on delete set null, + event_type text not null check ( + event_type in ( + 'checkout_completed', + 'subscription_created', + 'subscription_updated', + 'subscription_deleted', + 'invoice_paid', + 'invoice_payment_failed', + 'portal_returned', + 'checkout_returned', + 'addon_purchased', + 'billing_status_changed', + 'plan_change_scheduled' + ) + ), + source text not null check (source in ('stripe', 'app', 'system')), + payload_json jsonb not null default '{}'::jsonb, + external_event_id text, + external_customer_ref text, + external_subscription_ref text, + occurred_at timestamptz not null default now(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists workspace_billing_accounts_pending_plan_idx on public.workspace_billing_accounts (pending_plan_code); +create index if not exists workspace_billing_accounts_sync_status_idx on public.workspace_billing_accounts (billing_sync_status); +create index if not exists workspace_billing_timeline_events_workspace_id_idx on public.workspace_billing_timeline_events (workspace_id, occurred_at desc); +create index if not exists workspace_billing_timeline_events_event_type_idx on public.workspace_billing_timeline_events (event_type); +create index if not exists workspace_billing_timeline_events_subscription_ref_idx on public.workspace_billing_timeline_events (external_subscription_ref); + +drop trigger if exists set_workspace_billing_timeline_events_updated_at on public.workspace_billing_timeline_events; +create trigger set_workspace_billing_timeline_events_updated_at +before update on public.workspace_billing_timeline_events +for each row +execute function public.set_updated_at(); diff --git a/db/migrations/0006_analytics_events.sql b/db/migrations/0006_analytics_events.sql new file mode 100644 index 0000000..f60bd73 --- /dev/null +++ b/db/migrations/0006_analytics_events.sql @@ -0,0 +1,20 @@ +create table if not exists public.analytics_events ( + id uuid primary key default gen_random_uuid(), + event_name text not null, + event_source text not null check (event_source in ('web_app', 'api', 'stripe_webhook', 'system')), + user_id uuid references public.users (id) on delete set null, + workspace_id uuid references public.workspaces (id) on delete set null, + plan_code text, + addon_code text, + resource text, + amount numeric, + currency text, + metadata_json jsonb not null default '{}'::jsonb, + occurred_at timestamptz not null default now(), + created_at timestamptz not null default now() +); + +create index if not exists analytics_events_occurred_at_idx on public.analytics_events (occurred_at desc); +create index if not exists analytics_events_event_name_idx on public.analytics_events (event_name); +create index if not exists analytics_events_workspace_id_idx on public.analytics_events (workspace_id); +create index if not exists analytics_events_plan_code_idx on public.analytics_events (plan_code); diff --git a/package-lock.json b/package-lock.json index 6e1f319..d924bb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "stream-json": "^2.1.0", + "stripe": "^22.1.1", "tailwind-merge": "^3.5.0", "vite": "^6.2.0", "zod": "^4.3.6" @@ -3276,6 +3277,23 @@ "url": "https://github.com/sponsors/uhop" } }, + "node_modules/stripe": { + "version": "22.1.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz", + "integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", diff --git a/package.json b/package.json index 48326da..aa1208b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "stream-json": "^2.1.0", + "stripe": "^22.1.1", "tailwind-merge": "^3.5.0", "vite": "^6.2.0", "zod": "^4.3.6" diff --git a/server/src/account/repository.ts b/server/src/account/repository.ts index cb3b058..46e28e0 100644 --- a/server/src/account/repository.ts +++ b/server/src/account/repository.ts @@ -1,6 +1,7 @@ import type { Pool, PoolClient } from 'pg'; import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js'; import { getWorkspaceBillingState } from '../billing/service.js'; +import { isBillingAdminEmail } from '../config/env.js'; type DbClient = Pool | PoolClient; @@ -164,6 +165,7 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise canManageMembers: workspace.role === 'owner' || workspace.role === 'admin', message: 'Workspace member management is coming soon.', }, + isBillingAdmin: isBillingAdminEmail(user.email), }; } diff --git a/server/src/analytics/repository.ts b/server/src/analytics/repository.ts new file mode 100644 index 0000000..19059dc --- /dev/null +++ b/server/src/analytics/repository.ts @@ -0,0 +1,105 @@ +import type { Pool, PoolClient } from 'pg'; +import type { AnalyticsEventInput } from '../../../shared/analytics/events.js'; +import type { AnalyticsMetricBucket } from '../../../shared/types.js'; + +type DbClient = Pool | PoolClient; + +type AnalyticsBucketRow = { + key: string; + count: string; +}; + +export async function insertAnalyticsEvent(db: DbClient, input: AnalyticsEventInput) { + await db.query( + ` + insert into public.analytics_events ( + event_name, + event_source, + user_id, + workspace_id, + plan_code, + addon_code, + resource, + amount, + currency, + metadata_json, + occurred_at + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, coalesce($11::timestamptz, now())) + `, + [ + input.eventName, + input.eventSource, + input.userId ?? null, + input.workspaceId ?? null, + input.planCode ?? null, + input.addonCode ?? null, + input.resource ?? null, + input.amount ?? null, + input.currency ?? null, + input.metadata ?? {}, + input.occurredAt ?? null, + ], + ); +} + +export function listAnalyticsCountsByEvent(db: DbClient, sinceIso: string): Promise { + return listBuckets(db, sinceIso, 'event_name', `event_name`); +} + +export function listPricingPlanSelectionCounts(db: DbClient, sinceIso: string): Promise { + return listBuckets(db, sinceIso, `coalesce(plan_code, metadata_json->>'planCode', 'unknown')`, `event_name = 'pricing_plan_selected'`); +} + +export function listQuotaExhaustionCounts(db: DbClient, sinceIso: string): Promise { + return listBuckets(db, sinceIso, `coalesce(resource, metadata_json->>'resource', 'unknown')`, `event_name = 'quota_exhausted_blocked'`); +} + +export function listUpgradeTriggerCounts(db: DbClient, sinceIso: string): Promise { + return listBuckets( + db, + sinceIso, + `coalesce(metadata_json->>'denialReason', event_name)`, + `event_name in ('quota_exhausted_blocked', 'feature_gate_encountered')`, + ); +} + +export function listAddonAttachCounts(db: DbClient, sinceIso: string): Promise { + return listBuckets(db, sinceIso, `coalesce(addon_code, metadata_json->>'addonCode', 'unknown')`, `event_name = 'addon_purchase_completed'`); +} + +export function listPlanMixCounts(db: DbClient, sinceIso: string): Promise { + return listBuckets(db, sinceIso, `coalesce(plan_code, metadata_json->>'planCode', 'unknown')`, `event_name in ('checkout_completed', 'plan_changed')`); +} + +export function listChurnSignalCounts(db: DbClient, sinceIso: string): Promise { + return listBuckets(db, sinceIso, `event_name`, `event_name in ('subscription_canceled', 'payment_failed')`); +} + +export function listExpansionSignalCounts(db: DbClient, sinceIso: string): Promise { + return listBuckets(db, sinceIso, `event_name`, `event_name in ('checkout_completed', 'addon_purchase_completed', 'plan_changed')`); +} + +async function listBuckets( + db: DbClient, + sinceIso: string, + keyExpression: string, + filterSql: string, +): Promise { + const result = await db.query( + ` + select ${keyExpression} as key, count(*)::text as count + from public.analytics_events + where occurred_at >= $1 + and ${filterSql} + group by 1 + order by count(*) desc, 1 asc + `, + [sinceIso], + ); + + return result.rows.map((row) => ({ + key: row.key, + count: Number(row.count), + })); +} diff --git a/server/src/analytics/service.ts b/server/src/analytics/service.ts new file mode 100644 index 0000000..644b4e1 --- /dev/null +++ b/server/src/analytics/service.ts @@ -0,0 +1,53 @@ +import type { Pool, PoolClient } from 'pg'; +import type { AnalyticsEventInput } from '../../../shared/analytics/events.js'; +import type { AdminAnalyticsSummary } from '../../../shared/types.js'; +import { + insertAnalyticsEvent, + listAddonAttachCounts, + listChurnSignalCounts, + listExpansionSignalCounts, + listPlanMixCounts, + listPricingPlanSelectionCounts, + listQuotaExhaustionCounts, + listUpgradeTriggerCounts, +} from './repository.js'; + +type DbClient = Pool | PoolClient; + +export async function recordAnalyticsEvent(db: DbClient, input: AnalyticsEventInput) { + await insertAnalyticsEvent(db, input); +} + +export async function getAdminAnalyticsSummary(db: DbClient, days = 30): Promise { + const now = Date.now(); + const clampedDays = Math.min(Math.max(days, 7), 90); + const sinceIso = new Date(now - clampedDays * 24 * 60 * 60 * 1000).toISOString(); + + const [ + pricingConversionByPlan, + quotaExhaustionEvents, + upgradeTriggers, + addonAttach, + planMix, + churnSignals, + expansionSignals, + ] = await Promise.all([ + listPricingPlanSelectionCounts(db, sinceIso), + listQuotaExhaustionCounts(db, sinceIso), + listUpgradeTriggerCounts(db, sinceIso), + listAddonAttachCounts(db, sinceIso), + listPlanMixCounts(db, sinceIso), + listChurnSignalCounts(db, sinceIso), + listExpansionSignalCounts(db, sinceIso), + ]); + + return { + pricingConversionByPlan, + quotaExhaustionEvents, + upgradeTriggers, + addonAttach, + planMix, + churnSignals, + expansionSignals, + }; +} diff --git a/server/src/app.ts b/server/src/app.ts index e58afb5..b033d75 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,8 +5,10 @@ import { getEnv } from './config/env.js'; import { deepResearchRoutes } from './routes/deep-research.js'; import { authRoutes } from './routes/auth.js'; import { accountRoutes } from './routes/account.js'; +import { billingRoutes } from './routes/billing.js'; import { healthRoutes } from './routes/health.js'; import { searchJobRoutes } from './routes/search-jobs.js'; +import { analyticsRoutes } from './routes/analytics.js'; function parseAllowedOrigins(rawOrigins: string) { return rawOrigins @@ -50,8 +52,10 @@ export async function buildApp() { await app.register(healthRoutes, { prefix: '/api' }); await app.register(authRoutes, { prefix: '/api' }); await app.register(accountRoutes, { prefix: '/api' }); + await app.register(billingRoutes, { prefix: '/api' }); await app.register(searchJobRoutes, { prefix: '/api' }); await app.register(deepResearchRoutes, { prefix: '/api' }); + await app.register(analyticsRoutes, { prefix: '/api' }); return app; } diff --git a/server/src/billing/enforcement-service.ts b/server/src/billing/enforcement-service.ts index 721cd86..f3a9e43 100644 --- a/server/src/billing/enforcement-service.ts +++ b/server/src/billing/enforcement-service.ts @@ -1,4 +1,5 @@ import type { Pool, PoolClient } from 'pg'; +import { resolveBillingAccessState } from '../../../shared/billing/lifecycle.js'; import type { AccountBillingState } from '../../../shared/types.js'; import type { EntitlementDecision, UsageAction, UsageAmount, UsageCostEstimate, UsageResource } from '../../../shared/billing/entitlements.js'; import { evaluateActionEntitlement, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js'; @@ -73,6 +74,31 @@ export async function checkActionEntitlementForWorkspace( }; } + const billingAccess = resolveBillingAccessState({ + status: billing.status, + currentPeriodEndsAt: billing.currentPeriodEndsAt, + cancelAtPeriodEnd: billing.cancelAtPeriodEnd, + gracePeriodEndsAt: billing.gracePeriodEndsAt, + }); + + if (input.costEstimate.isChargeable && billingAccess.accessMode === 'blocked') { + return { + allowed: false, + decision: { + status: 'blocked_upgrade_required', + denialReason: mapBillingStatusToDenialReason(billing.status), + action: input.action, + resource: getPrimaryUsageAmount(input.costEstimate)?.resource ?? 'research_credits', + requiredAmount: getPrimaryUsageAmount(input.costEstimate)?.amount ?? 0, + remainingAmount: 0, + currentPlanCode: billing.planCode, + suggestedUpgradePlanCode: billing.planCode, + addonEligible: false, + contactSalesRequired: false, + }, + }; + } + if (!input.costEstimate.isChargeable) { return { allowed: true, @@ -165,6 +191,12 @@ function formatEntitlementErrorMessage(decision: EntitlementDecision) { switch (decision.denialReason) { case 'billing_not_configured': return 'A billing plan is required before this action can run.'; + case 'billing_past_due': + return 'Payment is overdue and billing access is currently blocked.'; + case 'billing_canceled': + return 'This subscription is canceled. Reactivate billing to continue.'; + case 'billing_inactive': + return 'Billing is inactive for this workspace.'; case 'feature_not_available': return 'Your current plan does not include this feature.'; case 'not_launch_ready': @@ -178,5 +210,19 @@ function formatEntitlementErrorMessage(decision: EntitlementDecision) { } } +function mapBillingStatusToDenialReason(status: AccountBillingState['status']) { + switch (status) { + case 'past_due': + return 'billing_past_due' as const; + case 'canceled': + return 'billing_canceled' as const; + case 'inactive': + case 'not_configured': + return 'billing_inactive' as const; + case 'active': + return 'billing_inactive' as const; + } +} + // Export policy exists in shared billing modules, but route-level export enforcement // stays deferred until export generation moves to a backend endpoint. diff --git a/server/src/billing/repository.ts b/server/src/billing/repository.ts index b4d34db..fab8b3a 100644 --- a/server/src/billing/repository.ts +++ b/server/src/billing/repository.ts @@ -1,5 +1,6 @@ import type { Pool, PoolClient } from 'pg'; import type { AddonCode, BillingInterval, PlanCode } from '../../../shared/billing/plans.js'; +import type { BillingSyncStatus, BillingTimelineEventType } from '../../../shared/billing/lifecycle.js'; import type { AccountBillingStatus, BillingAddonBalanceSummary } from '../../../shared/types.js'; import type { UsageResource } from '../../../shared/billing/entitlements.js'; @@ -16,12 +17,32 @@ export interface BillingAccountRecord { cancelAtPeriodEnd: boolean; canceledAt: string | null; trialEndsAt: string | null; + gracePeriodEndsAt: string | null; + pendingPlanCode: PlanCode | null; + pendingPlanEffectiveAt: string | null; + billingSyncStatus: BillingSyncStatus; + lastStripeSyncAt: string | null; externalCustomerRef: string | null; externalSubscriptionRef: string | null; createdAt: string; updatedAt: string; } +export interface BillingTimelineEventRecord { + id: string; + workspaceId: string; + billingAccountId: string | null; + eventType: BillingTimelineEventType; + source: 'stripe' | 'app' | 'system'; + payloadJson: Record; + externalEventId: string | null; + externalCustomerRef: string | null; + externalSubscriptionRef: string | null; + occurredAt: string; + createdAt: string; + updatedAt: string; +} + export interface UsagePeriodRecord { id: string; workspaceId: string; @@ -67,12 +88,51 @@ type BillingAccountRow = { cancel_at_period_end: boolean; canceled_at: string | null; trial_ends_at: string | null; + grace_period_ends_at: string | null; + pending_plan_code: string | null; + pending_plan_effective_at: string | null; + billing_sync_status: BillingSyncStatus; + last_stripe_sync_at: string | null; external_customer_ref: string | null; external_subscription_ref: string | null; created_at: string; updated_at: string; }; +type BillingTimelineEventRow = { + id: string; + workspace_id: string; + billing_account_id: string | null; + event_type: BillingTimelineEventType; + source: 'stripe' | 'app' | 'system'; + payload_json: Record; + external_event_id: string | null; + external_customer_ref: string | null; + external_subscription_ref: string | null; + occurred_at: string; + created_at: string; + updated_at: string; +}; + +type BillingAdminWorkspaceSummaryRow = { + workspace_id: string; + workspace_name: string; + workspace_type: 'personal' | 'company'; + member_count: string; + status: AccountBillingStatus; + plan_code: string | null; + billing_interval: BillingInterval | null; + current_period_ends_at: string | null; + cancel_at_period_end: boolean; + grace_period_ends_at: string | null; + pending_plan_code: string | null; + pending_plan_effective_at: string | null; + billing_sync_status: BillingSyncStatus; + last_stripe_sync_at: string | null; + external_customer_ref: string | null; + external_subscription_ref: string | null; +}; + type UsagePeriodRow = { id: string; workspace_id: string; @@ -120,6 +180,8 @@ export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: s select id, workspace_id, plan_code, billing_interval, status, current_period_starts_at, current_period_ends_at, cancel_at_period_end, canceled_at, trial_ends_at, + grace_period_ends_at, pending_plan_code, pending_plan_effective_at, + billing_sync_status, last_stripe_sync_at, external_customer_ref, external_subscription_ref, created_at, updated_at from public.workspace_billing_accounts @@ -153,6 +215,8 @@ export async function createDefaultBillingAccountForWorkspace(db: DbClient, work returning id, workspace_id, plan_code, billing_interval, status, current_period_starts_at, current_period_ends_at, cancel_at_period_end, canceled_at, trial_ends_at, + grace_period_ends_at, pending_plan_code, pending_plan_effective_at, + billing_sync_status, last_stripe_sync_at, external_customer_ref, external_subscription_ref, created_at, updated_at `, @@ -187,6 +251,11 @@ export async function updateBillingAccountState( cancelAtPeriodEnd?: boolean; canceledAt?: string | null; trialEndsAt?: string | null; + gracePeriodEndsAt?: string | null; + pendingPlanCode?: PlanCode | null; + pendingPlanEffectiveAt?: string | null; + billingSyncStatus?: BillingSyncStatus; + lastStripeSyncAt?: string | null; externalCustomerRef?: string | null; externalSubscriptionRef?: string | null; }, @@ -203,10 +272,15 @@ export async function updateBillingAccountState( cancel_at_period_end, canceled_at, trial_ends_at, + grace_period_ends_at, + pending_plan_code, + pending_plan_effective_at, + billing_sync_status, + last_stripe_sync_at, external_customer_ref, external_subscription_ref ) - values ($1, $2, $3, $4, $5, $6, coalesce($7, false), $8, $9, $10, $11) + values ($1, $2, $3, $4, $5, $6, coalesce($7, false), $8, $9, $10, $11, $12, coalesce($13, 'ok'), $14, $15, $16) on conflict (workspace_id) do update set plan_code = excluded.plan_code, @@ -217,11 +291,18 @@ export async function updateBillingAccountState( cancel_at_period_end = excluded.cancel_at_period_end, canceled_at = excluded.canceled_at, trial_ends_at = excluded.trial_ends_at, + grace_period_ends_at = excluded.grace_period_ends_at, + pending_plan_code = excluded.pending_plan_code, + pending_plan_effective_at = excluded.pending_plan_effective_at, + billing_sync_status = excluded.billing_sync_status, + last_stripe_sync_at = excluded.last_stripe_sync_at, external_customer_ref = excluded.external_customer_ref, external_subscription_ref = excluded.external_subscription_ref returning id, workspace_id, plan_code, billing_interval, status, current_period_starts_at, current_period_ends_at, cancel_at_period_end, canceled_at, trial_ends_at, + grace_period_ends_at, pending_plan_code, pending_plan_effective_at, + billing_sync_status, last_stripe_sync_at, external_customer_ref, external_subscription_ref, created_at, updated_at `, @@ -235,6 +316,11 @@ export async function updateBillingAccountState( input.cancelAtPeriodEnd ?? false, input.canceledAt ?? null, input.trialEndsAt ?? null, + input.gracePeriodEndsAt ?? null, + input.pendingPlanCode ?? null, + input.pendingPlanEffectiveAt ?? null, + input.billingSyncStatus ?? 'ok', + input.lastStripeSyncAt ?? null, input.externalCustomerRef ?? null, input.externalSubscriptionRef ?? null, ], @@ -365,6 +451,180 @@ export async function listAddonBalancesForWorkspace(db: DbClient, workspaceId: s })); } +export async function createBillingTimelineEvent( + db: DbClient, + input: { + workspaceId: string; + billingAccountId?: string | null; + eventType: BillingTimelineEventType; + source: 'stripe' | 'app' | 'system'; + payloadJson?: Record; + externalEventId?: string | null; + externalCustomerRef?: string | null; + externalSubscriptionRef?: string | null; + occurredAt?: string; + }, +) { + const result = await db.query( + ` + insert into public.workspace_billing_timeline_events ( + workspace_id, billing_account_id, event_type, source, payload_json, + external_event_id, external_customer_ref, external_subscription_ref, occurred_at + ) + values ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, coalesce($9::timestamptz, now())) + returning id, workspace_id, billing_account_id, event_type, source, payload_json, + external_event_id, external_customer_ref, external_subscription_ref, + occurred_at, created_at, updated_at + `, + [ + input.workspaceId, + input.billingAccountId ?? null, + input.eventType, + input.source, + JSON.stringify(input.payloadJson ?? {}), + input.externalEventId ?? null, + input.externalCustomerRef ?? null, + input.externalSubscriptionRef ?? null, + input.occurredAt ?? null, + ], + ); + + return mapBillingTimelineEventRow(result.rows[0]); +} + +export async function listRecentBillingTimelineEventsForWorkspace(db: DbClient, workspaceId: string, limit = 20) { + const result = await db.query( + ` + select id, workspace_id, billing_account_id, event_type, source, payload_json, + external_event_id, external_customer_ref, external_subscription_ref, + occurred_at, created_at, updated_at + from public.workspace_billing_timeline_events + where workspace_id = $1 + order by occurred_at desc, created_at desc + limit $2 + `, + [workspaceId, limit], + ); + + return result.rows.map(mapBillingTimelineEventRow); +} + +export async function listBillingAdminWorkspaceSummaries(db: DbClient, search: string | null, limit = 50) { + const result = await db.query( + ` + select + w.id as workspace_id, + w.name as workspace_name, + w.workspace_type, + ( + select count(*)::text + from public.workspace_memberships member + where member.workspace_id = w.id + ) as member_count, + billing.status, + billing.plan_code, + billing.billing_interval, + billing.current_period_ends_at, + billing.cancel_at_period_end, + billing.grace_period_ends_at, + billing.pending_plan_code, + billing.pending_plan_effective_at, + billing.billing_sync_status, + billing.last_stripe_sync_at, + billing.external_customer_ref, + billing.external_subscription_ref + from public.workspaces w + join public.workspace_billing_accounts billing on billing.workspace_id = w.id + where ( + $1::text is null + or w.name ilike '%' || $1 || '%' + or billing.external_customer_ref ilike '%' || $1 || '%' + or billing.external_subscription_ref ilike '%' || $1 || '%' + ) + order by w.updated_at desc + limit $2 + `, + [search, limit], + ); + + return result.rows.map((row) => ({ + workspaceId: row.workspace_id, + workspaceName: row.workspace_name, + workspaceType: row.workspace_type, + memberCount: Number(row.member_count), + status: row.status, + planCode: row.plan_code as PlanCode | null, + billingInterval: row.billing_interval, + currentPeriodEndsAt: row.current_period_ends_at, + cancelAtPeriodEnd: row.cancel_at_period_end, + gracePeriodEndsAt: row.grace_period_ends_at, + pendingPlanCode: row.pending_plan_code as PlanCode | null, + pendingPlanEffectiveAt: row.pending_plan_effective_at, + billingSyncStatus: row.billing_sync_status, + lastStripeSyncAt: row.last_stripe_sync_at, + externalCustomerRef: row.external_customer_ref, + externalSubscriptionRef: row.external_subscription_ref, + })); +} + +export async function getBillingAdminWorkspaceSummaryByWorkspaceId(db: DbClient, workspaceId: string) { + const result = await db.query( + ` + select + w.id as workspace_id, + w.name as workspace_name, + w.workspace_type, + ( + select count(*)::text + from public.workspace_memberships member + where member.workspace_id = w.id + ) as member_count, + billing.status, + billing.plan_code, + billing.billing_interval, + billing.current_period_ends_at, + billing.cancel_at_period_end, + billing.grace_period_ends_at, + billing.pending_plan_code, + billing.pending_plan_effective_at, + billing.billing_sync_status, + billing.last_stripe_sync_at, + billing.external_customer_ref, + billing.external_subscription_ref + from public.workspaces w + join public.workspace_billing_accounts billing on billing.workspace_id = w.id + where w.id = $1 + limit 1 + `, + [workspaceId], + ); + + if (result.rowCount === 0) { + return null; + } + + const row = result.rows[0]; + + return { + workspaceId: row.workspace_id, + workspaceName: row.workspace_name, + workspaceType: row.workspace_type, + memberCount: Number(row.member_count), + status: row.status, + planCode: row.plan_code as PlanCode | null, + billingInterval: row.billing_interval, + currentPeriodEndsAt: row.current_period_ends_at, + cancelAtPeriodEnd: row.cancel_at_period_end, + gracePeriodEndsAt: row.grace_period_ends_at, + pendingPlanCode: row.pending_plan_code as PlanCode | null, + pendingPlanEffectiveAt: row.pending_plan_effective_at, + billingSyncStatus: row.billing_sync_status, + lastStripeSyncAt: row.last_stripe_sync_at, + externalCustomerRef: row.external_customer_ref, + externalSubscriptionRef: row.external_subscription_ref, + }; +} + export async function recordAddonPurchase( db: DbClient, input: { @@ -448,6 +708,11 @@ function mapBillingAccountRow(row: BillingAccountRow): BillingAccountRecord { cancelAtPeriodEnd: row.cancel_at_period_end, canceledAt: row.canceled_at, trialEndsAt: row.trial_ends_at, + gracePeriodEndsAt: row.grace_period_ends_at, + pendingPlanCode: row.pending_plan_code as PlanCode | null, + pendingPlanEffectiveAt: row.pending_plan_effective_at, + billingSyncStatus: row.billing_sync_status, + lastStripeSyncAt: row.last_stripe_sync_at, externalCustomerRef: row.external_customer_ref, externalSubscriptionRef: row.external_subscription_ref, createdAt: row.created_at, @@ -490,6 +755,23 @@ function getDefaultBillingPeriodBounds() { }; } +function mapBillingTimelineEventRow(row: BillingTimelineEventRow): BillingTimelineEventRecord { + return { + id: row.id, + workspaceId: row.workspace_id, + billingAccountId: row.billing_account_id, + eventType: row.event_type, + source: row.source, + payloadJson: row.payload_json, + externalEventId: row.external_event_id, + externalCustomerRef: row.external_customer_ref, + externalSubscriptionRef: row.external_subscription_ref, + occurredAt: row.occurred_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + async function bootstrapDefaultBillingAccountState(db: DbClient, workspaceId: string) { const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds(); diff --git a/server/src/billing/service.ts b/server/src/billing/service.ts index 7d0dd56..f945817 100644 --- a/server/src/billing/service.ts +++ b/server/src/billing/service.ts @@ -1,5 +1,6 @@ import type { Pool, PoolClient } from 'pg'; import { getUsageAllowanceForPlan, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js'; +import { getPendingPlanChangeMessage, isBillingSyncStale, resolveBillingAccessState } from '../../../shared/billing/lifecycle.js'; import type { BillingAddonBalanceSummary, BillingUsageResourceSummary, AccountBillingState } from '../../../shared/types.js'; import type { UsageResource } from '../../../shared/billing/entitlements.js'; import { @@ -24,6 +25,16 @@ export async function getWorkspaceBillingState(db: DbClient, workspaceId: string currentPeriodStartsAt: billingAccount.currentPeriodStartsAt, currentPeriodEndsAt: billingAccount.currentPeriodEndsAt, cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd, + canceledAt: billingAccount.canceledAt, + trialEndsAt: billingAccount.trialEndsAt, + gracePeriodEndsAt: billingAccount.gracePeriodEndsAt, + pendingPlanCode: billingAccount.pendingPlanCode, + pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt, + billingSyncStatus: billingAccount.billingSyncStatus, + lastStripeSyncAt: billingAccount.lastStripeSyncAt, + provider: billingAccount.externalCustomerRef ? 'stripe' : null, + externalCustomerRef: billingAccount.externalCustomerRef, + externalSubscriptionRef: billingAccount.externalSubscriptionRef, usage: [], addonBalances: await listAddonBalancesForWorkspace(db, workspaceId), message: 'Subscription management is being prepared. Plan details, usage tracking, and billing controls will appear here in a future update.', @@ -40,11 +51,19 @@ export async function getWorkspaceBillingState(db: DbClient, workspaceId: string currentPeriodStartsAt: usageSnapshot.currentPeriodStartsAt, currentPeriodEndsAt: usageSnapshot.currentPeriodEndsAt, cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd, + canceledAt: billingAccount.canceledAt, + trialEndsAt: billingAccount.trialEndsAt, + gracePeriodEndsAt: billingAccount.gracePeriodEndsAt, + pendingPlanCode: billingAccount.pendingPlanCode, + pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt, + billingSyncStatus: isBillingSyncStale(billingAccount.lastStripeSyncAt) ? 'stale' : billingAccount.billingSyncStatus, + lastStripeSyncAt: billingAccount.lastStripeSyncAt, + provider: billingAccount.externalCustomerRef ? 'stripe' : null, + externalCustomerRef: billingAccount.externalCustomerRef, + externalSubscriptionRef: billingAccount.externalSubscriptionRef, usage: usageSnapshot.usage, addonBalances, - message: billingAccount.status === 'active' - ? 'Billing state is active. Usage tracking is available and entitlement enforcement can build on this foundation.' - : 'Billing state is stored, but subscription automation and enforcement are still being built.', + message: buildBillingAccountMessage(billingAccount), }; } @@ -191,3 +210,25 @@ function minDate(a: Date, b: Date) { export async function getWorkspaceAddonBalances(db: DbClient, workspaceId: string): Promise { return listAddonBalancesForWorkspace(db, workspaceId); } + +function buildBillingAccountMessage(billingAccount: BillingAccountRecord) { + const lifecycle = resolveBillingAccessState({ + status: billingAccount.status, + currentPeriodEndsAt: billingAccount.currentPeriodEndsAt, + cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd, + gracePeriodEndsAt: billingAccount.gracePeriodEndsAt, + }); + + const pendingPlanMessage = getPendingPlanChangeMessage( + billingAccount.pendingPlanCode, + billingAccount.pendingPlanEffectiveAt, + ); + + const syncMessage = isBillingSyncStale(billingAccount.lastStripeSyncAt) + ? 'Stripe sync looks stale and may need support follow-up.' + : billingAccount.externalSubscriptionRef + ? 'Billing is synced from Stripe.' + : 'Billing state is managed locally until Stripe subscription data is attached.'; + + return [lifecycle.message, pendingPlanMessage, syncMessage].filter(Boolean).join(' '); +} diff --git a/server/src/config/env.ts b/server/src/config/env.ts index 0729d12..b7250c1 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -26,6 +26,19 @@ const envSchema = z.object({ GOOGLE_MAPS_SERVER_KEY: z.string().optional(), PG_BOSS_SCHEMA: z.string().default('pgboss'), SESSION_TTL_DAYS: z.coerce.number().int().positive().default(30), + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_PUBLISHABLE_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), + STRIPE_PRICE_STARTER_MONTHLY: z.string().optional(), + STRIPE_PRICE_STARTER_ANNUAL: z.string().optional(), + STRIPE_PRICE_GROWTH_MONTHLY: z.string().optional(), + STRIPE_PRICE_GROWTH_ANNUAL: z.string().optional(), + STRIPE_PRICE_PRO_MONTHLY: z.string().optional(), + STRIPE_PRICE_PRO_ANNUAL: z.string().optional(), + STRIPE_PRICE_EXPORT_PACK_10K: z.string().optional(), + STRIPE_PRICE_EXPORT_PACK_50K: z.string().optional(), + STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(), + BILLING_ADMIN_EMAILS: z.string().optional(), }); export type AppEnv = z.infer; @@ -40,3 +53,17 @@ export function getEnv(): AppEnv { cachedEnv = envSchema.parse(process.env); return cachedEnv; } + +export function isBillingAdminEmail(email: string) { + const allowlist = getEnv().BILLING_ADMIN_EMAILS; + + if (!allowlist) { + return false; + } + + return allowlist + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean) + .includes(email.trim().toLowerCase()); +} diff --git a/server/src/payments/catalog.ts b/server/src/payments/catalog.ts new file mode 100644 index 0000000..5406eab --- /dev/null +++ b/server/src/payments/catalog.ts @@ -0,0 +1,106 @@ +import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js'; +import { getAddonByCode } from '../../../shared/billing/addons.js'; +import { getPlanByCode } from '../../../shared/billing/plans.js'; +import type { AppEnv } from '../config/env.js'; + +export type SupportedSubscriptionPlanCode = Exclude; +export type SupportedAddonCode = 'export_pack_10k' | 'export_pack_50k'; + +const supportedSubscriptionPlanCodes = [ + 'starter_monthly', + 'starter_annual', + 'growth_monthly', + 'growth_annual', + 'pro_monthly', + 'pro_annual', +] as const satisfies readonly SupportedSubscriptionPlanCode[]; + +const supportedAddonCodes = ['export_pack_10k', 'export_pack_50k'] as const satisfies readonly SupportedAddonCode[]; + +export function isSupportedSubscriptionPlanCode(planCode: ActivePlanCode): planCode is SupportedSubscriptionPlanCode { + return supportedSubscriptionPlanCodes.includes(planCode as SupportedSubscriptionPlanCode); +} + +export function isSupportedAddonCode(addonCode: AddonCode): addonCode is SupportedAddonCode { + return supportedAddonCodes.includes(addonCode as SupportedAddonCode); +} + +export function getStripePriceIdForPlan(env: AppEnv, planCode: SupportedSubscriptionPlanCode) { + const mapping: Record = { + starter_monthly: env.STRIPE_PRICE_STARTER_MONTHLY, + starter_annual: env.STRIPE_PRICE_STARTER_ANNUAL, + growth_monthly: env.STRIPE_PRICE_GROWTH_MONTHLY, + growth_annual: env.STRIPE_PRICE_GROWTH_ANNUAL, + pro_monthly: env.STRIPE_PRICE_PRO_MONTHLY, + pro_annual: env.STRIPE_PRICE_PRO_ANNUAL, + }; + + const priceId = mapping[planCode]; + if (!priceId) { + throw new Error(`Missing Stripe price ID for plan '${planCode}'.`); + } + + return priceId; +} + +export function getStripePriceIdForAddon(env: AppEnv, addonCode: SupportedAddonCode) { + const mapping: Record = { + export_pack_10k: env.STRIPE_PRICE_EXPORT_PACK_10K, + export_pack_50k: env.STRIPE_PRICE_EXPORT_PACK_50K, + }; + + const priceId = mapping[addonCode]; + if (!priceId) { + throw new Error(`Missing Stripe price ID for add-on '${addonCode}'.`); + } + + return priceId; +} + +export function getPlanCodeForStripePriceId(env: AppEnv, priceId: string): SupportedSubscriptionPlanCode | null { + for (const planCode of supportedSubscriptionPlanCodes) { + if (getStripePriceIdForPlan(env, planCode) === priceId) { + return planCode; + } + } + + return null; +} + +export function getAddonCodeForStripePriceId(env: AppEnv, priceId: string): SupportedAddonCode | null { + for (const addonCode of supportedAddonCodes) { + if (getStripePriceIdForAddon(env, addonCode) === priceId) { + return addonCode; + } + } + + return null; +} + +export function assertSelfServePlanSupportsStripeCheckout(planCode: ActivePlanCode) { + const plan = getPlanByCode(planCode); + + if (!plan) { + throw new Error(`Unknown plan '${planCode}'.`); + } + + if (!plan.isSelfServe || plan.contactSalesRequired || !isSupportedSubscriptionPlanCode(planCode)) { + throw new Error(`Plan '${planCode}' is not available for self-serve Stripe checkout.`); + } + + return plan; +} + +export function assertAddonSupportsStripeCheckout(addonCode: AddonCode) { + const addon = getAddonByCode(addonCode); + + if (!addon) { + throw new Error(`Unknown add-on '${addonCode}'.`); + } + + if (addon.availability !== 'active' || addon.purchaseMode !== 'one_time' || !isSupportedAddonCode(addonCode)) { + throw new Error(`Add-on '${addonCode}' is not available for Stripe checkout.`); + } + + return addon; +} diff --git a/server/src/payments/repository.ts b/server/src/payments/repository.ts new file mode 100644 index 0000000..b601c05 --- /dev/null +++ b/server/src/payments/repository.ts @@ -0,0 +1,166 @@ +import type { Pool, PoolClient } from 'pg'; + +type DbClient = Pool | PoolClient; + +export type BillingWebhookEventStatus = 'received' | 'processed' | 'failed' | 'ignored'; + +export interface BillingWebhookEventRecord { + id: string; + provider: 'stripe'; + externalEventId: string; + eventType: string; + status: BillingWebhookEventStatus; + workspaceId: string | null; + externalCustomerRef: string | null; + externalSubscriptionRef: string | null; + payloadJson: Record; + errorMessage: string | null; + receivedAt: string; + processedAt: string | null; + createdAt: string; + updatedAt: string; +} + +type BillingWebhookEventRow = { + id: string; + provider: 'stripe'; + external_event_id: string; + event_type: string; + status: BillingWebhookEventStatus; + workspace_id: string | null; + external_customer_ref: string | null; + external_subscription_ref: string | null; + payload_json: Record; + error_message: string | null; + received_at: string; + processed_at: string | null; + created_at: string; + updated_at: string; +}; + +export async function recordIncomingWebhookEvent( + db: DbClient, + input: { + provider: 'stripe'; + externalEventId: string; + eventType: string; + workspaceId?: string | null; + externalCustomerRef?: string | null; + externalSubscriptionRef?: string | null; + payloadJson: Record; + }, +) { + const result = await db.query( + ` + insert into public.billing_webhook_events ( + provider, external_event_id, event_type, status, + workspace_id, external_customer_ref, external_subscription_ref, payload_json + ) + values ($1, $2, $3, 'received', $4, $5, $6, $7::jsonb) + on conflict (provider, external_event_id) do nothing + returning id, provider, external_event_id, event_type, status, + workspace_id, external_customer_ref, external_subscription_ref, + payload_json, error_message, received_at, processed_at, created_at, updated_at + `, + [ + input.provider, + input.externalEventId, + input.eventType, + input.workspaceId ?? null, + input.externalCustomerRef ?? null, + input.externalSubscriptionRef ?? null, + JSON.stringify(input.payloadJson), + ], + ); + + if (result.rowCount === 0) { + const existing = await getWebhookEventByExternalId(db, input.provider, input.externalEventId); + return { + record: existing, + inserted: false, + }; + } + + return { + record: mapBillingWebhookEventRow(result.rows[0]), + inserted: true, + }; +} + +export async function getWebhookEventByExternalId(db: DbClient, provider: 'stripe', externalEventId: string) { + const result = await db.query( + ` + select id, provider, external_event_id, event_type, status, + workspace_id, external_customer_ref, external_subscription_ref, + payload_json, error_message, received_at, processed_at, created_at, updated_at + from public.billing_webhook_events + where provider = $1 and external_event_id = $2 + limit 1 + `, + [provider, externalEventId], + ); + + if (result.rowCount === 0) { + return null; + } + + return mapBillingWebhookEventRow(result.rows[0]); +} + +export async function markWebhookEventProcessed(db: DbClient, id: string, status: Extract) { + await db.query( + ` + update public.billing_webhook_events + set status = $2, processed_at = now(), error_message = null + where id = $1 + `, + [id, status], + ); +} + +export async function markWebhookEventFailed(db: DbClient, id: string, errorMessage: string) { + await db.query( + ` + update public.billing_webhook_events + set status = 'failed', processed_at = now(), error_message = $2 + where id = $1 + `, + [id, errorMessage], + ); +} + +export async function listRecentWebhookEventsForWorkspace(db: DbClient, workspaceId: string, limit = 20) { + const result = await db.query( + ` + select id, provider, external_event_id, event_type, status, + workspace_id, external_customer_ref, external_subscription_ref, + payload_json, error_message, received_at, processed_at, created_at, updated_at + from public.billing_webhook_events + where workspace_id = $1 + order by received_at desc + limit $2 + `, + [workspaceId, limit], + ); + + return result.rows.map(mapBillingWebhookEventRow); +} + +function mapBillingWebhookEventRow(row: BillingWebhookEventRow): BillingWebhookEventRecord { + return { + id: row.id, + provider: row.provider, + externalEventId: row.external_event_id, + eventType: row.event_type, + status: row.status, + workspaceId: row.workspace_id, + externalCustomerRef: row.external_customer_ref, + externalSubscriptionRef: row.external_subscription_ref, + payloadJson: row.payload_json, + errorMessage: row.error_message, + receivedAt: row.received_at, + processedAt: row.processed_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} diff --git a/server/src/payments/service.ts b/server/src/payments/service.ts new file mode 100644 index 0000000..e573f3c --- /dev/null +++ b/server/src/payments/service.ts @@ -0,0 +1,395 @@ +import type Stripe from 'stripe'; +import type { Pool, PoolClient } from 'pg'; +import { getDefaultBillingGracePeriodEndsAt } from '../../../shared/billing/lifecycle.js'; +import { getAddonByCode } from '../../../shared/billing/addons.js'; +import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js'; +import { getPlanByCode } from '../../../shared/billing/plans.js'; +import { ensureWorkspaceForUser } from '../account/repository.js'; +import { ensureBillingAccountForWorkspace, listAddonBalancesForWorkspace, recordAddonPurchase, updateBillingAccountState, upsertAddonBalance } from '../billing/repository.js'; +import { getEnv } from '../config/env.js'; +import { + assertAddonSupportsStripeCheckout, + assertSelfServePlanSupportsStripeCheckout, + getPlanCodeForStripePriceId, + type SupportedAddonCode, + type SupportedSubscriptionPlanCode, + getStripePriceIdForAddon, + getStripePriceIdForPlan, +} from './catalog.js'; +import { getStripeClient, isStripeConfigured } from './stripe-client.js'; + +type DbClient = Pool | PoolClient; + +export interface BillingActor { + id: string; + email: string; + displayName?: string | null; +} + +export async function createSubscriptionCheckoutSession( + db: DbClient, + input: { user: BillingActor; planCode: ActivePlanCode }, +) { + ensureStripeReady('checkout'); + + const workspace = await ensureWorkspaceForUser(db, input.user); + if (!workspace) { + throw new Error('Failed to resolve billing workspace.'); + } + + const plan = assertSelfServePlanSupportsStripeCheckout(input.planCode); + const env = getEnv(); + const stripe = getStripeClient(); + const billingAccount = await ensureBillingAccountForWorkspace(db, workspace.id); + const customerId = await ensureStripeCustomer(db, stripe, { + workspaceId: workspace.id, + workspaceName: workspace.name, + user: input.user, + existingCustomerRef: billingAccount.externalCustomerRef, + }); + + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + success_url: buildStripeReturnUrl('/account?billing=success'), + cancel_url: buildStripeReturnUrl('/account?billing=cancelled'), + line_items: [ + { + price: getStripePriceIdForPlan(env, plan.code as SupportedSubscriptionPlanCode), + quantity: 1, + }, + ], + allow_promotion_codes: true, + client_reference_id: workspace.id, + metadata: { + workspaceId: workspace.id, + checkoutKind: 'subscription', + planCode: plan.code, + billingInterval: plan.billingInterval, + initiatedByUserId: input.user.id, + }, + subscription_data: { + metadata: { + workspaceId: workspace.id, + planCode: plan.code, + billingInterval: plan.billingInterval, + }, + }, + customer_update: { + name: 'auto', + address: 'auto', + }, + }); + + if (!session.url) { + throw new Error('Stripe did not return a checkout URL.'); + } + + return { + checkoutUrl: session.url, + }; +} + +export async function createAddonCheckoutSession( + db: DbClient, + input: { user: BillingActor; addonCode: AddonCode }, +) { + ensureStripeReady('checkout'); + + const workspace = await ensureWorkspaceForUser(db, input.user); + if (!workspace) { + throw new Error('Failed to resolve billing workspace.'); + } + + const billingAccount = await ensureBillingAccountForWorkspace(db, workspace.id); + const addon = assertAddonSupportsStripeCheckout(input.addonCode); + + if (!billingAccount.planCode) { + throw new Error('A billing plan is required before buying add-ons.'); + } + + const plan = getPlanByCode(billingAccount.planCode); + if (!plan || !plan.eligibleAddonCodes.includes(addon.code)) { + throw new Error('This add-on is not eligible for the current plan.'); + } + + const env = getEnv(); + const stripe = getStripeClient(); + const customerId = await ensureStripeCustomer(db, stripe, { + workspaceId: workspace.id, + workspaceName: workspace.name, + user: input.user, + existingCustomerRef: billingAccount.externalCustomerRef, + }); + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + customer: customerId, + success_url: buildStripeReturnUrl('/account?billing=addon-success'), + cancel_url: buildStripeReturnUrl('/account?billing=cancelled'), + line_items: [ + { + price: getStripePriceIdForAddon(env, addon.code as SupportedAddonCode), + quantity: 1, + }, + ], + client_reference_id: workspace.id, + metadata: { + workspaceId: workspace.id, + checkoutKind: 'addon', + addonCode: addon.code, + initiatedByUserId: input.user.id, + }, + }); + + if (!session.url) { + throw new Error('Stripe did not return a checkout URL.'); + } + + return { + checkoutUrl: session.url, + }; +} + +export async function createBillingPortalSession(db: DbClient, input: { user: BillingActor }) { + ensureStripeReady('billing portal'); + + const workspace = await ensureWorkspaceForUser(db, input.user); + if (!workspace) { + throw new Error('Failed to resolve billing workspace.'); + } + + const billingAccount = await ensureBillingAccountForWorkspace(db, workspace.id); + + if (!billingAccount.externalCustomerRef) { + throw new Error('This workspace is not connected to Stripe billing yet.'); + } + + const stripe = getStripeClient(); + const session = await stripe.billingPortal.sessions.create({ + customer: billingAccount.externalCustomerRef, + return_url: buildStripeReturnUrl('/account'), + configuration: getEnv().STRIPE_BILLING_PORTAL_CONFIGURATION_ID || undefined, + }); + + return { + url: session.url, + }; +} + +export async function syncWorkspaceBillingFromStripeSubscription( + db: DbClient, + subscription: Stripe.Subscription, + workspaceIdHint?: string | null, +) { + const env = getEnv(); + const priceId = subscription.items.data[0]?.price?.id; + + if (!priceId) { + throw new Error(`Stripe subscription '${subscription.id}' is missing a primary price.`); + } + + const planCode = getPlanCodeForStripePriceId(env, priceId); + + if (!planCode) { + throw new Error(`Unsupported Stripe price '${priceId}' for subscription sync.`); + } + + const workspaceId = workspaceIdHint + || getMetadataValue(subscription.metadata, 'workspaceId') + || getMetadataValue(subscription.items.data[0]?.metadata, 'workspaceId'); + + if (!workspaceId) { + throw new Error(`Stripe subscription '${subscription.id}' is missing workspace metadata.`); + } + + const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId); + const plan = getPlanByCode(planCode); + + const nextStatus = mapStripeSubscriptionStatus(subscription.status); + + if (!plan) { + throw new Error(`Unknown internal plan '${planCode}'.`); + } + + await updateBillingAccountState(db, { + workspaceId, + planCode, + billingInterval: plan.billingInterval, + status: nextStatus, + currentPeriodStartsAt: toIsoStringOrNull(subscription.items.data[0]?.current_period_start ?? null), + currentPeriodEndsAt: toIsoStringOrNull(subscription.items.data[0]?.current_period_end ?? null), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + canceledAt: toIsoStringOrNull(subscription.canceled_at), + trialEndsAt: toIsoStringOrNull(subscription.trial_end), + gracePeriodEndsAt: nextStatus === 'past_due' + ? billingAccount.gracePeriodEndsAt ?? getDefaultBillingGracePeriodEndsAt(new Date()) + : null, + pendingPlanCode: null, + pendingPlanEffectiveAt: null, + billingSyncStatus: 'ok', + lastStripeSyncAt: new Date().toISOString(), + externalCustomerRef: extractStripeCustomerId(subscription.customer) ?? billingAccount.externalCustomerRef, + externalSubscriptionRef: subscription.id, + }); + + return workspaceId; +} + +export async function fulfillAddonCheckoutSession(db: DbClient, session: Stripe.Checkout.Session) { + const workspaceId = session.client_reference_id || getMetadataValue(session.metadata, 'workspaceId'); + const addonCode = getMetadataValue(session.metadata, 'addonCode') as AddonCode | null; + + if (!workspaceId || !addonCode) { + throw new Error(`Stripe checkout session '${session.id}' is missing add-on metadata.`); + } + + const addon = getAddonByCode(addonCode); + if (!addon || addon.resource === null || addon.quantity === null) { + throw new Error(`Unsupported add-on fulfillment for '${addonCode}'.`); + } + + await recordAddonPurchase(db, { + workspaceId, + addonCode, + resource: addon.resource, + purchasedQuantity: addon.quantity, + remainingQuantity: addon.quantity, + purchasedAt: session.created ? new Date(session.created * 1000).toISOString() : undefined, + expiresAt: null, + externalPurchaseRef: session.payment_intent ? String(session.payment_intent) : session.id, + }); + + const existingBalance = (await listAddonBalancesForWorkspace(db, workspaceId)).find( + (balance) => balance.addonCode === addonCode && balance.resource === addon.resource, + ); + + await upsertAddonBalance(db, { + workspaceId, + addonCode, + resource: addon.resource, + remainingQuantity: (existingBalance?.remainingQuantity ?? 0) + addon.quantity, + expiresAt: null, + }); + + return workspaceId; +} + +export async function retrieveStripeSubscription(subscriptionId: string) { + const stripe = getStripeClient(); + return stripe.subscriptions.retrieve(subscriptionId); +} + +export function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): 'active' | 'inactive' | 'past_due' | 'canceled' { + switch (status) { + case 'active': + case 'trialing': + return 'active'; + case 'past_due': + case 'unpaid': + return 'past_due'; + case 'canceled': + case 'incomplete_expired': + return 'canceled'; + case 'incomplete': + case 'paused': + return 'inactive'; + default: + return 'inactive'; + } +} + +export function getWorkspaceIdFromStripeObject(input: { metadata?: Stripe.Metadata | null; customer?: string | Stripe.Customer | Stripe.DeletedCustomer | null; client_reference_id?: string | null }) { + return input.client_reference_id || getMetadataValue(input.metadata ?? null, 'workspaceId'); +} + +export function getStripeCustomerIdFromObject(input: { customer?: string | Stripe.Customer | Stripe.DeletedCustomer | null }) { + return extractStripeCustomerId(input.customer ?? null); +} + +export function getStripeSubscriptionIdFromInvoice(invoice: Stripe.Invoice) { + return extractSubscriptionId(invoice.parent?.subscription_details?.subscription ?? null); +} + +function ensureStripeReady(context: string) { + if (!isStripeConfigured()) { + throw new Error(`Stripe ${context} is not configured in this environment yet.`); + } +} + +async function ensureStripeCustomer( + db: DbClient, + stripe: Stripe, + input: { + workspaceId: string; + workspaceName: string; + user: BillingActor; + existingCustomerRef: string | null; + }, +) { + if (input.existingCustomerRef) { + return input.existingCustomerRef; + } + + const customer = await stripe.customers.create({ + email: input.user.email, + name: input.workspaceName, + metadata: { + workspaceId: input.workspaceId, + environment: getEnv().NODE_ENV, + ownerUserId: input.user.id, + }, + }); + + const billingAccount = await ensureBillingAccountForWorkspace(db, input.workspaceId); + await updateBillingAccountState(db, { + workspaceId: input.workspaceId, + planCode: billingAccount.planCode, + billingInterval: billingAccount.billingInterval, + status: billingAccount.status, + currentPeriodStartsAt: billingAccount.currentPeriodStartsAt, + currentPeriodEndsAt: billingAccount.currentPeriodEndsAt, + cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd, + canceledAt: billingAccount.canceledAt, + trialEndsAt: billingAccount.trialEndsAt, + gracePeriodEndsAt: billingAccount.gracePeriodEndsAt, + pendingPlanCode: billingAccount.pendingPlanCode, + pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt, + billingSyncStatus: billingAccount.billingSyncStatus, + lastStripeSyncAt: billingAccount.lastStripeSyncAt, + externalCustomerRef: customer.id, + externalSubscriptionRef: billingAccount.externalSubscriptionRef, + }); + + return customer.id; +} + +function buildStripeReturnUrl(path: string) { + const origin = getEnv().APP_ORIGIN.split(',')[0]?.trim() || 'http://localhost:3000'; + return `${origin.replace(/\/+$/, '')}${path}`; +} + +function getMetadataValue(metadata: Stripe.Metadata | null | undefined, key: string) { + const value = metadata?.[key]; + return typeof value === 'string' && value.trim() ? value : null; +} + +function extractStripeCustomerId(customer: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined) { + if (!customer) { + return null; + } + + return typeof customer === 'string' ? customer : customer.id; +} + +function extractSubscriptionId(subscription: string | Stripe.Subscription | null) { + if (!subscription) { + return null; + } + + return typeof subscription === 'string' ? subscription : subscription.id; +} + +function toIsoStringOrNull(timestamp: number | null) { + return typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : null; +} diff --git a/server/src/payments/stripe-client.ts b/server/src/payments/stripe-client.ts new file mode 100644 index 0000000..b7dc750 --- /dev/null +++ b/server/src/payments/stripe-client.ts @@ -0,0 +1,27 @@ +import Stripe from 'stripe'; +import { getEnv } from '../config/env.js'; + +let cachedStripeClient: Stripe | null = null; + +export function isStripeConfigured() { + const env = getEnv(); + return Boolean(env.STRIPE_SECRET_KEY); +} + +export function getStripeClient() { + if (cachedStripeClient) { + return cachedStripeClient; + } + + const env = getEnv(); + + if (!env.STRIPE_SECRET_KEY) { + throw new Error('Stripe is not configured. Set STRIPE_SECRET_KEY to enable payments.'); + } + + cachedStripeClient = new Stripe(env.STRIPE_SECRET_KEY, { + apiVersion: '2026-04-22.dahlia', + }); + + return cachedStripeClient; +} diff --git a/server/src/payments/webhooks.ts b/server/src/payments/webhooks.ts new file mode 100644 index 0000000..4bdfa3f --- /dev/null +++ b/server/src/payments/webhooks.ts @@ -0,0 +1,298 @@ +import type Stripe from 'stripe'; +import type { Pool, PoolClient } from 'pg'; +import { getDbPool } from '../db/pool.js'; +import { getEnv } from '../config/env.js'; +import { createBillingTimelineEvent, ensureBillingAccountForWorkspace } from '../billing/repository.js'; +import { markWebhookEventFailed, markWebhookEventProcessed, recordIncomingWebhookEvent } from './repository.js'; +import { getStripeClient } from './stripe-client.js'; +import { + fulfillAddonCheckoutSession, + getStripeCustomerIdFromObject, + getStripeSubscriptionIdFromInvoice, + getWorkspaceIdFromStripeObject, + retrieveStripeSubscription, + syncWorkspaceBillingFromStripeSubscription, +} from './service.js'; +import { recordAnalyticsEvent } from '../analytics/service.js'; + +type DbClient = Pool | PoolClient; + +export function constructStripeWebhookEvent(payload: string, signature: string) { + const webhookSecret = getEnv().STRIPE_WEBHOOK_SECRET; + + if (!webhookSecret) { + throw new Error('Stripe webhook secret is not configured.'); + } + + return getStripeClient().webhooks.constructEvent(payload, signature, webhookSecret); +} + +export async function processStripeWebhookEvent(event: Stripe.Event, db: DbClient = getDbPool()) { + const workspaceId = getWorkspaceIdForWebhookEvent(event); + const externalCustomerRef = getStripeCustomerIdForWebhookEvent(event); + const externalSubscriptionRef = getStripeSubscriptionIdForWebhookEvent(event); + const incoming = await recordIncomingWebhookEvent(db, { + provider: 'stripe', + externalEventId: event.id, + eventType: event.type, + workspaceId, + externalCustomerRef, + externalSubscriptionRef, + payloadJson: event as unknown as Record, + }); + + if (!incoming.inserted) { + return { + status: 'duplicate' as const, + workspaceId: incoming.record?.workspaceId ?? workspaceId, + }; + } + + try { + const processed = await handleStripeWebhookEvent(db, event, workspaceId); + if (workspaceId) { + const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId); + await createBillingTimelineEvent(db, { + workspaceId, + billingAccountId: billingAccount.id, + eventType: mapTimelineEventType(event), + source: 'stripe', + payloadJson: { + eventType: event.type, + livemode: event.livemode, + }, + externalEventId: event.id, + externalCustomerRef, + externalSubscriptionRef, + occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(), + }); + } + await markWebhookEventProcessed(db, incoming.record!.id, processed ? 'processed' : 'ignored'); + return { + status: processed ? 'processed' as const : 'ignored' as const, + workspaceId, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Webhook processing failed.'; + await markWebhookEventFailed(db, incoming.record!.id, message); + throw error; + } +} + +function mapTimelineEventType(event: Stripe.Event) { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + return session.mode === 'payment' ? 'addon_purchased' : 'checkout_completed'; + } + case 'customer.subscription.created': + return 'subscription_created'; + case 'customer.subscription.updated': + return 'subscription_updated'; + case 'customer.subscription.deleted': + return 'subscription_deleted'; + case 'invoice.paid': + return 'invoice_paid'; + case 'invoice.payment_failed': + return 'invoice_payment_failed'; + default: + return 'billing_status_changed'; + } +} + +async function handleStripeWebhookEvent(db: DbClient, event: Stripe.Event, workspaceIdHint?: string | null) { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + const workspaceId = workspaceIdHint ?? session.client_reference_id ?? null; + + if (session.mode === 'payment' && session.payment_status === 'paid') { + await fulfillAddonCheckoutSession(db, session); + await safeRecordAnalyticsEvent(db, { + eventName: 'addon_purchase_completed', + eventSource: 'stripe_webhook', + workspaceId, + addonCode: session.metadata?.addonCode ?? null, + amount: typeof session.amount_total === 'number' ? session.amount_total / 100 : null, + currency: session.currency ?? null, + metadata: { + stripeEventType: event.type, + stripeSessionId: session.id, + stripeMode: session.mode, + }, + occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(), + }); + return true; + } + + if (session.mode === 'subscription' && typeof session.subscription === 'string') { + const subscription = await retrieveStripeSubscription(session.subscription); + await syncWorkspaceBillingFromStripeSubscription(db, subscription, workspaceId); + await safeRecordAnalyticsEvent(db, { + eventName: 'checkout_completed', + eventSource: 'stripe_webhook', + workspaceId, + planCode: subscription.metadata?.planCode ?? null, + metadata: { + stripeEventType: event.type, + stripeSessionId: session.id, + stripeMode: session.mode, + stripeSubscriptionId: subscription.id, + }, + occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(), + }); + return true; + } + + return false; + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + await syncWorkspaceBillingFromStripeSubscription(db, subscription, workspaceIdHint); + + if (event.type === 'customer.subscription.deleted') { + await safeRecordAnalyticsEvent(db, { + eventName: 'subscription_canceled', + eventSource: 'stripe_webhook', + workspaceId: workspaceIdHint ?? null, + planCode: subscription.metadata?.planCode ?? null, + metadata: { + stripeEventType: event.type, + stripeSubscriptionId: subscription.id, + status: subscription.status, + }, + occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(), + }); + } + + if (event.type === 'customer.subscription.updated') { + const previousAttributes = (event.data as { previous_attributes?: Record }).previous_attributes ?? {}; + const changed = 'status' in previousAttributes || 'items' in previousAttributes || 'cancel_at_period_end' in previousAttributes; + if (changed) { + await safeRecordAnalyticsEvent(db, { + eventName: 'plan_changed', + eventSource: 'stripe_webhook', + workspaceId: workspaceIdHint ?? null, + planCode: subscription.metadata?.planCode ?? null, + metadata: { + stripeEventType: event.type, + stripeSubscriptionId: subscription.id, + status: subscription.status, + previousAttributes, + }, + occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(), + }); + } + } + + return true; + } + case 'invoice.paid': + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = getStripeSubscriptionIdFromInvoice(invoice); + + if (event.type === 'invoice.payment_failed') { + await safeRecordAnalyticsEvent(db, { + eventName: 'payment_failed', + eventSource: 'stripe_webhook', + workspaceId: workspaceIdHint ?? null, + amount: typeof invoice.amount_due === 'number' ? invoice.amount_due / 100 : null, + currency: invoice.currency ?? null, + metadata: { + stripeEventType: event.type, + stripeInvoiceId: invoice.id, + stripeSubscriptionId: subscriptionId, + }, + occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(), + }); + } + + if (!subscriptionId) { + return false; + } + + const subscription = await retrieveStripeSubscription(subscriptionId); + await syncWorkspaceBillingFromStripeSubscription(db, subscription, workspaceIdHint); + return true; + } + default: + return false; + } +} + +function getWorkspaceIdForWebhookEvent(event: Stripe.Event) { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + return getWorkspaceIdFromStripeObject(session); + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + return getWorkspaceIdFromStripeObject(subscription); + } + case 'invoice.paid': + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + return getWorkspaceIdFromStripeObject({ metadata: invoice.parent?.subscription_details?.metadata ?? invoice.lines.data[0]?.metadata ?? null }); + } + default: + return null; + } +} + +function getStripeCustomerIdForWebhookEvent(event: Stripe.Event) { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + return getStripeCustomerIdFromObject(session); + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + return getStripeCustomerIdFromObject(subscription); + } + case 'invoice.paid': + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + return getStripeCustomerIdFromObject(invoice); + } + default: + return null; + } +} + +function getStripeSubscriptionIdForWebhookEvent(event: Stripe.Event) { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + return typeof session.subscription === 'string' ? session.subscription : session.subscription?.id ?? null; + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + return subscription.id; + } + case 'invoice.paid': + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + return getStripeSubscriptionIdFromInvoice(invoice); + } + default: + return null; + } +} + +async function safeRecordAnalyticsEvent(db: DbClient, input: Parameters[1]) { + try { + await recordAnalyticsEvent(db, input); + } catch { + return; + } +} diff --git a/server/src/routes/analytics.ts b/server/src/routes/analytics.ts new file mode 100644 index 0000000..f01b485 --- /dev/null +++ b/server/src/routes/analytics.ts @@ -0,0 +1,92 @@ +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { z, ZodError } from 'zod'; +import { ensureWorkspaceForUser } from '../account/repository.js'; +import { hydrateAuthUser, requireAuth } from '../auth/middleware.js'; +import { isBillingAdminEmail } from '../config/env.js'; +import { getDbPool } from '../db/pool.js'; +import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js'; + +const eventInputSchema = z.object({ + eventName: z.enum([ + 'pricing_plan_selected', + 'checkout_started', + 'checkout_completed', + 'checkout_canceled', + 'portal_opened', + 'quota_warning_shown', + 'quota_exhausted_blocked', + 'feature_gate_encountered', + 'addon_checkout_started', + 'addon_purchase_completed', + 'plan_changed', + 'payment_failed', + 'subscription_canceled', + ]), + eventSource: z.enum(['web_app', 'api', 'stripe_webhook', 'system']).default('web_app'), + workspaceId: z.string().uuid().optional(), + planCode: z.string().trim().min(1).max(128).optional(), + addonCode: z.string().trim().min(1).max(128).optional(), + resource: z.string().trim().min(1).max(128).optional(), + amount: z.number().finite().optional(), + currency: z.string().trim().min(1).max(16).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + occurredAt: z.string().datetime().optional(), +}); + +const summaryQuerySchema = z.object({ + days: z.coerce.number().int().min(7).max(90).optional(), +}); + +async function requireBillingAdmin(request: FastifyRequest, reply: FastifyReply) { + if (!request.authUser || !isBillingAdminEmail(request.authUser.email)) { + return reply.code(403).send({ error: 'Billing admin access is required.' }); + } + + return undefined; +} + +export const analyticsRoutes: FastifyPluginAsync = async (app) => { + app.post('/analytics/events', async (request, reply) => { + try { + const payload = eventInputSchema.parse(request.body ?? {}); + const db = getDbPool(); + const authUser = await hydrateAuthUser(request); + + let workspaceId = payload.workspaceId ?? null; + if (authUser) { + const workspace = await ensureWorkspaceForUser(db, authUser); + workspaceId = workspace?.id ?? workspaceId; + } + + await recordAnalyticsEvent(db, { + ...payload, + userId: authUser?.id ?? null, + workspaceId, + }); + + return { ok: true }; + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid analytics payload.' }); + } + + request.log.error(error); + return reply.code(500).send({ error: 'Failed to record analytics event.' }); + } + }); + + app.get('/admin/analytics/summary', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => { + try { + const query = summaryQuerySchema.parse(request.query ?? {}); + const summary = await getAdminAnalyticsSummary(getDbPool(), query.days ?? 30); + return { summary }; + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid analytics summary query.' }); + } + + request.log.error(error); + return reply.code(500).send({ error: 'Failed to load analytics summary.' }); + } + }); +}; diff --git a/server/src/routes/billing.ts b/server/src/routes/billing.ts new file mode 100644 index 0000000..dcac8a9 --- /dev/null +++ b/server/src/routes/billing.ts @@ -0,0 +1,198 @@ +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { z, ZodError } from 'zod'; +import { getDbPool } from '../db/pool.js'; +import { requireAuth } from '../auth/middleware.js'; +import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js'; +import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js'; +import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js'; +import { constructStripeWebhookEvent, processStripeWebhookEvent } from '../payments/webhooks.js'; +import { ensureWorkspaceForUser } from '../account/repository.js'; +import { isBillingAdminEmail } from '../config/env.js'; +import { + ensureBillingAccountForWorkspace, + getBillingAdminWorkspaceSummaryByWorkspaceId, + listBillingAdminWorkspaceSummaries, + listRecentBillingTimelineEventsForWorkspace, +} from '../billing/repository.js'; +import { ensureCurrentUsagePeriodForBillingAccount, getWorkspaceBillingState } from '../billing/service.js'; + +const subscriptionCheckoutSchema = z.object({ + planCode: z.enum(['starter_monthly', 'starter_annual', 'growth_monthly', 'growth_annual', 'pro_monthly', 'pro_annual', 'enterprise_custom']), +}); + +const addonCheckoutSchema = z.object({ + addonCode: z.enum(['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly']), +}); + +const adminWorkspaceQuerySchema = z.object({ + query: z.string().trim().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), +}); + +const adminWorkspaceParamsSchema = z.object({ + workspaceId: z.string().uuid(), +}); + +function parseJsonBody(body: unknown, schema: z.ZodSchema) { + if (typeof body !== 'string') { + throw new Error('Expected raw JSON request body.'); + } + + return schema.parse(JSON.parse(body) as unknown); +} + +async function requireBillingAdmin(request: FastifyRequest, reply: FastifyReply) { + if (!request.authUser || !isBillingAdminEmail(request.authUser.email)) { + return reply.code(403).send({ error: 'Billing admin access is required.' }); + } + + return undefined; +} + +export const billingRoutes: FastifyPluginAsync = async (app) => { + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_request, body, done) => { + done(null, body); + }); + + app.post('/billing/checkout/subscription', { preHandler: requireAuth }, async (request, reply) => { + try { + const payload = parseJsonBody<{ planCode: ActivePlanCode }>(request.body, subscriptionCheckoutSchema); + const result = await createSubscriptionCheckoutSession(getDbPool(), { + user: request.authUser!, + planCode: payload.planCode, + }); + + return result; + } catch (error) { + if (error instanceof SyntaxError || error instanceof ZodError) { + return reply.code(400).send({ error: 'Invalid subscription checkout payload.' }); + } + + request.log.error(error); + return reply.code(400).send({ error: error instanceof Error ? error.message : 'Failed to create subscription checkout session.' }); + } + }); + + app.post('/billing/checkout/addon', { preHandler: requireAuth }, async (request, reply) => { + try { + const payload = parseJsonBody<{ addonCode: AddonCode }>(request.body, addonCheckoutSchema); + const result = await createAddonCheckoutSession(getDbPool(), { + user: request.authUser!, + addonCode: payload.addonCode, + }); + + return result; + } catch (error) { + if (error instanceof SyntaxError || error instanceof ZodError) { + return reply.code(400).send({ error: 'Invalid add-on checkout payload.' }); + } + + request.log.error(error); + return reply.code(400).send({ error: error instanceof Error ? error.message : 'Failed to create add-on checkout session.' }); + } + }); + + app.post('/billing/portal', { preHandler: requireAuth }, async (request, reply) => { + try { + const result = await createBillingPortalSession(getDbPool(), { + user: request.authUser!, + }); + + return result; + } catch (error) { + request.log.error(error); + return reply.code(400).send({ error: error instanceof Error ? error.message : 'Failed to create billing portal session.' }); + } + }); + + app.get('/billing/debug', { preHandler: requireAuth }, async (request, reply) => { + try { + const workspace = await ensureWorkspaceForUser(getDbPool(), request.authUser!); + + if (!workspace) { + return reply.code(500).send({ error: 'Failed to resolve workspace.' }); + } + + const events = await listRecentWebhookEventsForWorkspace(getDbPool(), workspace.id); + return { events }; + } catch (error) { + request.log.error(error); + return reply.code(500).send({ error: 'Failed to load billing debug events.' }); + } + }); + + app.get('/admin/billing/workspaces', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => { + try { + const query = adminWorkspaceQuerySchema.parse(request.query ?? {}); + const workspaces = await listBillingAdminWorkspaceSummaries( + getDbPool(), + query.query ?? null, + query.limit ?? 50, + ); + return { workspaces }; + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid billing admin query.' }); + } + + request.log.error(error); + return reply.code(500).send({ error: 'Failed to load admin billing workspaces.' }); + } + }); + + app.get('/admin/billing/workspaces/:workspaceId', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => { + try { + const { workspaceId } = adminWorkspaceParamsSchema.parse(request.params); + const db = getDbPool(); + const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId); + + if (!summary) { + return reply.code(404).send({ error: 'Billing workspace not found.' }); + } + + const billing = await getWorkspaceBillingState(db, workspaceId); + const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId); + const usagePeriod = await ensureCurrentUsagePeriodForBillingAccount(db, billingAccount); + const timeline = await listRecentBillingTimelineEventsForWorkspace(db, workspaceId, 25); + const webhookEvents = await listRecentWebhookEventsForWorkspace(db, workspaceId, 25); + + return { + workspace: { + summary, + billing, + usagePeriodId: usagePeriod?.id ?? null, + timeline, + webhookEvents, + }, + }; + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid billing workspace id.' }); + } + + request.log.error(error); + return reply.code(500).send({ error: 'Failed to load admin billing workspace detail.' }); + } + }); + + app.post('/billing/webhooks/stripe', async (request, reply) => { + const signature = request.headers['stripe-signature']; + + if (!signature || Array.isArray(signature)) { + return reply.code(400).send({ error: 'Missing Stripe signature.' }); + } + + try { + if (typeof request.body !== 'string') { + return reply.code(400).send({ error: 'Expected raw Stripe webhook payload.' }); + } + + const event = constructStripeWebhookEvent(request.body, signature); + const result = await processStripeWebhookEvent(event); + return { received: true, status: result.status }; + } catch (error) { + request.log.error(error); + return reply.code(400).send({ error: error instanceof Error ? error.message : 'Failed to process Stripe webhook.' }); + } + }); +}; diff --git a/server/src/routes/deep-research.ts b/server/src/routes/deep-research.ts index 2a4b15c..08591b7 100644 --- a/server/src/routes/deep-research.ts +++ b/server/src/routes/deep-research.ts @@ -6,6 +6,7 @@ import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getW import { getDbPool } from '../db/pool.js'; import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, getDeepResearchBatchEstimate, listDeepResearchBatches } from '../deep-research/service.js'; import { previewDeepResearchForPoint } from '../postal/service.js'; +import { recordAnalyticsEvent } from '../analytics/service.js'; const previewSchema = z.object({ lat: z.number().finite().min(-90).max(90), @@ -53,6 +54,48 @@ export const deepResearchRoutes: FastifyPluginAsync = async (app) => { }); if (!enforcement.allowed) { + try { + await recordAnalyticsEvent(db, { + eventName: 'quota_exhausted_blocked', + eventSource: 'api', + userId: request.authUser!.id, + workspaceId: enforcementContext.workspaceId, + planCode: enforcement.decision.currentPlanCode, + resource: enforcement.decision.resource, + metadata: { + denialReason: enforcement.decision.denialReason, + action: enforcement.decision.action, + resource: enforcement.decision.resource, + currentPlan: enforcement.decision.currentPlanCode, + suggestedPlan: enforcement.decision.suggestedUpgradePlanCode, + }, + }); + } catch (analyticsError) { + request.log.warn(analyticsError, 'Failed to record quota block analytics event.'); + } + + if (enforcement.decision.denialReason === 'feature_not_available' || enforcement.decision.denialReason === 'not_launch_ready') { + try { + await recordAnalyticsEvent(db, { + eventName: 'feature_gate_encountered', + eventSource: 'api', + userId: request.authUser!.id, + workspaceId: enforcementContext.workspaceId, + planCode: enforcement.decision.currentPlanCode, + resource: enforcement.decision.resource, + metadata: { + denialReason: enforcement.decision.denialReason, + action: enforcement.decision.action, + resource: enforcement.decision.resource, + currentPlan: enforcement.decision.currentPlanCode, + suggestedPlan: enforcement.decision.suggestedUpgradePlanCode, + }, + }); + } catch (analyticsError) { + request.log.warn(analyticsError, 'Failed to record feature-gate analytics event.'); + } + } + const errorResponse = buildEntitlementErrorResponse(enforcement.decision); return reply.code(errorResponse.statusCode).send(errorResponse.body); } diff --git a/server/src/routes/search-jobs.ts b/server/src/routes/search-jobs.ts index 186a4d2..c285b70 100644 --- a/server/src/routes/search-jobs.ts +++ b/server/src/routes/search-jobs.ts @@ -6,6 +6,7 @@ import { getDbPool } from '../db/pool.js'; import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js'; import { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js'; import { runSearchForUser } from '../search/run-search.js'; +import { recordAnalyticsEvent } from '../analytics/service.js'; const runSearchSchema = z.object({ name: z.string().trim().min(1).max(160).optional(), @@ -41,6 +42,48 @@ export const searchJobRoutes: FastifyPluginAsync = async (app) => { }); if (!enforcement.allowed) { + try { + await recordAnalyticsEvent(db, { + eventName: 'quota_exhausted_blocked', + eventSource: 'api', + userId: request.authUser!.id, + workspaceId: enforcementContext.workspaceId, + planCode: enforcement.decision.currentPlanCode, + resource: enforcement.decision.resource, + metadata: { + denialReason: enforcement.decision.denialReason, + action: enforcement.decision.action, + resource: enforcement.decision.resource, + currentPlan: enforcement.decision.currentPlanCode, + suggestedPlan: enforcement.decision.suggestedUpgradePlanCode, + }, + }); + } catch (analyticsError) { + request.log.warn(analyticsError, 'Failed to record quota block analytics event.'); + } + + if (enforcement.decision.denialReason === 'feature_not_available' || enforcement.decision.denialReason === 'not_launch_ready') { + try { + await recordAnalyticsEvent(db, { + eventName: 'feature_gate_encountered', + eventSource: 'api', + userId: request.authUser!.id, + workspaceId: enforcementContext.workspaceId, + planCode: enforcement.decision.currentPlanCode, + resource: enforcement.decision.resource, + metadata: { + denialReason: enforcement.decision.denialReason, + action: enforcement.decision.action, + resource: enforcement.decision.resource, + currentPlan: enforcement.decision.currentPlanCode, + suggestedPlan: enforcement.decision.suggestedUpgradePlanCode, + }, + }); + } catch (analyticsError) { + request.log.warn(analyticsError, 'Failed to record feature-gate analytics event.'); + } + } + const errorResponse = buildEntitlementErrorResponse(enforcement.decision); return reply.code(errorResponse.statusCode).send(errorResponse.body); } diff --git a/shared/analytics/events.ts b/shared/analytics/events.ts new file mode 100644 index 0000000..c1121e9 --- /dev/null +++ b/shared/analytics/events.ts @@ -0,0 +1,30 @@ +export type AnalyticsEventName = + | 'pricing_plan_selected' + | 'checkout_started' + | 'checkout_completed' + | 'checkout_canceled' + | 'portal_opened' + | 'quota_warning_shown' + | 'quota_exhausted_blocked' + | 'feature_gate_encountered' + | 'addon_checkout_started' + | 'addon_purchase_completed' + | 'plan_changed' + | 'payment_failed' + | 'subscription_canceled'; + +export type AnalyticsEventSource = 'web_app' | 'api' | 'stripe_webhook' | 'system'; + +export interface AnalyticsEventInput { + eventName: AnalyticsEventName; + eventSource: AnalyticsEventSource; + userId?: string | null; + workspaceId?: string | null; + planCode?: string | null; + addonCode?: string | null; + resource?: string | null; + amount?: number | null; + currency?: string | null; + metadata?: Record; + occurredAt?: string; +} diff --git a/shared/billing/entitlements.ts b/shared/billing/entitlements.ts index 1f7779a..4688f56 100644 --- a/shared/billing/entitlements.ts +++ b/shared/billing/entitlements.ts @@ -24,7 +24,15 @@ export type EntitlementDecisionStatus = | 'blocked_addon_available' | 'contact_sales_required'; -export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready' | 'billing_not_configured'; +export type EntitlementDenialReason = + | 'feature_not_available' + | 'quota_exhausted' + | 'custom_enterprise_only' + | 'not_launch_ready' + | 'billing_not_configured' + | 'billing_past_due' + | 'billing_canceled' + | 'billing_inactive'; export interface UsageSubject { type: UsageSubjectType; diff --git a/shared/billing/lifecycle.ts b/shared/billing/lifecycle.ts new file mode 100644 index 0000000..746d174 --- /dev/null +++ b/shared/billing/lifecycle.ts @@ -0,0 +1,126 @@ +import type { AccountBillingStatus } from '../types.js'; +import type { PlanCode } from './plans.js'; + +export type BillingAccessMode = 'full' | 'degraded' | 'blocked'; + +export type BillingSyncStatus = 'ok' | 'stale' | 'error'; + +export type BillingTimelineEventType = + | 'checkout_completed' + | 'subscription_created' + | 'subscription_updated' + | 'subscription_deleted' + | 'invoice_paid' + | 'invoice_payment_failed' + | 'portal_returned' + | 'checkout_returned' + | 'addon_purchased' + | 'billing_status_changed' + | 'plan_change_scheduled'; + +export interface BillingLifecycleState { + status: AccountBillingStatus; + currentPeriodEndsAt: string | null; + cancelAtPeriodEnd: boolean; + gracePeriodEndsAt: string | null; +} + +export interface BillingAccessResolution { + accessMode: BillingAccessMode; + shouldWarn: boolean; + message: string | null; +} + +export const DEFAULT_BILLING_GRACE_PERIOD_DAYS = 7; +export const DEFAULT_BILLING_SYNC_STALE_AFTER_HOURS = 24; + +export function getDefaultBillingGracePeriodEndsAt(referenceDate: Date) { + const nextDate = new Date(referenceDate); + nextDate.setUTCDate(nextDate.getUTCDate() + DEFAULT_BILLING_GRACE_PERIOD_DAYS); + return nextDate.toISOString(); +} + +export function resolveBillingAccessState(state: BillingLifecycleState, referenceDate = new Date()): BillingAccessResolution { + switch (state.status) { + case 'active': + if (state.cancelAtPeriodEnd && state.currentPeriodEndsAt) { + return { + accessMode: 'full', + shouldWarn: true, + message: `Your subscription is scheduled to cancel on ${formatLifecycleDate(state.currentPeriodEndsAt)}.`, + }; + } + + return { + accessMode: 'full', + shouldWarn: false, + message: null, + }; + case 'past_due': { + if (state.gracePeriodEndsAt) { + const graceEndsAt = new Date(state.gracePeriodEndsAt); + if (!Number.isNaN(graceEndsAt.getTime()) && referenceDate <= graceEndsAt) { + return { + accessMode: 'degraded', + shouldWarn: true, + message: `Payment is overdue. Update billing before ${formatLifecycleDate(state.gracePeriodEndsAt)} to avoid blocked usage.`, + }; + } + } + + return { + accessMode: 'blocked', + shouldWarn: true, + message: 'Payment is overdue and the grace period has ended. Chargeable actions are blocked until billing is resolved.', + }; + } + case 'inactive': + return { + accessMode: 'blocked', + shouldWarn: true, + message: 'Billing is inactive. Start or restore a subscription to resume chargeable actions.', + }; + case 'canceled': + return { + accessMode: 'blocked', + shouldWarn: true, + message: 'This subscription is canceled. Reactivate billing to resume chargeable actions.', + }; + case 'not_configured': + return { + accessMode: 'blocked', + shouldWarn: true, + message: 'Billing is not configured for this workspace yet.', + }; + } +} + +export function isBillingSyncStale(lastStripeSyncAt: string | null, referenceDate = new Date()) { + if (!lastStripeSyncAt) { + return false; + } + + const syncedAt = new Date(lastStripeSyncAt); + if (Number.isNaN(syncedAt.getTime())) { + return false; + } + + const staleAfterMs = DEFAULT_BILLING_SYNC_STALE_AFTER_HOURS * 60 * 60 * 1000; + return referenceDate.getTime() - syncedAt.getTime() > staleAfterMs; +} + +export function getPendingPlanChangeMessage(pendingPlanCode: PlanCode | null, pendingPlanEffectiveAt: string | null) { + if (!pendingPlanCode || !pendingPlanEffectiveAt) { + return null; + } + + return `Plan change to ${pendingPlanCode} takes effect on ${formatLifecycleDate(pendingPlanEffectiveAt)}.`; +} + +export function formatLifecycleDate(value: string | null) { + if (!value) { + return 'Not set'; + } + + return new Date(value).toLocaleDateString(); +} diff --git a/shared/types.ts b/shared/types.ts index 0a6158f..3cb7874 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,5 +1,6 @@ import type { AddonCode, BillingInterval, PlanCode } from './billing/plans.js'; import type { UsageAllowanceAvailability, UsageResource } from './billing/entitlements.js'; +import type { BillingSyncStatus, BillingTimelineEventType } from './billing/lifecycle.js'; export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped'; @@ -50,6 +51,34 @@ export interface BillingAddonBalanceSummary { expiresAt: string | null; } +export interface BillingWebhookEventSummary { + id: string; + provider: 'stripe'; + externalEventId: string; + eventType: string; + status: 'received' | 'processed' | 'failed' | 'ignored'; + externalCustomerRef: string | null; + externalSubscriptionRef: string | null; + errorMessage: string | null; + receivedAt: string; + processedAt: string | null; +} + +export interface BillingTimelineEventSummary { + id: string; + workspaceId: string; + billingAccountId: string | null; + eventType: BillingTimelineEventType; + source: 'stripe' | 'app' | 'system'; + payloadJson: Record; + externalEventId: string | null; + externalCustomerRef: string | null; + externalSubscriptionRef: string | null; + occurredAt: string; + createdAt: string; + updatedAt: string; +} + export interface AccountBillingState { status: AccountBillingStatus; planCode: PlanCode | null; @@ -57,11 +86,83 @@ export interface AccountBillingState { currentPeriodStartsAt: string | null; currentPeriodEndsAt: string | null; cancelAtPeriodEnd: boolean; + canceledAt?: string | null; + trialEndsAt?: string | null; + gracePeriodEndsAt: string | null; + pendingPlanCode: PlanCode | null; + pendingPlanEffectiveAt: string | null; + billingSyncStatus: BillingSyncStatus; + lastStripeSyncAt: string | null; + provider: 'stripe' | null; + externalCustomerRef: string | null; + externalSubscriptionRef: string | null; usage: BillingUsageResourceSummary[]; addonBalances: BillingAddonBalanceSummary[]; message: string; } +export interface BillingCheckoutSessionResponse { + checkoutUrl: string; +} + +export interface BillingPortalSessionResponse { + url: string; +} + +export interface BillingDebugData { + events: BillingWebhookEventSummary[]; +} + +export interface BillingAdminWorkspaceSummary { + workspaceId: string; + workspaceName: string; + workspaceType: WorkspaceType; + memberCount: number; + status: AccountBillingStatus; + planCode: PlanCode | null; + billingInterval: BillingInterval | null; + currentPeriodEndsAt: string | null; + cancelAtPeriodEnd: boolean; + gracePeriodEndsAt: string | null; + pendingPlanCode: PlanCode | null; + pendingPlanEffectiveAt: string | null; + billingSyncStatus: BillingSyncStatus; + lastStripeSyncAt: string | null; + externalCustomerRef: string | null; + externalSubscriptionRef: string | null; +} + +export interface BillingAdminWorkspaceDetail { + summary: BillingAdminWorkspaceSummary; + billing: AccountBillingState; + usagePeriodId: string | null; + timeline: BillingTimelineEventSummary[]; + webhookEvents: BillingWebhookEventSummary[]; +} + +export interface BillingAdminWorkspaceListResponse { + workspaces: BillingAdminWorkspaceSummary[]; +} + +export interface BillingAdminWorkspaceDetailResponse { + workspace: BillingAdminWorkspaceDetail; +} + +export interface AnalyticsMetricBucket { + key: string; + count: number; +} + +export interface AdminAnalyticsSummary { + pricingConversionByPlan: AnalyticsMetricBucket[]; + quotaExhaustionEvents: AnalyticsMetricBucket[]; + upgradeTriggers: AnalyticsMetricBucket[]; + addonAttach: AnalyticsMetricBucket[]; + planMix: AnalyticsMetricBucket[]; + churnSignals: AnalyticsMetricBucket[]; + expansionSignals: AnalyticsMetricBucket[]; +} + export interface AccountTeamPlaceholder { canManageMembers: boolean; message: string; @@ -73,6 +174,7 @@ export interface AccountPageData { summary: AccountSummary; billing: AccountBillingState; team: AccountTeamPlaceholder; + isBillingAdmin?: boolean; } export interface UpdateAccountProfileRequest { diff --git a/src/App.tsx b/src/App.tsx index 4903eab..8175c2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,7 @@ import { PricingComparisonTable } from './components/PricingComparisonTable'; import { ResearchWorkspace } from './components/ResearchWorkspace'; import { ResultsWorkspace } from './components/ResultsWorkspace'; import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui'; -import type { BillingInterval } from '../shared/billing/plans'; +import type { BillingInterval, PlanCode } from '../shared/billing/plans'; import type { SessionUser } from '../shared/types'; import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth'; import { hasApiConfig } from './lib/api'; @@ -42,6 +42,7 @@ export default function App() { const [authNotice, setAuthNotice] = useState(null); const [isAuthenticating, setIsAuthenticating] = useState(false); const [authMode, setAuthMode] = useState<'sign_in' | 'sign_up'>('sign_in'); + const [billingIntentPlanCode, setBillingIntentPlanCode] = useState(null); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [displayName, setDisplayName] = useState(''); @@ -132,6 +133,9 @@ export default function App() { setUser(nextUser); setAuthNotice('Account created and signed in.'); + if (billingIntentPlanCode) { + setActiveTab('account'); + } return; } @@ -141,6 +145,9 @@ export default function App() { }); setUser(nextUser); + if (billingIntentPlanCode) { + setActiveTab('account'); + } navigatePublicPage('landing', setPublicPage); } catch (error) { setAuthError(error instanceof Error ? error.message : 'Authentication failed.'); @@ -217,13 +224,14 @@ export default function App() { ); } - return ( - { - handleSetAuthMode(mode); - navigatePublicPage('auth', setPublicPage); - }} - /> + return ( + { + handleSetAuthMode(mode); + setBillingIntentPlanCode(planCode ?? null); + navigatePublicPage('auth', setPublicPage); + }} + /> ); } @@ -279,6 +287,8 @@ export default function App() { setUser((currentUser) => (currentUser ? { ...nextUser, sessionId: currentUser.sessionId } : currentUser))} + initialCheckoutPlanCode={billingIntentPlanCode} + onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)} /> )} @@ -305,7 +315,7 @@ function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'lan } function LandingPage(props: { - onGoToAuth: (mode: 'sign_in' | 'sign_up') => void; + onGoToAuth: (mode: 'sign_in' | 'sign_up', planCode?: PlanCode) => void; }) { const { onGoToAuth } = props; const [pricingInterval, setPricingInterval] = useState>('monthly'); diff --git a/src/components/AccountPage.tsx b/src/components/AccountPage.tsx index 83bb649..1aa132a 100644 --- a/src/components/AccountPage.tsx +++ b/src/components/AccountPage.tsx @@ -1,13 +1,24 @@ import { AlertCircle, ArrowUpRight, Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react'; import { useEffect, useState } from 'react'; import { getEligibleAddonsForPlan } from '../../shared/billing/addons'; -import { getPlanByCode } from '../../shared/billing/plans'; -import type { AccountPageData, AppUser } from '../../shared/types'; -import { getAccountPageData, updateAccountProfile } from '../lib/account'; +import { formatLifecycleDate } from '../../shared/billing/lifecycle'; +import { getPlanByCode, getPublicPricingPlans, type PlanCode } from '../../shared/billing/plans'; +import type { AccountPageData, AppUser, BillingAdminWorkspaceDetail, BillingAdminWorkspaceSummary } from '../../shared/types'; +import { + createAddonCheckout, + createBillingPortalSession, + createSubscriptionCheckout, + getAccountPageData, + getAdminBillingWorkspaceDetail, + listAdminBillingWorkspaces, + updateAccountProfile, +} from '../lib/account'; import { formatBillingIntervalLabel, formatBillingStatusLabel, formatDateLabel, + formatPlanPeriod, + formatPlanPrice, formatQuantity, formatUsageResourceName, getBillingStatusBadgeVariant, @@ -17,22 +28,32 @@ import { getUsageWarningMessage, getUsageWarningState, } from '../lib/billing-ui'; +import { sendAnalyticsEvent } from '../lib/analytics'; import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui'; +const SALES_EMAIL = 'sales@localescope.app'; + interface AccountPageProps { user: AppUser; onUserUpdated: (user: AppUser) => void; + initialCheckoutPlanCode?: PlanCode | null; + onConsumeInitialCheckoutPlanCode?: () => void; } -export function AccountPage({ user, onUserUpdated }: AccountPageProps) { +export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = null, onConsumeInitialCheckoutPlanCode }: AccountPageProps) { const [account, setAccount] = useState(null); const [displayName, setDisplayName] = useState(user.displayName); const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl ?? ''); const [workspaceName, setWorkspaceName] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [billingAction, setBillingAction] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); + const [adminQuery, setAdminQuery] = useState(''); + const [adminWorkspaces, setAdminWorkspaces] = useState([]); + const [adminWorkspaceDetail, setAdminWorkspaceDetail] = useState(null); + const [adminLoading, setAdminLoading] = useState(false); useEffect(() => { let isMounted = true; @@ -52,6 +73,16 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { setDisplayName(nextAccount.profile.displayName); setAvatarUrl(nextAccount.profile.avatarUrl ?? ''); setWorkspaceName(nextAccount.workspace.name); + setNotice(getBillingReturnNotice()); + + if (nextAccount.isBillingAdmin) { + setAdminLoading(true); + const adminResponse = await listAdminBillingWorkspaces(); + if (isMounted) { + setAdminWorkspaces(adminResponse.workspaces); + } + setAdminLoading(false); + } } catch (nextError) { if (!isMounted) { return; @@ -72,6 +103,28 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { }; }, []); + useEffect(() => { + if (!notice || typeof window === 'undefined') { + return; + } + + const url = new URL(window.location.href); + if (url.searchParams.has('billing')) { + url.searchParams.delete('billing'); + window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`); + } + }, [notice]); + + useEffect(() => { + if (!account || !initialCheckoutPlanCode || billingAction !== null) { + return; + } + + void handlePlanAction(initialCheckoutPlanCode).finally(() => { + onConsumeInitialCheckoutPlanCode?.(); + }); + }, [account, initialCheckoutPlanCode, billingAction, onConsumeInitialCheckoutPlanCode]); + const handleSave = async () => { setSaving(true); setError(null); @@ -94,6 +147,122 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { } }; + const handleSubscriptionCheckout = async (planCode: string) => { + setBillingAction(`plan:${planCode}`); + setError(null); + setNotice(null); + + try { + sendAnalyticsEvent({ + eventName: 'checkout_started', + planCode, + }); + const response = await createSubscriptionCheckout(planCode); + window.location.assign(response.checkoutUrl); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to start checkout.'); + } finally { + setBillingAction(null); + } + }; + + const handleAddonCheckout = async (addonCode: string) => { + setBillingAction(`addon:${addonCode}`); + setError(null); + setNotice(null); + + try { + sendAnalyticsEvent({ + eventName: 'addon_checkout_started', + addonCode, + }); + const response = await createAddonCheckout(addonCode); + window.location.assign(response.checkoutUrl); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to start add-on checkout.'); + } finally { + setBillingAction(null); + } + }; + + const handleBillingPortal = async () => { + setBillingAction('portal'); + setError(null); + + try { + sendAnalyticsEvent({ + eventName: 'portal_opened', + }); + const response = await createBillingPortalSession(); + window.location.assign(response.url); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to open billing portal.'); + } finally { + setBillingAction(null); + } + }; + + const handlePlanAction = async (planCode: PlanCode) => { + const targetPlan = getPlanByCode(planCode); + + if (!targetPlan) { + setError('Selected plan is unavailable. Please refresh and try again.'); + return; + } + + if (targetPlan.contactSalesRequired || !targetPlan.isSelfServe) { + setError(null); + setNotice(`This plan is available through sales. Contact ${SALES_EMAIL} to coordinate your rollout.`); + if (typeof window !== 'undefined') { + window.location.assign(`mailto:${SALES_EMAIL}`); + } + return; + } + + if (account.billing.planCode === planCode && !account.billing.cancelAtPeriodEnd) { + setError(null); + setNotice('You are already on this plan.'); + return; + } + + const hasExternalPaidSubscription = Boolean(account.billing.externalCustomerRef && account.billing.externalSubscriptionRef); + if (hasExternalPaidSubscription && account.billing.planCode !== planCode) { + setNotice('Use the billing portal to change plans for your existing subscription.'); + await handleBillingPortal(); + return; + } + + await handleSubscriptionCheckout(planCode); + }; + + const handleLoadAdminWorkspace = async (workspaceId: string) => { + setAdminLoading(true); + setError(null); + + try { + const response = await getAdminBillingWorkspaceDetail(workspaceId); + setAdminWorkspaceDetail(response.workspace); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to load billing workspace detail.'); + } finally { + setAdminLoading(false); + } + }; + + const handleSearchAdminWorkspaces = async () => { + setAdminLoading(true); + setError(null); + + try { + const response = await listAdminBillingWorkspaces(adminQuery); + setAdminWorkspaces(response.workspaces); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to search billing workspaces.'); + } finally { + setAdminLoading(false); + } + }; + if (loading) { return ( @@ -118,6 +287,8 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { const suggestedUpgradePlanCode = getSuggestedUpgradePlanCode(account.billing.planCode, account.billing.billingInterval); const suggestedUpgradePlan = suggestedUpgradePlanCode ? getPlanByCode(suggestedUpgradePlanCode) : null; const eligibleAddons = activePlan ? getEligibleAddonsForPlan(activePlan.code, { includeComingSoon: true }) : []; + const publicPricingPlans = getPublicPricingPlans(); + const hasExternalPaidSubscription = Boolean(account.billing.externalCustomerRef && account.billing.externalSubscriptionRef); return ( @@ -233,8 +404,39 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { Current period ends {formatDateLabel(account.billing.currentPeriodEndsAt)} +
+ Grace period ends + {formatDateLabel(account.billing.gracePeriodEndsAt)} +
+
+ Stripe sync + {account.billing.billingSyncStatus} +

{account.billing.message}

+ {account.billing.pendingPlanCode && account.billing.pendingPlanEffectiveAt ? ( +
+ Pending change to {account.billing.pendingPlanCode} on {formatLifecycleDate(account.billing.pendingPlanEffectiveAt)}. +
+ ) : null} +
+ {account.billing.externalCustomerRef ? ( + + ) : null} + {suggestedUpgradePlan ? ( + + ) : null} +
{suggestedUpgradePlan ? (

Suggested next plan

@@ -243,7 +445,14 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {

{suggestedUpgradePlan.name}

Step up when you need more usage headroom or premium workflows.

- @@ -252,6 +461,50 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { ) : null} + +
+

Plan Options

+

Choose the right subscription

+
+
+ {publicPricingPlans.map((plan) => { + const isCurrentPlan = account.billing.planCode === plan.code && !account.billing.cancelAtPeriodEnd; + const buttonLabel = isCurrentPlan + ? 'Current plan' + : plan.contactSalesRequired + ? 'Contact sales' + : hasExternalPaidSubscription + ? 'Change in billing portal' + : `Choose ${plan.name}`; + + return ( +
+
+
+

{plan.name}

+

+ {formatPlanPrice(plan.priceCents, plan.currencyCode)} {formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)} +

+
+ +
+
+ ); + })} +
+
+
@@ -347,6 +600,20 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { {addon.purchaseMode === 'one_time' ? 'One-time' : 'Recurring'} {addon.quantity === null ? 'Feature add-on' : `${formatQuantity(addon.quantity)} units`}
+ {addon.availability === 'active' ? ( +
+ +
+ ) : null}
))} @@ -365,6 +632,72 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {

{account.team.message}

+ + {account.isBillingAdmin ? ( + +
+
+

Admin Billing

+

Support visibility

+
+ {adminLoading ? : null} +
+
+ setAdminQuery(event.target.value)} placeholder="Search workspace or Stripe id" /> + +
+
+ {adminWorkspaces.map((workspace) => ( + + ))} +
+ + {adminWorkspaceDetail ? ( +
+
+

{adminWorkspaceDetail.summary.workspaceName}

+

{adminWorkspaceDetail.summary.planCode || 'No plan'} · {adminWorkspaceDetail.summary.status}

+
+
+
Renewal: {formatDateLabel(adminWorkspaceDetail.billing.currentPeriodEndsAt)}
+
Grace ends: {formatDateLabel(adminWorkspaceDetail.billing.gracePeriodEndsAt)}
+
Subscription ref: {adminWorkspaceDetail.summary.externalSubscriptionRef || 'Not set'}
+
Usage period id: {adminWorkspaceDetail.usagePeriodId || 'Not active'}
+
+
+

Recent timeline

+
+ {adminWorkspaceDetail.timeline.map((entry) => ( +
+
+ {entry.eventType} + {formatDateLabel(entry.occurredAt)} +
+
+ ))} +
+
+
+ ) : null} +
+ ) : null} @@ -377,3 +710,22 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
); } + +function getBillingReturnNotice() { + if (typeof window === 'undefined') { + return null; + } + + const billing = new URL(window.location.href).searchParams.get('billing'); + + switch (billing) { + case 'success': + return 'Billing checkout completed. Subscription sync should appear here shortly.'; + case 'addon-success': + return 'Add-on purchase completed. New balance should appear here shortly.'; + case 'cancelled': + return 'Checkout was canceled before completion.'; + default: + return null; + } +} diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx index 9f84cad..4350d62 100644 --- a/src/components/PricingCards.tsx +++ b/src/components/PricingCards.tsx @@ -1,11 +1,12 @@ import { Check } from 'lucide-react'; -import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlansForInterval, type BillingInterval } from '../../shared/billing/plans'; +import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlansForInterval, type BillingInterval, type PlanCode } from '../../shared/billing/plans'; import { Button } from './ui'; import { formatPlanPeriod, formatPlanPrice } from '../lib/billing-ui'; +import { sendAnalyticsEvent } from '../lib/analytics'; interface PricingCardsProps { billingInterval: Extract; - onGoToAuth: (mode: 'sign_in' | 'sign_up') => void; + onGoToAuth: (mode: 'sign_in' | 'sign_up', planCode?: PlanCode) => void; } export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps) { @@ -45,7 +46,16 @@ export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps)