Public Access
1
0

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
This commit is contained in:
pguerrerox
2026-05-22 22:55:04 +00:00
parent 94b8c357b4
commit 5508e15da1
35 changed files with 2851 additions and 50 deletions
+1
View File
@@ -16,6 +16,7 @@ APP_PORT="4000"
APP_ORIGIN="http://localhost:3000" APP_ORIGIN="http://localhost:3000"
SESSION_TTL_DAYS="30" SESSION_TTL_DAYS="30"
GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY" GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
BILLING_ADMIN_EMAILS="ops@example.com"
# Docker Compose database env vars # Docker Compose database env vars
POSTGRES_DB="leads4less" POSTGRES_DB="leads4less"
+5
View File
@@ -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 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 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 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 ### 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. - 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. - 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. - 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. - 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] ## [2026-05-01]
+29
View File
@@ -18,6 +18,7 @@ LocaleScope is a React + Vite app for researching local markets, saving business
- `DATABASE_URL` - `DATABASE_URL`
- `COOKIE_SECRET` - `COOKIE_SECRET`
- `GOOGLE_MAPS_SERVER_KEY` - `GOOGLE_MAPS_SERVER_KEY`
- Stripe env vars below if you want to test billing locally
3. Run the frontend: 3. Run the frontend:
`npm run dev:web` `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: 4. Start the worker:
`npm run dev: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 ## Docker Deployment
1. Copy `.env.example` to `.env` and set at least: 1. Copy `.env.example` to `.env` and set at least:
+60 -27
View File
@@ -243,7 +243,7 @@
- [x] Add quota warning UX before hard exhaustion. - [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. - [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: 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: 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. - [ ] 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. - [ ] 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 ## 10) Payments Integration
- [ ] Choose billing provider (likely Stripe). - [x] Choose billing provider (Stripe).
- [ ] Map internal SKUs to external billing products/prices. - [x] Map internal SKUs to external billing products/prices.
- [ ] Support subscriptions, annual billing, add-ons, and enterprise/manual invoicing. - [x] Support subscriptions, annual billing, add-ons, and enterprise/manual invoicing.
- [ ] Define webhook handling for subscription state changes. - [x] Define webhook handling for subscription state changes.
- [ ] Define downgrade, cancellation, retry, and grace-period behavior. - [ ] 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.
- [ ] Add internal admin visibility for billing state. - [ ] 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 ## 11) Post-Payments Hardening & Admin Visibility
- [ ] Decide whether to launch founder LTD at all. - [ ] Define explicit downgrade behavior:
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers). - effective timing for scheduled vs immediate plan changes
- [ ] Define founder SKUs: - entitlement/usage treatment when the target plan is below current usage
- Founder Plan = $249 one-time - account messaging for pending downgrade state
- Founder Pro = $499 one-time - [x] Define explicit cancellation behavior:
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API. - end-of-period vs immediate access policy
- [ ] Define which future features are excluded from LTD plans. - 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 ## 12) Analytics, Ops, and Revenue Instrumentation
- [ ] Track pricing-page conversion by plan. - [x] Track pricing-page conversion by plan.
- [ ] Track quota exhaustion events. - [x] Track quota exhaustion events.
- [ ] Track upgrade triggers: - [x] Track upgrade triggers:
- export limit hit - export limit hit
- research limit hit - research limit hit
- feature-gate encounter - feature-gate encounter
- [ ] Track add-on attach rate. - [x] Track add-on attach rate.
- [ ] Track plan mix, churn, expansion revenue, and annual conversion. - [x] Track plan mix, churn, expansion revenue, and annual conversion.
- [ ] Add internal dashboards for billing and usage health. - [x] Add internal dashboards for billing and usage health.
## 13) Operational Enforcement Follow-Up ## 13) Operational Enforcement Follow-Up
- [ ] Add queue prioritization by plan tier. - [ ] 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: 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. - [ ] 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 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model.
- [ ] Phase 2: implement usage ledger and backend enforcement. - [ ] Phase 2: implement usage ledger and backend enforcement.
- [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises. - [ ] 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 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 5: finalize add-on strategy before wiring payment products.
- [ ] Phase 6: integrate payments and subscription lifecycle handling. - [ ] 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 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 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 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 ## Recommended Execution Order
- [ ] Next: `#10 Payments Integration` - [ ] Next: `#11 Post-Payments Hardening & Admin Visibility`
- [ ] Then: `#11 Founder / LTD Strategy` - [ ] Then: `#13 Analytics, Ops, and Revenue Instrumentation`
- [ ] Then: `#12 Analytics, Ops, and Revenue Instrumentation` - [ ] Then: collaboration, API, enrichment, and enterprise feature maturation from `#15 Rollout Plan`
- [ ] Keep `#13 Operational Enforcement Follow-Up` deferred until async worker routing, backend exports, or higher-volume execution patterns make it necessary. - [ ] 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 ## Open Questions
- [ ] Will research capacity be marketed as runs, credits, or both? - [ ] Will research capacity be marketed as runs, credits, or both?
+28
View File
@@ -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();
@@ -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();
+20
View File
@@ -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);
+18
View File
@@ -24,6 +24,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"stream-json": "^2.1.0", "stream-json": "^2.1.0",
"stripe": "^22.1.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"vite": "^6.2.0", "vite": "^6.2.0",
"zod": "^4.3.6" "zod": "^4.3.6"
@@ -3276,6 +3277,23 @@
"url": "https://github.com/sponsors/uhop" "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": { "node_modules/tagged-tag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+1
View File
@@ -38,6 +38,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"stream-json": "^2.1.0", "stream-json": "^2.1.0",
"stripe": "^22.1.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"vite": "^6.2.0", "vite": "^6.2.0",
"zod": "^4.3.6" "zod": "^4.3.6"
+2
View File
@@ -1,6 +1,7 @@
import type { Pool, PoolClient } from 'pg'; import type { Pool, PoolClient } from 'pg';
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js'; import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
import { getWorkspaceBillingState } from '../billing/service.js'; import { getWorkspaceBillingState } from '../billing/service.js';
import { isBillingAdminEmail } from '../config/env.js';
type DbClient = Pool | PoolClient; type DbClient = Pool | PoolClient;
@@ -164,6 +165,7 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin', canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
message: 'Workspace member management is coming soon.', message: 'Workspace member management is coming soon.',
}, },
isBillingAdmin: isBillingAdminEmail(user.email),
}; };
} }
+105
View File
@@ -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<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, 'event_name', `event_name`);
}
export function listPricingPlanSelectionCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(plan_code, metadata_json->>'planCode', 'unknown')`, `event_name = 'pricing_plan_selected'`);
}
export function listQuotaExhaustionCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(resource, metadata_json->>'resource', 'unknown')`, `event_name = 'quota_exhausted_blocked'`);
}
export function listUpgradeTriggerCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
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<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(addon_code, metadata_json->>'addonCode', 'unknown')`, `event_name = 'addon_purchase_completed'`);
}
export function listPlanMixCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
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<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `event_name`, `event_name in ('subscription_canceled', 'payment_failed')`);
}
export function listExpansionSignalCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
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<AnalyticsMetricBucket[]> {
const result = await db.query<AnalyticsBucketRow>(
`
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),
}));
}
+53
View File
@@ -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<AdminAnalyticsSummary> {
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,
};
}
+4
View File
@@ -5,8 +5,10 @@ import { getEnv } from './config/env.js';
import { deepResearchRoutes } from './routes/deep-research.js'; import { deepResearchRoutes } from './routes/deep-research.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { accountRoutes } from './routes/account.js'; import { accountRoutes } from './routes/account.js';
import { billingRoutes } from './routes/billing.js';
import { healthRoutes } from './routes/health.js'; import { healthRoutes } from './routes/health.js';
import { searchJobRoutes } from './routes/search-jobs.js'; import { searchJobRoutes } from './routes/search-jobs.js';
import { analyticsRoutes } from './routes/analytics.js';
function parseAllowedOrigins(rawOrigins: string) { function parseAllowedOrigins(rawOrigins: string) {
return rawOrigins return rawOrigins
@@ -50,8 +52,10 @@ export async function buildApp() {
await app.register(healthRoutes, { prefix: '/api' }); await app.register(healthRoutes, { prefix: '/api' });
await app.register(authRoutes, { prefix: '/api' }); await app.register(authRoutes, { prefix: '/api' });
await app.register(accountRoutes, { prefix: '/api' }); await app.register(accountRoutes, { prefix: '/api' });
await app.register(billingRoutes, { prefix: '/api' });
await app.register(searchJobRoutes, { prefix: '/api' }); await app.register(searchJobRoutes, { prefix: '/api' });
await app.register(deepResearchRoutes, { prefix: '/api' }); await app.register(deepResearchRoutes, { prefix: '/api' });
await app.register(analyticsRoutes, { prefix: '/api' });
return app; return app;
} }
+46
View File
@@ -1,4 +1,5 @@
import type { Pool, PoolClient } from 'pg'; import type { Pool, PoolClient } from 'pg';
import { resolveBillingAccessState } from '../../../shared/billing/lifecycle.js';
import type { AccountBillingState } from '../../../shared/types.js'; import type { AccountBillingState } from '../../../shared/types.js';
import type { EntitlementDecision, UsageAction, UsageAmount, UsageCostEstimate, UsageResource } from '../../../shared/billing/entitlements.js'; import type { EntitlementDecision, UsageAction, UsageAmount, UsageCostEstimate, UsageResource } from '../../../shared/billing/entitlements.js';
import { evaluateActionEntitlement, isActivePlanCodeForEntitlements } 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) { if (!input.costEstimate.isChargeable) {
return { return {
allowed: true, allowed: true,
@@ -165,6 +191,12 @@ function formatEntitlementErrorMessage(decision: EntitlementDecision) {
switch (decision.denialReason) { switch (decision.denialReason) {
case 'billing_not_configured': case 'billing_not_configured':
return 'A billing plan is required before this action can run.'; 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': case 'feature_not_available':
return 'Your current plan does not include this feature.'; return 'Your current plan does not include this feature.';
case 'not_launch_ready': 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 // Export policy exists in shared billing modules, but route-level export enforcement
// stays deferred until export generation moves to a backend endpoint. // stays deferred until export generation moves to a backend endpoint.
+283 -1
View File
@@ -1,5 +1,6 @@
import type { Pool, PoolClient } from 'pg'; import type { Pool, PoolClient } from 'pg';
import type { AddonCode, BillingInterval, PlanCode } from '../../../shared/billing/plans.js'; 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 { AccountBillingStatus, BillingAddonBalanceSummary } from '../../../shared/types.js';
import type { UsageResource } from '../../../shared/billing/entitlements.js'; import type { UsageResource } from '../../../shared/billing/entitlements.js';
@@ -16,12 +17,32 @@ export interface BillingAccountRecord {
cancelAtPeriodEnd: boolean; cancelAtPeriodEnd: boolean;
canceledAt: string | null; canceledAt: string | null;
trialEndsAt: string | null; trialEndsAt: string | null;
gracePeriodEndsAt: string | null;
pendingPlanCode: PlanCode | null;
pendingPlanEffectiveAt: string | null;
billingSyncStatus: BillingSyncStatus;
lastStripeSyncAt: string | null;
externalCustomerRef: string | null; externalCustomerRef: string | null;
externalSubscriptionRef: string | null; externalSubscriptionRef: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface BillingTimelineEventRecord {
id: string;
workspaceId: string;
billingAccountId: string | null;
eventType: BillingTimelineEventType;
source: 'stripe' | 'app' | 'system';
payloadJson: Record<string, unknown>;
externalEventId: string | null;
externalCustomerRef: string | null;
externalSubscriptionRef: string | null;
occurredAt: string;
createdAt: string;
updatedAt: string;
}
export interface UsagePeriodRecord { export interface UsagePeriodRecord {
id: string; id: string;
workspaceId: string; workspaceId: string;
@@ -67,12 +88,51 @@ type BillingAccountRow = {
cancel_at_period_end: boolean; cancel_at_period_end: boolean;
canceled_at: string | null; canceled_at: string | null;
trial_ends_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_customer_ref: string | null;
external_subscription_ref: string | null; external_subscription_ref: string | null;
created_at: string; created_at: string;
updated_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<string, unknown>;
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 = { type UsagePeriodRow = {
id: string; id: string;
workspace_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, select id, workspace_id, plan_code, billing_interval, status,
current_period_starts_at, current_period_ends_at, current_period_starts_at, current_period_ends_at,
cancel_at_period_end, canceled_at, trial_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, external_customer_ref, external_subscription_ref,
created_at, updated_at created_at, updated_at
from public.workspace_billing_accounts 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, returning id, workspace_id, plan_code, billing_interval, status,
current_period_starts_at, current_period_ends_at, current_period_starts_at, current_period_ends_at,
cancel_at_period_end, canceled_at, trial_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, external_customer_ref, external_subscription_ref,
created_at, updated_at created_at, updated_at
`, `,
@@ -187,6 +251,11 @@ export async function updateBillingAccountState(
cancelAtPeriodEnd?: boolean; cancelAtPeriodEnd?: boolean;
canceledAt?: string | null; canceledAt?: string | null;
trialEndsAt?: string | null; trialEndsAt?: string | null;
gracePeriodEndsAt?: string | null;
pendingPlanCode?: PlanCode | null;
pendingPlanEffectiveAt?: string | null;
billingSyncStatus?: BillingSyncStatus;
lastStripeSyncAt?: string | null;
externalCustomerRef?: string | null; externalCustomerRef?: string | null;
externalSubscriptionRef?: string | null; externalSubscriptionRef?: string | null;
}, },
@@ -203,10 +272,15 @@ export async function updateBillingAccountState(
cancel_at_period_end, cancel_at_period_end,
canceled_at, canceled_at,
trial_ends_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_customer_ref,
external_subscription_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) on conflict (workspace_id)
do update set do update set
plan_code = excluded.plan_code, plan_code = excluded.plan_code,
@@ -217,11 +291,18 @@ export async function updateBillingAccountState(
cancel_at_period_end = excluded.cancel_at_period_end, cancel_at_period_end = excluded.cancel_at_period_end,
canceled_at = excluded.canceled_at, canceled_at = excluded.canceled_at,
trial_ends_at = excluded.trial_ends_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_customer_ref = excluded.external_customer_ref,
external_subscription_ref = excluded.external_subscription_ref external_subscription_ref = excluded.external_subscription_ref
returning id, workspace_id, plan_code, billing_interval, status, returning id, workspace_id, plan_code, billing_interval, status,
current_period_starts_at, current_period_ends_at, current_period_starts_at, current_period_ends_at,
cancel_at_period_end, canceled_at, trial_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, external_customer_ref, external_subscription_ref,
created_at, updated_at created_at, updated_at
`, `,
@@ -235,6 +316,11 @@ export async function updateBillingAccountState(
input.cancelAtPeriodEnd ?? false, input.cancelAtPeriodEnd ?? false,
input.canceledAt ?? null, input.canceledAt ?? null,
input.trialEndsAt ?? null, input.trialEndsAt ?? null,
input.gracePeriodEndsAt ?? null,
input.pendingPlanCode ?? null,
input.pendingPlanEffectiveAt ?? null,
input.billingSyncStatus ?? 'ok',
input.lastStripeSyncAt ?? null,
input.externalCustomerRef ?? null, input.externalCustomerRef ?? null,
input.externalSubscriptionRef ?? 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<string, unknown>;
externalEventId?: string | null;
externalCustomerRef?: string | null;
externalSubscriptionRef?: string | null;
occurredAt?: string;
},
) {
const result = await db.query<BillingTimelineEventRow>(
`
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<BillingTimelineEventRow>(
`
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<BillingAdminWorkspaceSummaryRow>(
`
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<BillingAdminWorkspaceSummaryRow>(
`
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( export async function recordAddonPurchase(
db: DbClient, db: DbClient,
input: { input: {
@@ -448,6 +708,11 @@ function mapBillingAccountRow(row: BillingAccountRow): BillingAccountRecord {
cancelAtPeriodEnd: row.cancel_at_period_end, cancelAtPeriodEnd: row.cancel_at_period_end,
canceledAt: row.canceled_at, canceledAt: row.canceled_at,
trialEndsAt: row.trial_ends_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, externalCustomerRef: row.external_customer_ref,
externalSubscriptionRef: row.external_subscription_ref, externalSubscriptionRef: row.external_subscription_ref,
createdAt: row.created_at, 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) { async function bootstrapDefaultBillingAccountState(db: DbClient, workspaceId: string) {
const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds(); const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds();
+44 -3
View File
@@ -1,5 +1,6 @@
import type { Pool, PoolClient } from 'pg'; import type { Pool, PoolClient } from 'pg';
import { getUsageAllowanceForPlan, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js'; 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 { BillingAddonBalanceSummary, BillingUsageResourceSummary, AccountBillingState } from '../../../shared/types.js';
import type { UsageResource } from '../../../shared/billing/entitlements.js'; import type { UsageResource } from '../../../shared/billing/entitlements.js';
import { import {
@@ -24,6 +25,16 @@ export async function getWorkspaceBillingState(db: DbClient, workspaceId: string
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt, currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt, currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd, 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: [], usage: [],
addonBalances: await listAddonBalancesForWorkspace(db, workspaceId), 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.', 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, currentPeriodStartsAt: usageSnapshot.currentPeriodStartsAt,
currentPeriodEndsAt: usageSnapshot.currentPeriodEndsAt, currentPeriodEndsAt: usageSnapshot.currentPeriodEndsAt,
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd, 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, usage: usageSnapshot.usage,
addonBalances, addonBalances,
message: billingAccount.status === 'active' message: buildBillingAccountMessage(billingAccount),
? '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.',
}; };
} }
@@ -191,3 +210,25 @@ function minDate(a: Date, b: Date) {
export async function getWorkspaceAddonBalances(db: DbClient, workspaceId: string): Promise<BillingAddonBalanceSummary[]> { export async function getWorkspaceAddonBalances(db: DbClient, workspaceId: string): Promise<BillingAddonBalanceSummary[]> {
return listAddonBalancesForWorkspace(db, workspaceId); 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(' ');
}
+27
View File
@@ -26,6 +26,19 @@ const envSchema = z.object({
GOOGLE_MAPS_SERVER_KEY: z.string().optional(), GOOGLE_MAPS_SERVER_KEY: z.string().optional(),
PG_BOSS_SCHEMA: z.string().default('pgboss'), PG_BOSS_SCHEMA: z.string().default('pgboss'),
SESSION_TTL_DAYS: z.coerce.number().int().positive().default(30), 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<typeof envSchema>; export type AppEnv = z.infer<typeof envSchema>;
@@ -40,3 +53,17 @@ export function getEnv(): AppEnv {
cachedEnv = envSchema.parse(process.env); cachedEnv = envSchema.parse(process.env);
return cachedEnv; 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());
}
+106
View File
@@ -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<ActivePlanCode, 'enterprise_custom'>;
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<SupportedSubscriptionPlanCode, string | undefined> = {
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<SupportedAddonCode, string | undefined> = {
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;
}
+166
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
},
) {
const result = await db.query<BillingWebhookEventRow>(
`
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<BillingWebhookEventRow>(
`
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<BillingWebhookEventStatus, 'processed' | 'ignored'>) {
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<BillingWebhookEventRow>(
`
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,
};
}
+395
View File
@@ -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;
}
+27
View File
@@ -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;
}
+298
View File
@@ -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<string, unknown>,
});
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<string, unknown> }).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<typeof recordAnalyticsEvent>[1]) {
try {
await recordAnalyticsEvent(db, input);
} catch {
return;
}
}
+92
View File
@@ -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.' });
}
});
};
+198
View File
@@ -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<T>(body: unknown, schema: z.ZodSchema<T>) {
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.' });
}
});
};
+43
View File
@@ -6,6 +6,7 @@ import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getW
import { getDbPool } from '../db/pool.js'; import { getDbPool } from '../db/pool.js';
import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, getDeepResearchBatchEstimate, listDeepResearchBatches } from '../deep-research/service.js'; import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, getDeepResearchBatchEstimate, listDeepResearchBatches } from '../deep-research/service.js';
import { previewDeepResearchForPoint } from '../postal/service.js'; import { previewDeepResearchForPoint } from '../postal/service.js';
import { recordAnalyticsEvent } from '../analytics/service.js';
const previewSchema = z.object({ const previewSchema = z.object({
lat: z.number().finite().min(-90).max(90), lat: z.number().finite().min(-90).max(90),
@@ -53,6 +54,48 @@ export const deepResearchRoutes: FastifyPluginAsync = async (app) => {
}); });
if (!enforcement.allowed) { 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); const errorResponse = buildEntitlementErrorResponse(enforcement.decision);
return reply.code(errorResponse.statusCode).send(errorResponse.body); return reply.code(errorResponse.statusCode).send(errorResponse.body);
} }
+43
View File
@@ -6,6 +6,7 @@ import { getDbPool } from '../db/pool.js';
import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js'; import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js';
import { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js'; import { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js';
import { runSearchForUser } from '../search/run-search.js'; import { runSearchForUser } from '../search/run-search.js';
import { recordAnalyticsEvent } from '../analytics/service.js';
const runSearchSchema = z.object({ const runSearchSchema = z.object({
name: z.string().trim().min(1).max(160).optional(), name: z.string().trim().min(1).max(160).optional(),
@@ -41,6 +42,48 @@ export const searchJobRoutes: FastifyPluginAsync = async (app) => {
}); });
if (!enforcement.allowed) { 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); const errorResponse = buildEntitlementErrorResponse(enforcement.decision);
return reply.code(errorResponse.statusCode).send(errorResponse.body); return reply.code(errorResponse.statusCode).send(errorResponse.body);
} }
+30
View File
@@ -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<string, unknown>;
occurredAt?: string;
}
+9 -1
View File
@@ -24,7 +24,15 @@ export type EntitlementDecisionStatus =
| 'blocked_addon_available' | 'blocked_addon_available'
| 'contact_sales_required'; | '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 { export interface UsageSubject {
type: UsageSubjectType; type: UsageSubjectType;
+126
View File
@@ -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();
}
+102
View File
@@ -1,5 +1,6 @@
import type { AddonCode, BillingInterval, PlanCode } from './billing/plans.js'; import type { AddonCode, BillingInterval, PlanCode } from './billing/plans.js';
import type { UsageAllowanceAvailability, UsageResource } from './billing/entitlements.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'; export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
@@ -50,6 +51,34 @@ export interface BillingAddonBalanceSummary {
expiresAt: string | null; 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<string, unknown>;
externalEventId: string | null;
externalCustomerRef: string | null;
externalSubscriptionRef: string | null;
occurredAt: string;
createdAt: string;
updatedAt: string;
}
export interface AccountBillingState { export interface AccountBillingState {
status: AccountBillingStatus; status: AccountBillingStatus;
planCode: PlanCode | null; planCode: PlanCode | null;
@@ -57,11 +86,83 @@ export interface AccountBillingState {
currentPeriodStartsAt: string | null; currentPeriodStartsAt: string | null;
currentPeriodEndsAt: string | null; currentPeriodEndsAt: string | null;
cancelAtPeriodEnd: boolean; 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[]; usage: BillingUsageResourceSummary[];
addonBalances: BillingAddonBalanceSummary[]; addonBalances: BillingAddonBalanceSummary[];
message: string; 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 { export interface AccountTeamPlaceholder {
canManageMembers: boolean; canManageMembers: boolean;
message: string; message: string;
@@ -73,6 +174,7 @@ export interface AccountPageData {
summary: AccountSummary; summary: AccountSummary;
billing: AccountBillingState; billing: AccountBillingState;
team: AccountTeamPlaceholder; team: AccountTeamPlaceholder;
isBillingAdmin?: boolean;
} }
export interface UpdateAccountProfileRequest { export interface UpdateAccountProfileRequest {
+13 -3
View File
@@ -24,7 +24,7 @@ import { PricingComparisonTable } from './components/PricingComparisonTable';
import { ResearchWorkspace } from './components/ResearchWorkspace'; import { ResearchWorkspace } from './components/ResearchWorkspace';
import { ResultsWorkspace } from './components/ResultsWorkspace'; import { ResultsWorkspace } from './components/ResultsWorkspace';
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui'; 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 type { SessionUser } from '../shared/types';
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth'; import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
import { hasApiConfig } from './lib/api'; import { hasApiConfig } from './lib/api';
@@ -42,6 +42,7 @@ export default function App() {
const [authNotice, setAuthNotice] = useState<string | null>(null); const [authNotice, setAuthNotice] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthenticating, setIsAuthenticating] = useState(false);
const [authMode, setAuthMode] = useState<'sign_in' | 'sign_up'>('sign_in'); const [authMode, setAuthMode] = useState<'sign_in' | 'sign_up'>('sign_in');
const [billingIntentPlanCode, setBillingIntentPlanCode] = useState<PlanCode | null>(null);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
@@ -132,6 +133,9 @@ export default function App() {
setUser(nextUser); setUser(nextUser);
setAuthNotice('Account created and signed in.'); setAuthNotice('Account created and signed in.');
if (billingIntentPlanCode) {
setActiveTab('account');
}
return; return;
} }
@@ -141,6 +145,9 @@ export default function App() {
}); });
setUser(nextUser); setUser(nextUser);
if (billingIntentPlanCode) {
setActiveTab('account');
}
navigatePublicPage('landing', setPublicPage); navigatePublicPage('landing', setPublicPage);
} catch (error) { } catch (error) {
setAuthError(error instanceof Error ? error.message : 'Authentication failed.'); setAuthError(error instanceof Error ? error.message : 'Authentication failed.');
@@ -219,8 +226,9 @@ export default function App() {
return ( return (
<LandingPage <LandingPage
onGoToAuth={(mode) => { onGoToAuth={(mode, planCode) => {
handleSetAuthMode(mode); handleSetAuthMode(mode);
setBillingIntentPlanCode(planCode ?? null);
navigatePublicPage('auth', setPublicPage); navigatePublicPage('auth', setPublicPage);
}} }}
/> />
@@ -279,6 +287,8 @@ export default function App() {
<AccountPage <AccountPage
user={user} user={user}
onUserUpdated={(nextUser) => setUser((currentUser) => (currentUser ? { ...nextUser, sessionId: currentUser.sessionId } : currentUser))} onUserUpdated={(nextUser) => setUser((currentUser) => (currentUser ? { ...nextUser, sessionId: currentUser.sessionId } : currentUser))}
initialCheckoutPlanCode={billingIntentPlanCode}
onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)}
/> />
)} )}
</Layout> </Layout>
@@ -305,7 +315,7 @@ function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'lan
} }
function LandingPage(props: { function LandingPage(props: {
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void; onGoToAuth: (mode: 'sign_in' | 'sign_up', planCode?: PlanCode) => void;
}) { }) {
const { onGoToAuth } = props; const { onGoToAuth } = props;
const [pricingInterval, setPricingInterval] = useState<Extract<BillingInterval, 'monthly' | 'annual'>>('monthly'); const [pricingInterval, setPricingInterval] = useState<Extract<BillingInterval, 'monthly' | 'annual'>>('monthly');
+357 -5
View File
@@ -1,13 +1,24 @@
import { AlertCircle, ArrowUpRight, Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react'; import { AlertCircle, ArrowUpRight, Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getEligibleAddonsForPlan } from '../../shared/billing/addons'; import { getEligibleAddonsForPlan } from '../../shared/billing/addons';
import { getPlanByCode } from '../../shared/billing/plans'; import { formatLifecycleDate } from '../../shared/billing/lifecycle';
import type { AccountPageData, AppUser } from '../../shared/types'; import { getPlanByCode, getPublicPricingPlans, type PlanCode } from '../../shared/billing/plans';
import { getAccountPageData, updateAccountProfile } from '../lib/account'; import type { AccountPageData, AppUser, BillingAdminWorkspaceDetail, BillingAdminWorkspaceSummary } from '../../shared/types';
import {
createAddonCheckout,
createBillingPortalSession,
createSubscriptionCheckout,
getAccountPageData,
getAdminBillingWorkspaceDetail,
listAdminBillingWorkspaces,
updateAccountProfile,
} from '../lib/account';
import { import {
formatBillingIntervalLabel, formatBillingIntervalLabel,
formatBillingStatusLabel, formatBillingStatusLabel,
formatDateLabel, formatDateLabel,
formatPlanPeriod,
formatPlanPrice,
formatQuantity, formatQuantity,
formatUsageResourceName, formatUsageResourceName,
getBillingStatusBadgeVariant, getBillingStatusBadgeVariant,
@@ -17,22 +28,32 @@ import {
getUsageWarningMessage, getUsageWarningMessage,
getUsageWarningState, getUsageWarningState,
} from '../lib/billing-ui'; } from '../lib/billing-ui';
import { sendAnalyticsEvent } from '../lib/analytics';
import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui'; import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui';
const SALES_EMAIL = 'sales@localescope.app';
interface AccountPageProps { interface AccountPageProps {
user: AppUser; user: AppUser;
onUserUpdated: (user: AppUser) => void; 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<AccountPageData | null>(null); const [account, setAccount] = useState<AccountPageData | null>(null);
const [displayName, setDisplayName] = useState(user.displayName); const [displayName, setDisplayName] = useState(user.displayName);
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl ?? ''); const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl ?? '');
const [workspaceName, setWorkspaceName] = useState(''); const [workspaceName, setWorkspaceName] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [billingAction, setBillingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [adminQuery, setAdminQuery] = useState('');
const [adminWorkspaces, setAdminWorkspaces] = useState<BillingAdminWorkspaceSummary[]>([]);
const [adminWorkspaceDetail, setAdminWorkspaceDetail] = useState<BillingAdminWorkspaceDetail | null>(null);
const [adminLoading, setAdminLoading] = useState(false);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -52,6 +73,16 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
setDisplayName(nextAccount.profile.displayName); setDisplayName(nextAccount.profile.displayName);
setAvatarUrl(nextAccount.profile.avatarUrl ?? ''); setAvatarUrl(nextAccount.profile.avatarUrl ?? '');
setWorkspaceName(nextAccount.workspace.name); setWorkspaceName(nextAccount.workspace.name);
setNotice(getBillingReturnNotice());
if (nextAccount.isBillingAdmin) {
setAdminLoading(true);
const adminResponse = await listAdminBillingWorkspaces();
if (isMounted) {
setAdminWorkspaces(adminResponse.workspaces);
}
setAdminLoading(false);
}
} catch (nextError) { } catch (nextError) {
if (!isMounted) { if (!isMounted) {
return; 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 () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setError(null); 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) { if (loading) {
return ( return (
<PageShell> <PageShell>
@@ -118,6 +287,8 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
const suggestedUpgradePlanCode = getSuggestedUpgradePlanCode(account.billing.planCode, account.billing.billingInterval); const suggestedUpgradePlanCode = getSuggestedUpgradePlanCode(account.billing.planCode, account.billing.billingInterval);
const suggestedUpgradePlan = suggestedUpgradePlanCode ? getPlanByCode(suggestedUpgradePlanCode) : null; const suggestedUpgradePlan = suggestedUpgradePlanCode ? getPlanByCode(suggestedUpgradePlanCode) : null;
const eligibleAddons = activePlan ? getEligibleAddonsForPlan(activePlan.code, { includeComingSoon: true }) : []; const eligibleAddons = activePlan ? getEligibleAddonsForPlan(activePlan.code, { includeComingSoon: true }) : [];
const publicPricingPlans = getPublicPricingPlans();
const hasExternalPaidSubscription = Boolean(account.billing.externalCustomerRef && account.billing.externalSubscriptionRef);
return ( return (
<PageShell> <PageShell>
@@ -233,8 +404,39 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
<span>Current period ends</span> <span>Current period ends</span>
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.currentPeriodEndsAt)}</span> <span className="font-medium text-stone-900">{formatDateLabel(account.billing.currentPeriodEndsAt)}</span>
</div> </div>
<div className="flex items-center justify-between gap-3">
<span>Grace period ends</span>
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.gracePeriodEndsAt)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Stripe sync</span>
<span className="font-medium text-stone-900">{account.billing.billingSyncStatus}</span>
</div>
</div> </div>
<p className="mt-4 text-sm text-stone-600">{account.billing.message}</p> <p className="mt-4 text-sm text-stone-600">{account.billing.message}</p>
{account.billing.pendingPlanCode && account.billing.pendingPlanEffectiveAt ? (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
Pending change to <span className="font-semibold">{account.billing.pendingPlanCode}</span> on {formatLifecycleDate(account.billing.pendingPlanEffectiveAt)}.
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-3">
{account.billing.externalCustomerRef ? (
<Button type="button" variant="secondary" onClick={() => void handleBillingPortal()} disabled={billingAction !== null}>
{billingAction === 'portal' ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Manage billing
</Button>
) : null}
{suggestedUpgradePlan ? (
<Button
type="button"
onClick={() => void handlePlanAction(suggestedUpgradePlan.code)}
disabled={billingAction !== null}
>
{billingAction === `plan:${suggestedUpgradePlan.code}` ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUpRight className="h-4 w-4" />}
Upgrade to {suggestedUpgradePlan.name}
</Button>
) : null}
</div>
{suggestedUpgradePlan ? ( {suggestedUpgradePlan ? (
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4"> <div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
<p className="text-sm font-semibold text-emerald-900">Suggested next plan</p> <p className="text-sm font-semibold text-emerald-900">Suggested next plan</p>
@@ -243,7 +445,14 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
<p className="text-base font-semibold text-emerald-950">{suggestedUpgradePlan.name}</p> <p className="text-base font-semibold text-emerald-950">{suggestedUpgradePlan.name}</p>
<p className="text-sm text-emerald-800">Step up when you need more usage headroom or premium workflows.</p> <p className="text-sm text-emerald-800">Step up when you need more usage headroom or premium workflows.</p>
</div> </div>
<Button type="button" size="sm" className="shrink-0"> <Button
type="button"
size="sm"
className="shrink-0"
onClick={() => void handlePlanAction(suggestedUpgradePlan.code)}
disabled={billingAction !== null}
>
{billingAction === `plan:${suggestedUpgradePlan.code}` ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Upgrade Upgrade
<ArrowUpRight className="h-4 w-4" /> <ArrowUpRight className="h-4 w-4" />
</Button> </Button>
@@ -252,6 +461,50 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
) : null} ) : null}
</Card> </Card>
<Card className="p-6">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Plan Options</p>
<h3 className="text-lg font-semibold text-stone-950">Choose the right subscription</h3>
</div>
<div className="mt-4 space-y-3">
{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 (
<div key={plan.code} className="rounded-2xl border border-stone-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-stone-900">{plan.name}</p>
<p className="mt-1 text-xs text-stone-500">
{formatPlanPrice(plan.priceCents, plan.currencyCode)} {formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)}
</p>
</div>
<Button
type="button"
size="sm"
variant={isCurrentPlan ? 'secondary' : 'primary'}
onClick={() => void handlePlanAction(plan.code)}
disabled={isCurrentPlan || billingAction !== null}
>
{billingAction === `plan:${plan.code}` || (billingAction === 'portal' && hasExternalPaidSubscription && !plan.contactSalesRequired && !isCurrentPlan)
? <Loader2 className="h-4 w-4 animate-spin" />
: null}
{buttonLabel}
</Button>
</div>
</div>
);
})}
</div>
</Card>
<Card className="p-6"> <Card className="p-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
@@ -347,6 +600,20 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
<Badge variant="info">{addon.purchaseMode === 'one_time' ? 'One-time' : 'Recurring'}</Badge> <Badge variant="info">{addon.purchaseMode === 'one_time' ? 'One-time' : 'Recurring'}</Badge>
<Badge>{addon.quantity === null ? 'Feature add-on' : `${formatQuantity(addon.quantity)} units`}</Badge> <Badge>{addon.quantity === null ? 'Feature add-on' : `${formatQuantity(addon.quantity)} units`}</Badge>
</div> </div>
{addon.availability === 'active' ? (
<div className="mt-3">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void handleAddonCheckout(addon.code)}
disabled={billingAction !== null}
>
{billingAction === `addon:${addon.code}` ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Buy add-on
</Button>
</div>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -365,6 +632,72 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
</div> </div>
<p className="mt-4 text-sm text-stone-600">{account.team.message}</p> <p className="mt-4 text-sm text-stone-600">{account.team.message}</p>
</Card> </Card>
{account.isBillingAdmin ? (
<Card className="p-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Admin Billing</p>
<h3 className="text-lg font-semibold text-stone-950">Support visibility</h3>
</div>
{adminLoading ? <Loader2 className="h-4 w-4 animate-spin text-stone-500" /> : null}
</div>
<div className="mt-4 flex gap-3">
<Input value={adminQuery} onChange={(event) => setAdminQuery(event.target.value)} placeholder="Search workspace or Stripe id" />
<Button type="button" variant="secondary" onClick={() => void handleSearchAdminWorkspaces()} disabled={adminLoading}>
Search
</Button>
</div>
<div className="mt-4 space-y-3">
{adminWorkspaces.map((workspace) => (
<button
key={workspace.workspaceId}
type="button"
onClick={() => void handleLoadAdminWorkspace(workspace.workspaceId)}
className="w-full rounded-2xl border border-stone-200 p-4 text-left transition hover:border-stone-300 hover:bg-stone-50"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-stone-900">{workspace.workspaceName}</p>
<p className="text-xs text-stone-500">{workspace.planCode || 'No plan'} · {workspace.status}</p>
</div>
<Badge variant={workspace.billingSyncStatus === 'error' ? 'danger' : workspace.billingSyncStatus === 'stale' ? 'warning' : 'success'}>
{workspace.billingSyncStatus}
</Badge>
</div>
</button>
))}
</div>
{adminWorkspaceDetail ? (
<div className="mt-6 space-y-4 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm">
<div>
<p className="font-semibold text-stone-900">{adminWorkspaceDetail.summary.workspaceName}</p>
<p className="text-stone-600">{adminWorkspaceDetail.summary.planCode || 'No plan'} · {adminWorkspaceDetail.summary.status}</p>
</div>
<div className="grid gap-2 text-stone-600">
<div>Renewal: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.currentPeriodEndsAt)}</span></div>
<div>Grace ends: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.gracePeriodEndsAt)}</span></div>
<div>Subscription ref: <span className="font-medium text-stone-900">{adminWorkspaceDetail.summary.externalSubscriptionRef || 'Not set'}</span></div>
<div>Usage period id: <span className="font-medium text-stone-900">{adminWorkspaceDetail.usagePeriodId || 'Not active'}</span></div>
</div>
<div>
<p className="font-semibold text-stone-900">Recent timeline</p>
<div className="mt-2 space-y-2">
{adminWorkspaceDetail.timeline.map((entry) => (
<div key={entry.id} className="rounded-xl border border-stone-200 bg-white px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-stone-900">{entry.eventType}</span>
<span className="text-xs text-stone-500">{formatDateLabel(entry.occurredAt)}</span>
</div>
</div>
))}
</div>
</div>
</div>
) : null}
</Card>
) : null}
</div> </div>
</div> </div>
@@ -377,3 +710,22 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
</PageShell> </PageShell>
); );
} }
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;
}
}
+13 -3
View File
@@ -1,11 +1,12 @@
import { Check } from 'lucide-react'; 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 { Button } from './ui';
import { formatPlanPeriod, formatPlanPrice } from '../lib/billing-ui'; import { formatPlanPeriod, formatPlanPrice } from '../lib/billing-ui';
import { sendAnalyticsEvent } from '../lib/analytics';
interface PricingCardsProps { interface PricingCardsProps {
billingInterval: Extract<BillingInterval, 'monthly' | 'annual'>; billingInterval: Extract<BillingInterval, 'monthly' | 'annual'>;
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void; onGoToAuth: (mode: 'sign_in' | 'sign_up', planCode?: PlanCode) => void;
} }
export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps) { export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps) {
@@ -45,7 +46,16 @@ export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps)
<Button <Button
type="button" type="button"
onClick={() => onGoToAuth(display.ctaMode)} onClick={() => {
sendAnalyticsEvent({
eventName: 'pricing_plan_selected',
planCode: plan.code,
metadata: {
billingInterval,
},
});
onGoToAuth(display.ctaMode, plan.code);
}}
className={`mt-8 w-full rounded-2xl ${isFeatured ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`} className={`mt-8 w-full rounded-2xl ${isFeatured ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
variant={isFeatured ? 'primary' : 'secondary'} variant={isFeatured ? 'primary' : 'secondary'}
> >
+42 -1
View File
@@ -1,4 +1,12 @@
import type { AccountPageData, UpdateAccountProfileRequest } from '../../shared/types'; import type {
AccountPageData,
BillingAdminWorkspaceDetailResponse,
BillingAdminWorkspaceListResponse,
BillingCheckoutSessionResponse,
BillingDebugData,
BillingPortalSessionResponse,
UpdateAccountProfileRequest,
} from '../../shared/types';
import { apiRequest } from './api'; import { apiRequest } from './api';
export async function getAccountPageData() { export async function getAccountPageData() {
@@ -14,3 +22,36 @@ export async function updateAccountProfile(payload: UpdateAccountProfileRequest)
return response.account; return response.account;
} }
export async function createSubscriptionCheckout(planCode: string) {
return apiRequest<BillingCheckoutSessionResponse>('/billing/checkout/subscription', {
method: 'POST',
body: JSON.stringify({ planCode }),
});
}
export async function createAddonCheckout(addonCode: string) {
return apiRequest<BillingCheckoutSessionResponse>('/billing/checkout/addon', {
method: 'POST',
body: JSON.stringify({ addonCode }),
});
}
export async function createBillingPortalSession() {
return apiRequest<BillingPortalSessionResponse>('/billing/portal', {
method: 'POST',
});
}
export async function getBillingDebugData() {
return apiRequest<BillingDebugData>('/billing/debug');
}
export async function listAdminBillingWorkspaces(query?: string) {
const search = query?.trim() ? `?query=${encodeURIComponent(query.trim())}` : '';
return apiRequest<BillingAdminWorkspaceListResponse>(`/admin/billing/workspaces${search}`);
}
export async function getAdminBillingWorkspaceDetail(workspaceId: string) {
return apiRequest<BillingAdminWorkspaceDetailResponse>(`/admin/billing/workspaces/${workspaceId}`);
}
+12
View File
@@ -0,0 +1,12 @@
import type { AnalyticsEventInput } from '../../shared/analytics/events';
import { apiRequest } from './api';
export function sendAnalyticsEvent(event: Omit<AnalyticsEventInput, 'eventSource'> & { eventSource?: AnalyticsEventInput['eventSource'] }) {
void apiRequest<{ ok: boolean }>('/analytics/events', {
method: 'POST',
body: JSON.stringify({
...event,
eventSource: event.eventSource ?? 'web_app',
}),
}).catch(() => undefined);
}