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:
@@ -16,6 +16,7 @@ APP_PORT="4000"
|
||||
APP_ORIGIN="http://localhost:3000"
|
||||
SESSION_TTL_DAYS="30"
|
||||
GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
|
||||
BILLING_ADMIN_EMAILS="ops@example.com"
|
||||
|
||||
# Docker Compose database env vars
|
||||
POSTGRES_DB="leads4less"
|
||||
|
||||
@@ -13,6 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- Added workspace-scoped billing foundation storage, repository/service layers, and add-on catalog definitions for billing accounts, usage periods, usage counters, purchases, and balances.
|
||||
- Added backend entitlement enforcement for basic search and deep research routes using shared workspace billing state and reusable usage-cost estimation.
|
||||
- Added workspace-readiness reference material plus billing-aware account usage, add-on, and upgrade surfaces to keep plan promises honest during the workspace migration phase.
|
||||
- Added Stripe payments foundations with checkout, billing-portal, and webhook processing routes plus idempotent webhook event storage for subscription sync and export-pack fulfillment.
|
||||
- Added billing lifecycle hardening data and support workflows, including Stripe sync metadata, grace-period support, workspace billing timeline events, and billing-admin workspace visibility endpoints.
|
||||
- Added billing and revenue instrumentation via a shared analytics event pipeline, API/webhook event emitters, and an admin analytics summary endpoint for pricing conversion, quota pressure, churn, and expansion signals.
|
||||
|
||||
### Changed
|
||||
- Normalized the product UI around shared design primitives for buttons, cards, alerts, tabs, and page shells to keep public, auth, research, results, dashboard, map, and account surfaces visually aligned.
|
||||
@@ -21,6 +24,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- Expanded the pricing experience with monthly/annual plan toggles, interval-specific pricing cards, and a comparison table derived from canonical billing metadata.
|
||||
- Bootstrapped existing and new workspaces into a pre-payments Starter billing state so usage tracking and enforcement can run before subscription automation exists.
|
||||
- Updated dashboard, map, and results copy to describe saved businesses and research outputs instead of lead-focused terminology.
|
||||
- Replaced account-page billing placeholders with Stripe-backed upgrade, add-on purchase, and billing-management actions while keeping enterprise on a manual sales path.
|
||||
- Hardened entitlement enforcement to treat past-due subscriptions as grace-window access before blocking chargeable actions, with clearer lifecycle messaging in account and API responses.
|
||||
|
||||
## [2026-05-01]
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ LocaleScope is a React + Vite app for researching local markets, saving business
|
||||
- `DATABASE_URL`
|
||||
- `COOKIE_SECRET`
|
||||
- `GOOGLE_MAPS_SERVER_KEY`
|
||||
- Stripe env vars below if you want to test billing locally
|
||||
3. Run the frontend:
|
||||
`npm run dev:web`
|
||||
|
||||
@@ -35,6 +36,34 @@ If you open the app from another machine on your LAN, set `VITE_API_BASE_URL` an
|
||||
4. Start the worker:
|
||||
`npm run dev:worker`
|
||||
|
||||
## Stripe Billing Setup
|
||||
|
||||
Stripe is now the active payments integration for self-serve subscriptions and one-time export packs.
|
||||
|
||||
Configure these server-side env vars to enable billing routes:
|
||||
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `STRIPE_PRICE_STARTER_MONTHLY`
|
||||
- `STRIPE_PRICE_STARTER_ANNUAL`
|
||||
- `STRIPE_PRICE_GROWTH_MONTHLY`
|
||||
- `STRIPE_PRICE_GROWTH_ANNUAL`
|
||||
- `STRIPE_PRICE_PRO_MONTHLY`
|
||||
- `STRIPE_PRICE_PRO_ANNUAL`
|
||||
- `STRIPE_PRICE_EXPORT_PACK_10K`
|
||||
- `STRIPE_PRICE_EXPORT_PACK_50K`
|
||||
- `STRIPE_BILLING_PORTAL_CONFIGURATION_ID` optional
|
||||
- `BILLING_ADMIN_EMAILS` optional comma-separated allowlist for internal billing admin access
|
||||
|
||||
Notes:
|
||||
|
||||
- The internal catalog in `shared/billing/plans.ts` and `shared/billing/addons.ts` remains canonical. Stripe price IDs are environment-specific mappings only.
|
||||
- Apply migrations before testing Stripe webhooks so `billing_webhook_events` exists: `npm run migrate`
|
||||
- Enterprise stays on a manual sales/invoicing path and does not use self-serve checkout.
|
||||
- Stripe lifecycle hardening now treats `past_due` subscriptions as a grace-window state before chargeable actions hard-block. The default grace window is 7 days.
|
||||
- Billing return notices now appear on the account page for completed and canceled checkout flows.
|
||||
- Internal billing support visibility is available through `/api/admin/billing/workspaces` for allowlisted admin emails.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
1. Copy `.env.example` to `.env` and set at least:
|
||||
|
||||
+60
-27
@@ -243,7 +243,7 @@
|
||||
- [x] Add quota warning UX before hard exhaustion.
|
||||
- [x] Keep migration-dependent collaboration messaging honest by surfacing included-but-not-ready capabilities as `Coming soon` instead of pretending they are fully live.
|
||||
- [ ] Future note: the pricing comparison table should stay aligned with workspace-readiness decisions as collaboration and shared asset features move toward workspace ownership.
|
||||
- [ ] Future note: upgrade CTAs are present, but actual checkout/subscription management should remain tied to the future payments integration step.
|
||||
- [ ] Future note: upgrade CTAs are present, but actual checkout/subscription management should remain tied to the post-payments hardening step.
|
||||
- [ ] Future note: pricing and account UX should keep users included, workspace limits, and collaboration-adjacent promises explicitly soft-gated until workspace-owned shared data and hard enforcement are ready.
|
||||
- [ ] Future note: replace placeholder upgrade CTAs in the account billing UI with a real upgrade path, pricing-page jump, contact-sales flow, or explicit `coming soon` behavior before broader rollout.
|
||||
|
||||
@@ -262,32 +262,54 @@
|
||||
- [ ] Future note: recurring feature add-ons should not be sold until the underlying capabilities and subscription management flows exist end-to-end.
|
||||
|
||||
## 10) Payments Integration
|
||||
- [ ] Choose billing provider (likely Stripe).
|
||||
- [ ] Map internal SKUs to external billing products/prices.
|
||||
- [ ] Support subscriptions, annual billing, add-ons, and enterprise/manual invoicing.
|
||||
- [ ] Define webhook handling for subscription state changes.
|
||||
- [ ] Define downgrade, cancellation, retry, and grace-period behavior.
|
||||
- [ ] Add internal admin visibility for billing state.
|
||||
- [x] Choose billing provider (Stripe).
|
||||
- [x] Map internal SKUs to external billing products/prices.
|
||||
- [x] Support subscriptions, annual billing, add-ons, and enterprise/manual invoicing.
|
||||
- [x] Define webhook handling for subscription state changes.
|
||||
- [ ] Future note: Stripe is now the active integration path; keep the internal plan/add-on catalog as the canonical packaging source and treat Stripe price IDs as environment-specific mappings.
|
||||
- [ ] Future note: the current customer-facing integration supports self-serve subscriptions, export-pack checkout, and the Stripe billing portal, while enterprise invoicing remains a manual sales workflow.
|
||||
- [ ] Future note: webhook idempotency currently relies on the `billing_webhook_events` store; keep Stripe event processing centralized there as billing lifecycle coverage expands.
|
||||
- [ ] Future note: post-payments hardening should tighten downgrade, cancellation, retry, and grace-period policy before broad rollout so Stripe portal actions and runtime entitlements stay aligned.
|
||||
|
||||
## 11) Founder / LTD Strategy
|
||||
- [ ] Decide whether to launch founder LTD at all.
|
||||
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
|
||||
- [ ] Define founder SKUs:
|
||||
- Founder Plan = $249 one-time
|
||||
- Founder Pro = $499 one-time
|
||||
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
|
||||
- [ ] Define which future features are excluded from LTD plans.
|
||||
## 11) Post-Payments Hardening & Admin Visibility
|
||||
- [ ] Define explicit downgrade behavior:
|
||||
- effective timing for scheduled vs immediate plan changes
|
||||
- entitlement/usage treatment when the target plan is below current usage
|
||||
- account messaging for pending downgrade state
|
||||
- [x] Define explicit cancellation behavior:
|
||||
- end-of-period vs immediate access policy
|
||||
- post-cancellation account state and reactivation path
|
||||
- handling for active add-on balances and usage windows
|
||||
- [x] Define explicit payment retry and grace-period behavior:
|
||||
- which Stripe states map to degraded vs blocked access
|
||||
- grace-period duration and user-facing messaging
|
||||
- when usage actions should warn, soft-block, or hard-block
|
||||
- [x] Wire pricing-page CTAs to real billing actions:
|
||||
- self-serve paid plans -> Stripe checkout
|
||||
- active paid workspaces -> plan-change or billing-portal path
|
||||
- enterprise plans -> contact-sales path
|
||||
- [x] Add account redirect notices after Stripe return:
|
||||
- successful checkout/portal return confirmation
|
||||
- canceled or incomplete checkout messaging
|
||||
- failed or unresolved billing-return guidance
|
||||
- [x] Expand internal admin billing visibility for operational maintenance and debugging:
|
||||
- show workspace billing summary with current plan, interval, status, renewal date, cancel-at-period-end, and trial/grace-period state
|
||||
- show current usage period and counters for research, exports, add-ons, and remaining balances
|
||||
- show recent Stripe/customer/subscription identifiers and latest webhook processing outcomes
|
||||
- show recent billing timeline entries for checkout, subscription changes, payment failures, cancellations, and add-on purchases
|
||||
- flag workspaces with stale billing sync, failed webhook processing, or status mismatches needing support follow-up
|
||||
- document the minimum admin view(s) needed so support can verify billing state without direct database inspection
|
||||
|
||||
## 12) Analytics, Ops, and Revenue Instrumentation
|
||||
- [ ] Track pricing-page conversion by plan.
|
||||
- [ ] Track quota exhaustion events.
|
||||
- [ ] Track upgrade triggers:
|
||||
- [x] Track pricing-page conversion by plan.
|
||||
- [x] Track quota exhaustion events.
|
||||
- [x] Track upgrade triggers:
|
||||
- export limit hit
|
||||
- research limit hit
|
||||
- feature-gate encounter
|
||||
- [ ] Track add-on attach rate.
|
||||
- [ ] Track plan mix, churn, expansion revenue, and annual conversion.
|
||||
- [ ] Add internal dashboards for billing and usage health.
|
||||
- [x] Track add-on attach rate.
|
||||
- [x] Track plan mix, churn, expansion revenue, and annual conversion.
|
||||
- [x] Add internal dashboards for billing and usage health.
|
||||
|
||||
## 13) Operational Enforcement Follow-Up
|
||||
- [ ] Add queue prioritization by plan tier.
|
||||
@@ -299,23 +321,34 @@
|
||||
- [ ] Future note: export enforcement remains deferred until CSV/export generation moves to a backend endpoint.
|
||||
- [ ] Future note: enrichment-route enforcement remains deferred until enrichment actions/routes are implemented.
|
||||
|
||||
## 14) Rollout Plan
|
||||
## 14) Founder / LTD Strategy
|
||||
- [ ] Decide whether to launch founder LTD at all.
|
||||
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
|
||||
- [ ] Define founder SKUs:
|
||||
- Founder Plan = $249 one-time
|
||||
- Founder Pro = $499 one-time
|
||||
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
|
||||
- [ ] Define which future features are excluded from LTD plans.
|
||||
|
||||
## 15) Rollout Plan
|
||||
- [ ] Phase 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model.
|
||||
- [ ] Phase 2: implement usage ledger and backend enforcement.
|
||||
- [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises.
|
||||
- [ ] Phase 4: update pricing page and account/billing UI based on the workspace/collaboration readiness decisions.
|
||||
- [ ] Phase 5: finalize add-on strategy before wiring payment products.
|
||||
- [ ] Phase 6: integrate payments and subscription lifecycle handling.
|
||||
- [ ] Phase 7: decide and implement founder/LTD strategy only after the core subscription path is stable.
|
||||
- [ ] Phase 7: harden post-payments lifecycle handling, wire real billing CTAs, and add pragmatic admin billing visibility before broader commercialization work.
|
||||
- [ ] Phase 8: expand analytics, ops, and revenue instrumentation around the live billing and upgrade flows.
|
||||
- [ ] Phase 9: launch collaboration, API, enrichment, and enterprise features as architecture matures.
|
||||
- [ ] Phase 10: complete deferred operational enforcement work such as queue prioritization, throttling, and backend export enforcement when runtime scale justifies it.
|
||||
- [ ] Phase 11: decide and implement founder/LTD strategy only after the app/site, billing lifecycle, admin/support visibility, analytics, and broader product maturity work are in place.
|
||||
|
||||
## Recommended Execution Order
|
||||
- [ ] Next: `#10 Payments Integration`
|
||||
- [ ] Then: `#11 Founder / LTD Strategy`
|
||||
- [ ] Then: `#12 Analytics, Ops, and Revenue Instrumentation`
|
||||
- [ ] Keep `#13 Operational Enforcement Follow-Up` deferred until async worker routing, backend exports, or higher-volume execution patterns make it necessary.
|
||||
- [ ] Next: `#11 Post-Payments Hardening & Admin Visibility`
|
||||
- [ ] Then: `#13 Analytics, Ops, and Revenue Instrumentation`
|
||||
- [ ] Then: collaboration, API, enrichment, and enterprise feature maturation from `#15 Rollout Plan`
|
||||
- [ ] Keep `#14 Operational Enforcement Follow-Up` deferred until async worker routing, backend exports, or higher-volume execution patterns make it necessary.
|
||||
- [ ] Last: `#12 Founder / LTD Strategy` once the app/site, billing lifecycle, admin/support visibility, analytics, and broader product maturity work are in place.
|
||||
|
||||
## Open Questions
|
||||
- [ ] Will research capacity be marketed as runs, credits, or both?
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
Generated
+18
@@ -24,6 +24,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"stream-json": "^2.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vite": "^6.2.0",
|
||||
"zod": "^4.3.6"
|
||||
@@ -3276,6 +3277,23 @@
|
||||
"url": "https://github.com/sponsors/uhop"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "22.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
|
||||
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"stream-json": "^2.1.0",
|
||||
"stripe": "^22.1.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vite": "^6.2.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
|
||||
import { getWorkspaceBillingState } from '../billing/service.js';
|
||||
import { isBillingAdminEmail } from '../config/env.js';
|
||||
|
||||
type DbClient = Pool | PoolClient;
|
||||
|
||||
@@ -164,6 +165,7 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise
|
||||
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
|
||||
message: 'Workspace member management is coming soon.',
|
||||
},
|
||||
isBillingAdmin: isBillingAdminEmail(user.email),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import { getEnv } from './config/env.js';
|
||||
import { deepResearchRoutes } from './routes/deep-research.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { accountRoutes } from './routes/account.js';
|
||||
import { billingRoutes } from './routes/billing.js';
|
||||
import { healthRoutes } from './routes/health.js';
|
||||
import { searchJobRoutes } from './routes/search-jobs.js';
|
||||
import { analyticsRoutes } from './routes/analytics.js';
|
||||
|
||||
function parseAllowedOrigins(rawOrigins: string) {
|
||||
return rawOrigins
|
||||
@@ -50,8 +52,10 @@ export async function buildApp() {
|
||||
await app.register(healthRoutes, { prefix: '/api' });
|
||||
await app.register(authRoutes, { prefix: '/api' });
|
||||
await app.register(accountRoutes, { prefix: '/api' });
|
||||
await app.register(billingRoutes, { prefix: '/api' });
|
||||
await app.register(searchJobRoutes, { prefix: '/api' });
|
||||
await app.register(deepResearchRoutes, { prefix: '/api' });
|
||||
await app.register(analyticsRoutes, { prefix: '/api' });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { resolveBillingAccessState } from '../../../shared/billing/lifecycle.js';
|
||||
import type { AccountBillingState } from '../../../shared/types.js';
|
||||
import type { EntitlementDecision, UsageAction, UsageAmount, UsageCostEstimate, UsageResource } from '../../../shared/billing/entitlements.js';
|
||||
import { evaluateActionEntitlement, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js';
|
||||
@@ -73,6 +74,31 @@ export async function checkActionEntitlementForWorkspace(
|
||||
};
|
||||
}
|
||||
|
||||
const billingAccess = resolveBillingAccessState({
|
||||
status: billing.status,
|
||||
currentPeriodEndsAt: billing.currentPeriodEndsAt,
|
||||
cancelAtPeriodEnd: billing.cancelAtPeriodEnd,
|
||||
gracePeriodEndsAt: billing.gracePeriodEndsAt,
|
||||
});
|
||||
|
||||
if (input.costEstimate.isChargeable && billingAccess.accessMode === 'blocked') {
|
||||
return {
|
||||
allowed: false,
|
||||
decision: {
|
||||
status: 'blocked_upgrade_required',
|
||||
denialReason: mapBillingStatusToDenialReason(billing.status),
|
||||
action: input.action,
|
||||
resource: getPrimaryUsageAmount(input.costEstimate)?.resource ?? 'research_credits',
|
||||
requiredAmount: getPrimaryUsageAmount(input.costEstimate)?.amount ?? 0,
|
||||
remainingAmount: 0,
|
||||
currentPlanCode: billing.planCode,
|
||||
suggestedUpgradePlanCode: billing.planCode,
|
||||
addonEligible: false,
|
||||
contactSalesRequired: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.costEstimate.isChargeable) {
|
||||
return {
|
||||
allowed: true,
|
||||
@@ -165,6 +191,12 @@ function formatEntitlementErrorMessage(decision: EntitlementDecision) {
|
||||
switch (decision.denialReason) {
|
||||
case 'billing_not_configured':
|
||||
return 'A billing plan is required before this action can run.';
|
||||
case 'billing_past_due':
|
||||
return 'Payment is overdue and billing access is currently blocked.';
|
||||
case 'billing_canceled':
|
||||
return 'This subscription is canceled. Reactivate billing to continue.';
|
||||
case 'billing_inactive':
|
||||
return 'Billing is inactive for this workspace.';
|
||||
case 'feature_not_available':
|
||||
return 'Your current plan does not include this feature.';
|
||||
case 'not_launch_ready':
|
||||
@@ -178,5 +210,19 @@ function formatEntitlementErrorMessage(decision: EntitlementDecision) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBillingStatusToDenialReason(status: AccountBillingState['status']) {
|
||||
switch (status) {
|
||||
case 'past_due':
|
||||
return 'billing_past_due' as const;
|
||||
case 'canceled':
|
||||
return 'billing_canceled' as const;
|
||||
case 'inactive':
|
||||
case 'not_configured':
|
||||
return 'billing_inactive' as const;
|
||||
case 'active':
|
||||
return 'billing_inactive' as const;
|
||||
}
|
||||
}
|
||||
|
||||
// Export policy exists in shared billing modules, but route-level export enforcement
|
||||
// stays deferred until export generation moves to a backend endpoint.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { AddonCode, BillingInterval, PlanCode } from '../../../shared/billing/plans.js';
|
||||
import type { BillingSyncStatus, BillingTimelineEventType } from '../../../shared/billing/lifecycle.js';
|
||||
import type { AccountBillingStatus, BillingAddonBalanceSummary } from '../../../shared/types.js';
|
||||
import type { UsageResource } from '../../../shared/billing/entitlements.js';
|
||||
|
||||
@@ -16,12 +17,32 @@ export interface BillingAccountRecord {
|
||||
cancelAtPeriodEnd: boolean;
|
||||
canceledAt: string | null;
|
||||
trialEndsAt: string | null;
|
||||
gracePeriodEndsAt: string | null;
|
||||
pendingPlanCode: PlanCode | null;
|
||||
pendingPlanEffectiveAt: string | null;
|
||||
billingSyncStatus: BillingSyncStatus;
|
||||
lastStripeSyncAt: string | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BillingTimelineEventRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
billingAccountId: string | null;
|
||||
eventType: BillingTimelineEventType;
|
||||
source: 'stripe' | 'app' | 'system';
|
||||
payloadJson: Record<string, unknown>;
|
||||
externalEventId: string | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
occurredAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UsagePeriodRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
@@ -67,12 +88,51 @@ type BillingAccountRow = {
|
||||
cancel_at_period_end: boolean;
|
||||
canceled_at: string | null;
|
||||
trial_ends_at: string | null;
|
||||
grace_period_ends_at: string | null;
|
||||
pending_plan_code: string | null;
|
||||
pending_plan_effective_at: string | null;
|
||||
billing_sync_status: BillingSyncStatus;
|
||||
last_stripe_sync_at: string | null;
|
||||
external_customer_ref: string | null;
|
||||
external_subscription_ref: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type BillingTimelineEventRow = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
billing_account_id: string | null;
|
||||
event_type: BillingTimelineEventType;
|
||||
source: 'stripe' | 'app' | 'system';
|
||||
payload_json: Record<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 = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -120,6 +180,8 @@ export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: s
|
||||
select id, workspace_id, plan_code, billing_interval, status,
|
||||
current_period_starts_at, current_period_ends_at,
|
||||
cancel_at_period_end, canceled_at, trial_ends_at,
|
||||
grace_period_ends_at, pending_plan_code, pending_plan_effective_at,
|
||||
billing_sync_status, last_stripe_sync_at,
|
||||
external_customer_ref, external_subscription_ref,
|
||||
created_at, updated_at
|
||||
from public.workspace_billing_accounts
|
||||
@@ -153,6 +215,8 @@ export async function createDefaultBillingAccountForWorkspace(db: DbClient, work
|
||||
returning id, workspace_id, plan_code, billing_interval, status,
|
||||
current_period_starts_at, current_period_ends_at,
|
||||
cancel_at_period_end, canceled_at, trial_ends_at,
|
||||
grace_period_ends_at, pending_plan_code, pending_plan_effective_at,
|
||||
billing_sync_status, last_stripe_sync_at,
|
||||
external_customer_ref, external_subscription_ref,
|
||||
created_at, updated_at
|
||||
`,
|
||||
@@ -187,6 +251,11 @@ export async function updateBillingAccountState(
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
canceledAt?: string | null;
|
||||
trialEndsAt?: string | null;
|
||||
gracePeriodEndsAt?: string | null;
|
||||
pendingPlanCode?: PlanCode | null;
|
||||
pendingPlanEffectiveAt?: string | null;
|
||||
billingSyncStatus?: BillingSyncStatus;
|
||||
lastStripeSyncAt?: string | null;
|
||||
externalCustomerRef?: string | null;
|
||||
externalSubscriptionRef?: string | null;
|
||||
},
|
||||
@@ -203,10 +272,15 @@ export async function updateBillingAccountState(
|
||||
cancel_at_period_end,
|
||||
canceled_at,
|
||||
trial_ends_at,
|
||||
grace_period_ends_at,
|
||||
pending_plan_code,
|
||||
pending_plan_effective_at,
|
||||
billing_sync_status,
|
||||
last_stripe_sync_at,
|
||||
external_customer_ref,
|
||||
external_subscription_ref
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, coalesce($7, false), $8, $9, $10, $11)
|
||||
values ($1, $2, $3, $4, $5, $6, coalesce($7, false), $8, $9, $10, $11, $12, coalesce($13, 'ok'), $14, $15, $16)
|
||||
on conflict (workspace_id)
|
||||
do update set
|
||||
plan_code = excluded.plan_code,
|
||||
@@ -217,11 +291,18 @@ export async function updateBillingAccountState(
|
||||
cancel_at_period_end = excluded.cancel_at_period_end,
|
||||
canceled_at = excluded.canceled_at,
|
||||
trial_ends_at = excluded.trial_ends_at,
|
||||
grace_period_ends_at = excluded.grace_period_ends_at,
|
||||
pending_plan_code = excluded.pending_plan_code,
|
||||
pending_plan_effective_at = excluded.pending_plan_effective_at,
|
||||
billing_sync_status = excluded.billing_sync_status,
|
||||
last_stripe_sync_at = excluded.last_stripe_sync_at,
|
||||
external_customer_ref = excluded.external_customer_ref,
|
||||
external_subscription_ref = excluded.external_subscription_ref
|
||||
returning id, workspace_id, plan_code, billing_interval, status,
|
||||
current_period_starts_at, current_period_ends_at,
|
||||
cancel_at_period_end, canceled_at, trial_ends_at,
|
||||
grace_period_ends_at, pending_plan_code, pending_plan_effective_at,
|
||||
billing_sync_status, last_stripe_sync_at,
|
||||
external_customer_ref, external_subscription_ref,
|
||||
created_at, updated_at
|
||||
`,
|
||||
@@ -235,6 +316,11 @@ export async function updateBillingAccountState(
|
||||
input.cancelAtPeriodEnd ?? false,
|
||||
input.canceledAt ?? null,
|
||||
input.trialEndsAt ?? null,
|
||||
input.gracePeriodEndsAt ?? null,
|
||||
input.pendingPlanCode ?? null,
|
||||
input.pendingPlanEffectiveAt ?? null,
|
||||
input.billingSyncStatus ?? 'ok',
|
||||
input.lastStripeSyncAt ?? null,
|
||||
input.externalCustomerRef ?? null,
|
||||
input.externalSubscriptionRef ?? null,
|
||||
],
|
||||
@@ -365,6 +451,180 @@ export async function listAddonBalancesForWorkspace(db: DbClient, workspaceId: s
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createBillingTimelineEvent(
|
||||
db: DbClient,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
billingAccountId?: string | null;
|
||||
eventType: BillingTimelineEventType;
|
||||
source: 'stripe' | 'app' | 'system';
|
||||
payloadJson?: Record<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(
|
||||
db: DbClient,
|
||||
input: {
|
||||
@@ -448,6 +708,11 @@ function mapBillingAccountRow(row: BillingAccountRow): BillingAccountRecord {
|
||||
cancelAtPeriodEnd: row.cancel_at_period_end,
|
||||
canceledAt: row.canceled_at,
|
||||
trialEndsAt: row.trial_ends_at,
|
||||
gracePeriodEndsAt: row.grace_period_ends_at,
|
||||
pendingPlanCode: row.pending_plan_code as PlanCode | null,
|
||||
pendingPlanEffectiveAt: row.pending_plan_effective_at,
|
||||
billingSyncStatus: row.billing_sync_status,
|
||||
lastStripeSyncAt: row.last_stripe_sync_at,
|
||||
externalCustomerRef: row.external_customer_ref,
|
||||
externalSubscriptionRef: row.external_subscription_ref,
|
||||
createdAt: row.created_at,
|
||||
@@ -490,6 +755,23 @@ function getDefaultBillingPeriodBounds() {
|
||||
};
|
||||
}
|
||||
|
||||
function mapBillingTimelineEventRow(row: BillingTimelineEventRow): BillingTimelineEventRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
billingAccountId: row.billing_account_id,
|
||||
eventType: row.event_type,
|
||||
source: row.source,
|
||||
payloadJson: row.payload_json,
|
||||
externalEventId: row.external_event_id,
|
||||
externalCustomerRef: row.external_customer_ref,
|
||||
externalSubscriptionRef: row.external_subscription_ref,
|
||||
occurredAt: row.occurred_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function bootstrapDefaultBillingAccountState(db: DbClient, workspaceId: string) {
|
||||
const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getUsageAllowanceForPlan, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js';
|
||||
import { getPendingPlanChangeMessage, isBillingSyncStale, resolveBillingAccessState } from '../../../shared/billing/lifecycle.js';
|
||||
import type { BillingAddonBalanceSummary, BillingUsageResourceSummary, AccountBillingState } from '../../../shared/types.js';
|
||||
import type { UsageResource } from '../../../shared/billing/entitlements.js';
|
||||
import {
|
||||
@@ -24,6 +25,16 @@ export async function getWorkspaceBillingState(db: DbClient, workspaceId: string
|
||||
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
|
||||
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
|
||||
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
|
||||
canceledAt: billingAccount.canceledAt,
|
||||
trialEndsAt: billingAccount.trialEndsAt,
|
||||
gracePeriodEndsAt: billingAccount.gracePeriodEndsAt,
|
||||
pendingPlanCode: billingAccount.pendingPlanCode,
|
||||
pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt,
|
||||
billingSyncStatus: billingAccount.billingSyncStatus,
|
||||
lastStripeSyncAt: billingAccount.lastStripeSyncAt,
|
||||
provider: billingAccount.externalCustomerRef ? 'stripe' : null,
|
||||
externalCustomerRef: billingAccount.externalCustomerRef,
|
||||
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
|
||||
usage: [],
|
||||
addonBalances: await listAddonBalancesForWorkspace(db, workspaceId),
|
||||
message: 'Subscription management is being prepared. Plan details, usage tracking, and billing controls will appear here in a future update.',
|
||||
@@ -40,11 +51,19 @@ export async function getWorkspaceBillingState(db: DbClient, workspaceId: string
|
||||
currentPeriodStartsAt: usageSnapshot.currentPeriodStartsAt,
|
||||
currentPeriodEndsAt: usageSnapshot.currentPeriodEndsAt,
|
||||
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
|
||||
canceledAt: billingAccount.canceledAt,
|
||||
trialEndsAt: billingAccount.trialEndsAt,
|
||||
gracePeriodEndsAt: billingAccount.gracePeriodEndsAt,
|
||||
pendingPlanCode: billingAccount.pendingPlanCode,
|
||||
pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt,
|
||||
billingSyncStatus: isBillingSyncStale(billingAccount.lastStripeSyncAt) ? 'stale' : billingAccount.billingSyncStatus,
|
||||
lastStripeSyncAt: billingAccount.lastStripeSyncAt,
|
||||
provider: billingAccount.externalCustomerRef ? 'stripe' : null,
|
||||
externalCustomerRef: billingAccount.externalCustomerRef,
|
||||
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
|
||||
usage: usageSnapshot.usage,
|
||||
addonBalances,
|
||||
message: billingAccount.status === 'active'
|
||||
? 'Billing state is active. Usage tracking is available and entitlement enforcement can build on this foundation.'
|
||||
: 'Billing state is stored, but subscription automation and enforcement are still being built.',
|
||||
message: buildBillingAccountMessage(billingAccount),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,3 +210,25 @@ function minDate(a: Date, b: Date) {
|
||||
export async function getWorkspaceAddonBalances(db: DbClient, workspaceId: string): Promise<BillingAddonBalanceSummary[]> {
|
||||
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(' ');
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@ const envSchema = z.object({
|
||||
GOOGLE_MAPS_SERVER_KEY: z.string().optional(),
|
||||
PG_BOSS_SCHEMA: z.string().default('pgboss'),
|
||||
SESSION_TTL_DAYS: z.coerce.number().int().positive().default(30),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_PRICE_STARTER_MONTHLY: z.string().optional(),
|
||||
STRIPE_PRICE_STARTER_ANNUAL: z.string().optional(),
|
||||
STRIPE_PRICE_GROWTH_MONTHLY: z.string().optional(),
|
||||
STRIPE_PRICE_GROWTH_ANNUAL: z.string().optional(),
|
||||
STRIPE_PRICE_PRO_MONTHLY: z.string().optional(),
|
||||
STRIPE_PRICE_PRO_ANNUAL: z.string().optional(),
|
||||
STRIPE_PRICE_EXPORT_PACK_10K: z.string().optional(),
|
||||
STRIPE_PRICE_EXPORT_PACK_50K: z.string().optional(),
|
||||
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
|
||||
BILLING_ADMIN_EMAILS: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AppEnv = z.infer<typeof envSchema>;
|
||||
@@ -40,3 +53,17 @@ export function getEnv(): AppEnv {
|
||||
cachedEnv = envSchema.parse(process.env);
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
export function isBillingAdminEmail(email: string) {
|
||||
const allowlist = getEnv().BILLING_ADMIN_EMAILS;
|
||||
|
||||
if (!allowlist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowlist
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.includes(email.trim().toLowerCase());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getW
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, getDeepResearchBatchEstimate, listDeepResearchBatches } from '../deep-research/service.js';
|
||||
import { previewDeepResearchForPoint } from '../postal/service.js';
|
||||
import { recordAnalyticsEvent } from '../analytics/service.js';
|
||||
|
||||
const previewSchema = z.object({
|
||||
lat: z.number().finite().min(-90).max(90),
|
||||
@@ -53,6 +54,48 @@ export const deepResearchRoutes: FastifyPluginAsync = async (app) => {
|
||||
});
|
||||
|
||||
if (!enforcement.allowed) {
|
||||
try {
|
||||
await recordAnalyticsEvent(db, {
|
||||
eventName: 'quota_exhausted_blocked',
|
||||
eventSource: 'api',
|
||||
userId: request.authUser!.id,
|
||||
workspaceId: enforcementContext.workspaceId,
|
||||
planCode: enforcement.decision.currentPlanCode,
|
||||
resource: enforcement.decision.resource,
|
||||
metadata: {
|
||||
denialReason: enforcement.decision.denialReason,
|
||||
action: enforcement.decision.action,
|
||||
resource: enforcement.decision.resource,
|
||||
currentPlan: enforcement.decision.currentPlanCode,
|
||||
suggestedPlan: enforcement.decision.suggestedUpgradePlanCode,
|
||||
},
|
||||
});
|
||||
} catch (analyticsError) {
|
||||
request.log.warn(analyticsError, 'Failed to record quota block analytics event.');
|
||||
}
|
||||
|
||||
if (enforcement.decision.denialReason === 'feature_not_available' || enforcement.decision.denialReason === 'not_launch_ready') {
|
||||
try {
|
||||
await recordAnalyticsEvent(db, {
|
||||
eventName: 'feature_gate_encountered',
|
||||
eventSource: 'api',
|
||||
userId: request.authUser!.id,
|
||||
workspaceId: enforcementContext.workspaceId,
|
||||
planCode: enforcement.decision.currentPlanCode,
|
||||
resource: enforcement.decision.resource,
|
||||
metadata: {
|
||||
denialReason: enforcement.decision.denialReason,
|
||||
action: enforcement.decision.action,
|
||||
resource: enforcement.decision.resource,
|
||||
currentPlan: enforcement.decision.currentPlanCode,
|
||||
suggestedPlan: enforcement.decision.suggestedUpgradePlanCode,
|
||||
},
|
||||
});
|
||||
} catch (analyticsError) {
|
||||
request.log.warn(analyticsError, 'Failed to record feature-gate analytics event.');
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse = buildEntitlementErrorResponse(enforcement.decision);
|
||||
return reply.code(errorResponse.statusCode).send(errorResponse.body);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getDbPool } from '../db/pool.js';
|
||||
import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js';
|
||||
import { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js';
|
||||
import { runSearchForUser } from '../search/run-search.js';
|
||||
import { recordAnalyticsEvent } from '../analytics/service.js';
|
||||
|
||||
const runSearchSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
@@ -41,6 +42,48 @@ export const searchJobRoutes: FastifyPluginAsync = async (app) => {
|
||||
});
|
||||
|
||||
if (!enforcement.allowed) {
|
||||
try {
|
||||
await recordAnalyticsEvent(db, {
|
||||
eventName: 'quota_exhausted_blocked',
|
||||
eventSource: 'api',
|
||||
userId: request.authUser!.id,
|
||||
workspaceId: enforcementContext.workspaceId,
|
||||
planCode: enforcement.decision.currentPlanCode,
|
||||
resource: enforcement.decision.resource,
|
||||
metadata: {
|
||||
denialReason: enforcement.decision.denialReason,
|
||||
action: enforcement.decision.action,
|
||||
resource: enforcement.decision.resource,
|
||||
currentPlan: enforcement.decision.currentPlanCode,
|
||||
suggestedPlan: enforcement.decision.suggestedUpgradePlanCode,
|
||||
},
|
||||
});
|
||||
} catch (analyticsError) {
|
||||
request.log.warn(analyticsError, 'Failed to record quota block analytics event.');
|
||||
}
|
||||
|
||||
if (enforcement.decision.denialReason === 'feature_not_available' || enforcement.decision.denialReason === 'not_launch_ready') {
|
||||
try {
|
||||
await recordAnalyticsEvent(db, {
|
||||
eventName: 'feature_gate_encountered',
|
||||
eventSource: 'api',
|
||||
userId: request.authUser!.id,
|
||||
workspaceId: enforcementContext.workspaceId,
|
||||
planCode: enforcement.decision.currentPlanCode,
|
||||
resource: enforcement.decision.resource,
|
||||
metadata: {
|
||||
denialReason: enforcement.decision.denialReason,
|
||||
action: enforcement.decision.action,
|
||||
resource: enforcement.decision.resource,
|
||||
currentPlan: enforcement.decision.currentPlanCode,
|
||||
suggestedPlan: enforcement.decision.suggestedUpgradePlanCode,
|
||||
},
|
||||
});
|
||||
} catch (analyticsError) {
|
||||
request.log.warn(analyticsError, 'Failed to record feature-gate analytics event.');
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse = buildEntitlementErrorResponse(enforcement.decision);
|
||||
return reply.code(errorResponse.statusCode).send(errorResponse.body);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,7 +24,15 @@ export type EntitlementDecisionStatus =
|
||||
| 'blocked_addon_available'
|
||||
| 'contact_sales_required';
|
||||
|
||||
export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready' | 'billing_not_configured';
|
||||
export type EntitlementDenialReason =
|
||||
| 'feature_not_available'
|
||||
| 'quota_exhausted'
|
||||
| 'custom_enterprise_only'
|
||||
| 'not_launch_ready'
|
||||
| 'billing_not_configured'
|
||||
| 'billing_past_due'
|
||||
| 'billing_canceled'
|
||||
| 'billing_inactive';
|
||||
|
||||
export interface UsageSubject {
|
||||
type: UsageSubjectType;
|
||||
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
import type { AddonCode, BillingInterval, PlanCode } from './billing/plans.js';
|
||||
import type { UsageAllowanceAvailability, UsageResource } from './billing/entitlements.js';
|
||||
import type { BillingSyncStatus, BillingTimelineEventType } from './billing/lifecycle.js';
|
||||
|
||||
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
|
||||
|
||||
@@ -50,6 +51,34 @@ export interface BillingAddonBalanceSummary {
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface BillingWebhookEventSummary {
|
||||
id: string;
|
||||
provider: 'stripe';
|
||||
externalEventId: string;
|
||||
eventType: string;
|
||||
status: 'received' | 'processed' | 'failed' | 'ignored';
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
errorMessage: string | null;
|
||||
receivedAt: string;
|
||||
processedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BillingTimelineEventSummary {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
billingAccountId: string | null;
|
||||
eventType: BillingTimelineEventType;
|
||||
source: 'stripe' | 'app' | 'system';
|
||||
payloadJson: Record<string, unknown>;
|
||||
externalEventId: string | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
occurredAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AccountBillingState {
|
||||
status: AccountBillingStatus;
|
||||
planCode: PlanCode | null;
|
||||
@@ -57,11 +86,83 @@ export interface AccountBillingState {
|
||||
currentPeriodStartsAt: string | null;
|
||||
currentPeriodEndsAt: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
canceledAt?: string | null;
|
||||
trialEndsAt?: string | null;
|
||||
gracePeriodEndsAt: string | null;
|
||||
pendingPlanCode: PlanCode | null;
|
||||
pendingPlanEffectiveAt: string | null;
|
||||
billingSyncStatus: BillingSyncStatus;
|
||||
lastStripeSyncAt: string | null;
|
||||
provider: 'stripe' | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
usage: BillingUsageResourceSummary[];
|
||||
addonBalances: BillingAddonBalanceSummary[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BillingCheckoutSessionResponse {
|
||||
checkoutUrl: string;
|
||||
}
|
||||
|
||||
export interface BillingPortalSessionResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BillingDebugData {
|
||||
events: BillingWebhookEventSummary[];
|
||||
}
|
||||
|
||||
export interface BillingAdminWorkspaceSummary {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
workspaceType: WorkspaceType;
|
||||
memberCount: number;
|
||||
status: AccountBillingStatus;
|
||||
planCode: PlanCode | null;
|
||||
billingInterval: BillingInterval | null;
|
||||
currentPeriodEndsAt: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
gracePeriodEndsAt: string | null;
|
||||
pendingPlanCode: PlanCode | null;
|
||||
pendingPlanEffectiveAt: string | null;
|
||||
billingSyncStatus: BillingSyncStatus;
|
||||
lastStripeSyncAt: string | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
}
|
||||
|
||||
export interface BillingAdminWorkspaceDetail {
|
||||
summary: BillingAdminWorkspaceSummary;
|
||||
billing: AccountBillingState;
|
||||
usagePeriodId: string | null;
|
||||
timeline: BillingTimelineEventSummary[];
|
||||
webhookEvents: BillingWebhookEventSummary[];
|
||||
}
|
||||
|
||||
export interface BillingAdminWorkspaceListResponse {
|
||||
workspaces: BillingAdminWorkspaceSummary[];
|
||||
}
|
||||
|
||||
export interface BillingAdminWorkspaceDetailResponse {
|
||||
workspace: BillingAdminWorkspaceDetail;
|
||||
}
|
||||
|
||||
export interface AnalyticsMetricBucket {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AdminAnalyticsSummary {
|
||||
pricingConversionByPlan: AnalyticsMetricBucket[];
|
||||
quotaExhaustionEvents: AnalyticsMetricBucket[];
|
||||
upgradeTriggers: AnalyticsMetricBucket[];
|
||||
addonAttach: AnalyticsMetricBucket[];
|
||||
planMix: AnalyticsMetricBucket[];
|
||||
churnSignals: AnalyticsMetricBucket[];
|
||||
expansionSignals: AnalyticsMetricBucket[];
|
||||
}
|
||||
|
||||
export interface AccountTeamPlaceholder {
|
||||
canManageMembers: boolean;
|
||||
message: string;
|
||||
@@ -73,6 +174,7 @@ export interface AccountPageData {
|
||||
summary: AccountSummary;
|
||||
billing: AccountBillingState;
|
||||
team: AccountTeamPlaceholder;
|
||||
isBillingAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateAccountProfileRequest {
|
||||
|
||||
+13
-3
@@ -24,7 +24,7 @@ import { PricingComparisonTable } from './components/PricingComparisonTable';
|
||||
import { ResearchWorkspace } from './components/ResearchWorkspace';
|
||||
import { ResultsWorkspace } from './components/ResultsWorkspace';
|
||||
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
|
||||
import type { BillingInterval } from '../shared/billing/plans';
|
||||
import type { BillingInterval, PlanCode } from '../shared/billing/plans';
|
||||
import type { SessionUser } from '../shared/types';
|
||||
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
|
||||
import { hasApiConfig } from './lib/api';
|
||||
@@ -42,6 +42,7 @@ export default function App() {
|
||||
const [authNotice, setAuthNotice] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<'sign_in' | 'sign_up'>('sign_in');
|
||||
const [billingIntentPlanCode, setBillingIntentPlanCode] = useState<PlanCode | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
@@ -132,6 +133,9 @@ export default function App() {
|
||||
|
||||
setUser(nextUser);
|
||||
setAuthNotice('Account created and signed in.');
|
||||
if (billingIntentPlanCode) {
|
||||
setActiveTab('account');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,6 +145,9 @@ export default function App() {
|
||||
});
|
||||
|
||||
setUser(nextUser);
|
||||
if (billingIntentPlanCode) {
|
||||
setActiveTab('account');
|
||||
}
|
||||
navigatePublicPage('landing', setPublicPage);
|
||||
} catch (error) {
|
||||
setAuthError(error instanceof Error ? error.message : 'Authentication failed.');
|
||||
@@ -219,8 +226,9 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<LandingPage
|
||||
onGoToAuth={(mode) => {
|
||||
onGoToAuth={(mode, planCode) => {
|
||||
handleSetAuthMode(mode);
|
||||
setBillingIntentPlanCode(planCode ?? null);
|
||||
navigatePublicPage('auth', setPublicPage);
|
||||
}}
|
||||
/>
|
||||
@@ -279,6 +287,8 @@ export default function App() {
|
||||
<AccountPage
|
||||
user={user}
|
||||
onUserUpdated={(nextUser) => setUser((currentUser) => (currentUser ? { ...nextUser, sessionId: currentUser.sessionId } : currentUser))}
|
||||
initialCheckoutPlanCode={billingIntentPlanCode}
|
||||
onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
@@ -305,7 +315,7 @@ function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'lan
|
||||
}
|
||||
|
||||
function LandingPage(props: {
|
||||
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
|
||||
onGoToAuth: (mode: 'sign_in' | 'sign_up', planCode?: PlanCode) => void;
|
||||
}) {
|
||||
const { onGoToAuth } = props;
|
||||
const [pricingInterval, setPricingInterval] = useState<Extract<BillingInterval, 'monthly' | 'annual'>>('monthly');
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { AlertCircle, ArrowUpRight, Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getEligibleAddonsForPlan } from '../../shared/billing/addons';
|
||||
import { getPlanByCode } from '../../shared/billing/plans';
|
||||
import type { AccountPageData, AppUser } from '../../shared/types';
|
||||
import { getAccountPageData, updateAccountProfile } from '../lib/account';
|
||||
import { formatLifecycleDate } from '../../shared/billing/lifecycle';
|
||||
import { getPlanByCode, getPublicPricingPlans, type PlanCode } from '../../shared/billing/plans';
|
||||
import type { AccountPageData, AppUser, BillingAdminWorkspaceDetail, BillingAdminWorkspaceSummary } from '../../shared/types';
|
||||
import {
|
||||
createAddonCheckout,
|
||||
createBillingPortalSession,
|
||||
createSubscriptionCheckout,
|
||||
getAccountPageData,
|
||||
getAdminBillingWorkspaceDetail,
|
||||
listAdminBillingWorkspaces,
|
||||
updateAccountProfile,
|
||||
} from '../lib/account';
|
||||
import {
|
||||
formatBillingIntervalLabel,
|
||||
formatBillingStatusLabel,
|
||||
formatDateLabel,
|
||||
formatPlanPeriod,
|
||||
formatPlanPrice,
|
||||
formatQuantity,
|
||||
formatUsageResourceName,
|
||||
getBillingStatusBadgeVariant,
|
||||
@@ -17,22 +28,32 @@ import {
|
||||
getUsageWarningMessage,
|
||||
getUsageWarningState,
|
||||
} from '../lib/billing-ui';
|
||||
import { sendAnalyticsEvent } from '../lib/analytics';
|
||||
import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui';
|
||||
|
||||
const SALES_EMAIL = 'sales@localescope.app';
|
||||
|
||||
interface AccountPageProps {
|
||||
user: AppUser;
|
||||
onUserUpdated: (user: AppUser) => void;
|
||||
initialCheckoutPlanCode?: PlanCode | null;
|
||||
onConsumeInitialCheckoutPlanCode?: () => void;
|
||||
}
|
||||
|
||||
export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = null, onConsumeInitialCheckoutPlanCode }: AccountPageProps) {
|
||||
const [account, setAccount] = useState<AccountPageData | null>(null);
|
||||
const [displayName, setDisplayName] = useState(user.displayName);
|
||||
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl ?? '');
|
||||
const [workspaceName, setWorkspaceName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [billingAction, setBillingAction] = useState<string | null>(null);
|
||||
const [error, setError] = 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(() => {
|
||||
let isMounted = true;
|
||||
@@ -52,6 +73,16 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
setDisplayName(nextAccount.profile.displayName);
|
||||
setAvatarUrl(nextAccount.profile.avatarUrl ?? '');
|
||||
setWorkspaceName(nextAccount.workspace.name);
|
||||
setNotice(getBillingReturnNotice());
|
||||
|
||||
if (nextAccount.isBillingAdmin) {
|
||||
setAdminLoading(true);
|
||||
const adminResponse = await listAdminBillingWorkspaces();
|
||||
if (isMounted) {
|
||||
setAdminWorkspaces(adminResponse.workspaces);
|
||||
}
|
||||
setAdminLoading(false);
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
@@ -72,6 +103,28 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has('billing')) {
|
||||
url.searchParams.delete('billing');
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!account || !initialCheckoutPlanCode || billingAction !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handlePlanAction(initialCheckoutPlanCode).finally(() => {
|
||||
onConsumeInitialCheckoutPlanCode?.();
|
||||
});
|
||||
}, [account, initialCheckoutPlanCode, billingAction, onConsumeInitialCheckoutPlanCode]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -94,6 +147,122 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscriptionCheckout = async (planCode: string) => {
|
||||
setBillingAction(`plan:${planCode}`);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
sendAnalyticsEvent({
|
||||
eventName: 'checkout_started',
|
||||
planCode,
|
||||
});
|
||||
const response = await createSubscriptionCheckout(planCode);
|
||||
window.location.assign(response.checkoutUrl);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to start checkout.');
|
||||
} finally {
|
||||
setBillingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddonCheckout = async (addonCode: string) => {
|
||||
setBillingAction(`addon:${addonCode}`);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
sendAnalyticsEvent({
|
||||
eventName: 'addon_checkout_started',
|
||||
addonCode,
|
||||
});
|
||||
const response = await createAddonCheckout(addonCode);
|
||||
window.location.assign(response.checkoutUrl);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to start add-on checkout.');
|
||||
} finally {
|
||||
setBillingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingPortal = async () => {
|
||||
setBillingAction('portal');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
sendAnalyticsEvent({
|
||||
eventName: 'portal_opened',
|
||||
});
|
||||
const response = await createBillingPortalSession();
|
||||
window.location.assign(response.url);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to open billing portal.');
|
||||
} finally {
|
||||
setBillingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlanAction = async (planCode: PlanCode) => {
|
||||
const targetPlan = getPlanByCode(planCode);
|
||||
|
||||
if (!targetPlan) {
|
||||
setError('Selected plan is unavailable. Please refresh and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetPlan.contactSalesRequired || !targetPlan.isSelfServe) {
|
||||
setError(null);
|
||||
setNotice(`This plan is available through sales. Contact ${SALES_EMAIL} to coordinate your rollout.`);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.assign(`mailto:${SALES_EMAIL}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.billing.planCode === planCode && !account.billing.cancelAtPeriodEnd) {
|
||||
setError(null);
|
||||
setNotice('You are already on this plan.');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExternalPaidSubscription = Boolean(account.billing.externalCustomerRef && account.billing.externalSubscriptionRef);
|
||||
if (hasExternalPaidSubscription && account.billing.planCode !== planCode) {
|
||||
setNotice('Use the billing portal to change plans for your existing subscription.');
|
||||
await handleBillingPortal();
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSubscriptionCheckout(planCode);
|
||||
};
|
||||
|
||||
const handleLoadAdminWorkspace = async (workspaceId: string) => {
|
||||
setAdminLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getAdminBillingWorkspaceDetail(workspaceId);
|
||||
setAdminWorkspaceDetail(response.workspace);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load billing workspace detail.');
|
||||
} finally {
|
||||
setAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchAdminWorkspaces = async () => {
|
||||
setAdminLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await listAdminBillingWorkspaces(adminQuery);
|
||||
setAdminWorkspaces(response.workspaces);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to search billing workspaces.');
|
||||
} finally {
|
||||
setAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -118,6 +287,8 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
const suggestedUpgradePlanCode = getSuggestedUpgradePlanCode(account.billing.planCode, account.billing.billingInterval);
|
||||
const suggestedUpgradePlan = suggestedUpgradePlanCode ? getPlanByCode(suggestedUpgradePlanCode) : null;
|
||||
const eligibleAddons = activePlan ? getEligibleAddonsForPlan(activePlan.code, { includeComingSoon: true }) : [];
|
||||
const publicPricingPlans = getPublicPricingPlans();
|
||||
const hasExternalPaidSubscription = Boolean(account.billing.externalCustomerRef && account.billing.externalSubscriptionRef);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -233,8 +404,39 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
<span>Current period ends</span>
|
||||
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.currentPeriodEndsAt)}</span>
|
||||
</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>
|
||||
<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 ? (
|
||||
<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>
|
||||
@@ -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-sm text-emerald-800">Step up when you need more usage headroom or premium workflows.</p>
|
||||
</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
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -252,6 +461,50 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
) : null}
|
||||
</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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -347,6 +600,20 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
<Badge variant="info">{addon.purchaseMode === 'one_time' ? 'One-time' : 'Recurring'}</Badge>
|
||||
<Badge>{addon.quantity === null ? 'Feature add-on' : `${formatQuantity(addon.quantity)} units`}</Badge>
|
||||
</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>
|
||||
@@ -365,6 +632,72 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-stone-600">{account.team.message}</p>
|
||||
</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>
|
||||
|
||||
@@ -377,3 +710,22 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlansForInterval, type BillingInterval } from '../../shared/billing/plans';
|
||||
import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlansForInterval, type BillingInterval, type PlanCode } from '../../shared/billing/plans';
|
||||
import { Button } from './ui';
|
||||
import { formatPlanPeriod, formatPlanPrice } from '../lib/billing-ui';
|
||||
import { sendAnalyticsEvent } from '../lib/analytics';
|
||||
|
||||
interface PricingCardsProps {
|
||||
billingInterval: Extract<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) {
|
||||
@@ -45,7 +46,16 @@ export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps)
|
||||
|
||||
<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' : ''}`}
|
||||
variant={isFeatured ? 'primary' : 'secondary'}
|
||||
>
|
||||
|
||||
+42
-1
@@ -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';
|
||||
|
||||
export async function getAccountPageData() {
|
||||
@@ -14,3 +22,36 @@ export async function updateAccountProfile(payload: UpdateAccountProfileRequest)
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user