From 94b8c357b4a43a09ef8982d0ad8d5b79cc3d3692 Mon Sep 17 00:00:00 2001 From: pguerrerox Date: Fri, 22 May 2026 17:50:28 +0000 Subject: [PATCH] feat: add billing foundation and entitlement enforcement - add workspace-scoped billing storage, usage tracking, and add-on catalog - enforce plan entitlements for search and deep research routes - expand pricing and account UI around billing state, usage, and upgrades --- CHANGELOG.md | 8 +- TODO-pricing.md | 178 +++++--- WORKSPACE-READINESS.md | 91 ++++ db/migrations/0003_billing_foundation.sql | 132 ++++++ server/src/account/repository.ts | 9 +- server/src/billing/enforcement-service.ts | 182 ++++++++ server/src/billing/repository.ts | 521 ++++++++++++++++++++++ server/src/billing/service.ts | 193 ++++++++ server/src/deep-research/service.ts | 20 +- server/src/routes/deep-research.ts | 26 +- server/src/routes/search-jobs.ts | 23 +- shared/billing/addons.ts | 114 +++++ shared/billing/entitlements.ts | 4 +- shared/billing/plans.ts | 12 + shared/billing/workspace-readiness.ts | 252 +++++++++++ shared/types.ts | 23 +- src/App.tsx | 98 +--- src/components/AccountPage.tsx | 150 ++++++- src/components/PricingCards.tsx | 70 +++ src/components/PricingComparisonTable.tsx | 132 ++++++ src/lib/billing-ui.ts | 182 ++++++++ 21 files changed, 2269 insertions(+), 151 deletions(-) create mode 100644 WORKSPACE-READINESS.md create mode 100644 db/migrations/0003_billing_foundation.sql create mode 100644 server/src/billing/enforcement-service.ts create mode 100644 server/src/billing/repository.ts create mode 100644 server/src/billing/service.ts create mode 100644 shared/billing/addons.ts create mode 100644 shared/billing/workspace-readiness.ts create mode 100644 src/components/PricingCards.tsx create mode 100644 src/components/PricingComparisonTable.tsx create mode 100644 src/lib/billing-ui.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 786fb15..830d184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] +## [2026-05-22] ### Added - Added an authenticated `Account` page with editable profile settings, workspace details, usage summaries, and placeholders for upcoming billing and team management. - Added workspace and workspace-membership schema foundations plus new account API endpoints so each user now has a default personal workspace for future company, billing, and team features. - Added a shared billing catalog, entitlement policy helpers, and feature-gate logic for Starter, Growth, Pro, and Enterprise packaging so pricing and future subscription enforcement can share one source of truth. +- 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. ### 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. - Refined the authenticated app for mobile with a phone-friendly top bar, bottom tab navigation, shorter inline research maps, touch-friendlier map gestures, and a mobile lead-card presentation in the dashboard while preserving desktop layouts. - Rebranded public, auth, worker, and supporting documentation copy from `Leads4less` to `LocaleScope` and repositioned the product around local market intelligence and territory research. -- Reworked the public pricing section and account billing placeholder to read from shared plan metadata, laying groundwork for future subscription, usage, and upgrade controls. +- 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. ## [2026-05-01] diff --git a/TODO-pricing.md b/TODO-pricing.md index 5ca2ac9..e877488 100644 --- a/TODO-pricing.md +++ b/TODO-pricing.md @@ -161,54 +161,115 @@ - [ ] Future note: revisit the fallback `coming_soon` state for unavailable or unmapped features before broad UI rollout so hidden vs upgrade vs future behavior stays intentional. ## 5) Billing & Data Model Design -- [ ] Design subscription/account state separately from the canonical plan catalog. -- [ ] Keep billing-provider identifiers out of the canonical catalog until payments integration work begins. -- [ ] Design subscription state storage for current plan, billing interval, and status. -- [ ] Design a monthly usage ledger for: +- [x] Design subscription/account state separately from the canonical plan catalog. +- [x] Keep billing-provider identifiers out of the canonical catalog until payments integration work begins. +- [x] Design subscription state storage for current plan, billing interval, and status. +- [x] Design a monthly usage ledger for: - research credits/runs - exports - enrichments - API usage (future) -- [ ] Design add-on purchases and remaining balances. -- [ ] Define renewal/reset behavior for monthly quotas. -- [ ] Define annual billing behavior and renewal terms. -- [ ] Define LTD handling with monthly quotas and non-unlimited usage. +- [x] Design add-on purchases and remaining balances. +- [x] Define renewal/reset behavior for monthly quotas. +- [x] Define annual billing behavior and renewal terms. +- [x] Define LTD handling with monthly quotas and non-unlimited usage. +- [x] Implement workspace-scoped billing foundation storage for: + - billing accounts + - usage periods + - usage counters + - add-on purchases + - add-on balances +- [x] Add billing repository/service layers and minimal account-data integration. +- [x] Keep step `#5` foundation-only: + - no Stripe/webhook integration yet + - no route enforcement yet + - no full billing UI rollout yet +- [ ] Future note: `remaining = 0` for `not_available` resources is intentional and should stay aligned with entitlement semantics. +- [ ] Future note: expired billing periods should fail closed for current usage-window resolution until subscription lifecycle automation can advance billing periods reliably. +- [ ] Future note: consider exposing `usagePeriodId` later if enforcement, debugging, or admin tooling needs period-level traceability. +- [ ] Future note: add transactional workflows around billing-account updates, usage updates, and add-on purchase/balance mutations once real payment flows are introduced. +- [ ] Future note: `plan_code` is currently unconstrained text in the database; keep application-side validation strict unless a later migration adds stronger DB validation. +- [ ] Future note: usage ownership is workspace-scoped in storage, but current operational enforcement is still catching up to that model. ## 6) Enforcement Architecture -- [ ] Create a centralized entitlement/usage policy service on the backend. -- [ ] Ensure all high-cost actions check entitlements before execution. -- [ ] Start with enforcement on: - - research routes - - export routes - - enrichment routes (future) -- [ ] Add queue prioritization by plan tier. -- [ ] Add throttling/fair-usage controls. -- [ ] Add clear API responses for quota exhaustion and upgrade flows. +- [x] Create a centralized entitlement/usage policy service on the backend. +- [x] Ensure all high-cost actions check entitlements before execution. +- [x] Start with enforcement on: + - [x] research routes +- [x] Bootstrap new and existing workspaces into a usable pre-payments Starter billing state so enforcement does not hard-block all chargeable actions before subscriptions exist. +- [x] Reuse a shared deep-research estimate path so entitlement cost estimation and batch creation use the same preview-derived basis. +- [x] Add clear API responses for quota exhaustion and upgrade flows. +- [ ] Future note: the current enforcement slice should be treated as the core entitlement/runtime gate, not the full operational control layer. +- [ ] Future note: the default Starter bootstrap is a pre-payments usability policy and should be revisited when real subscription lifecycle automation is implemented. ## 7) Workspace, User, and Collaboration Readiness -- [ ] Review whether current data ownership is sufficiently workspace-scoped for plan promises. -- [ ] Identify gaps between current user-scoped data model and promised team/workspace packaging. -- [ ] Document which catalog limits can be enforced immediately versus only represented commercially at launch. -- [ ] Define how to enforce: - - users included - - workspace limits - - shared assets/lists - - collaboration permissions -- [ ] Decide whether some collaboration features need phased rollout rather than immediate sale. +- [x] Review whether current data ownership is sufficiently workspace-scoped for plan promises. +- [x] Identify gaps between current user-scoped data model and promised team/workspace packaging. +- [x] Document which catalog limits can be enforced immediately versus only represented commercially at launch. +- [x] Define how to enforce: + - [x] users included + - [x] workspace limits + - [x] shared assets/lists + - [x] collaboration permissions +- [x] Decide whether some collaboration features need phased rollout rather than immediate sale. +- [x] Add a shared workspace-readiness matrix covering: + - current ownership scope + - target ownership scope + - enforceability state + - collaboration phase definitions +- [x] Document the current collaboration phase as workspace billing with mostly personal data ownership. +- [x] Define the migration target for workspace ownership of: + - `search_jobs` + - `deep_research_batches` + - `businesses` + - `search_job_results` +- [ ] Future note: before true collaboration is sold as real runtime behavior, core domain entities need `workspace_id` ownership and repository/query updates. +- [ ] Future note: users included and workspace limits should remain soft-gated until multi-workspace UX and shared data ownership mature. +- [ ] Future note: shared lists, tagging/notes, and collaboration permissions should not be treated as hard-enforceable features until the workspace migration is complete. -## 8) Add-On Strategy -- [ ] Define export add-ons: +## 8) Pricing Page & Account UX +- [x] Build pricing page from canonical plan definitions instead of hardcoded copy. +- [x] Derive pricing-card and comparison-table content from presentation metadata layered on top of the canonical catalog. +- [x] Add plan comparison table. +- [x] Add annual/monthly toggle. +- [x] Add upgrade CTAs and contact-sales CTA. +- [x] Add account/billing page showing: + - [x] current plan + - [x] billing interval + - [x] usage this month + - [x] remaining quota + - [x] available add-ons + - [x] upgrade options +- [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: 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. + +## 9) Add-On Strategy +- [x] Define export add-ons: - +10k exports = $29 - +50k exports = $99 -- [ ] Define enrichment packs: +- [x] Define enrichment packs: - 1,000 enrichments = $49 -- [ ] Reserve future add-ons for: +- [x] Reserve future add-ons for: - AI prospecting assistant - white-label / agency tools - higher API capacity -- [ ] Decide whether add-ons are one-time, monthly recurring, or both. +- [x] Decide whether add-ons are one-time, monthly recurring, or both. +- [ ] Future note: launch active add-ons should stay limited to one-time export packs until enrichment delivery and payments lifecycle handling are live. +- [ ] Future note: recurring feature add-ons should not be sold until the underlying capabilities and subscription management flows exist end-to-end. -## 9) Founder / LTD Strategy +## 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. + +## 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: @@ -217,29 +278,6 @@ - [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API. - [ ] Define which future features are excluded from LTD plans. -## 10) Pricing Page & Account UX -- [ ] Build pricing page from canonical plan definitions instead of hardcoded copy. -- [ ] Derive pricing-card and comparison-table content from presentation metadata layered on top of the canonical catalog. -- [ ] Add plan comparison table. -- [ ] Add annual/monthly toggle. -- [ ] Add upgrade CTAs and contact-sales CTA. -- [ ] Add account/billing page showing: - - current plan - - billing interval - - usage this month - - remaining quota - - available add-ons - - upgrade options -- [ ] Add quota warning UX before hard exhaustion. - -## 11) 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. - ## 12) Analytics, Ops, and Revenue Instrumentation - [ ] Track pricing-page conversion by plan. - [ ] Track quota exhaustion events. @@ -251,13 +289,33 @@ - [ ] Track plan mix, churn, expansion revenue, and annual conversion. - [ ] Add internal dashboards for billing and usage health. -## 13) Rollout Plan +## 13) Operational Enforcement Follow-Up +- [ ] Add queue prioritization by plan tier. +- [ ] Add throttling/fair-usage controls. +- [ ] Add export-route enforcement once CSV/export generation moves to a backend endpoint. +- [ ] Add enrichment-route enforcement once enrichment actions/routes are implemented. +- [ ] Future note: queue prioritization is deferred until async worker routing or higher-volume queued execution becomes an active runtime path. +- [ ] Future note: throttling/fair-usage controls are deferred until higher-volume execution patterns require operational protection. +- [ ] 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 - [ ] Phase 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model. - [ ] Phase 2: implement usage ledger and backend enforcement. -- [ ] Phase 3: update pricing page and account/billing UI. -- [ ] Phase 4: integrate payments and subscription lifecycle handling. -- [ ] Phase 5: launch add-ons and annual billing. -- [ ] Phase 6: launch collaboration, API, enrichment, and enterprise features as architecture matures. +- [ ] 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 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. + +## 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. ## Open Questions - [ ] Will research capacity be marketed as runs, credits, or both? diff --git a/WORKSPACE-READINESS.md b/WORKSPACE-READINESS.md new file mode 100644 index 0000000..438d517 --- /dev/null +++ b/WORKSPACE-READINESS.md @@ -0,0 +1,91 @@ +# Workspace Readiness + +## Current State + +LocaleScope now uses workspaces as the billing and quota subject, but the core research domain is still mostly user-owned. + +Already workspace-scoped: +- workspaces +- workspace memberships +- billing accounts +- usage periods and counters +- add-on purchases and balances + +Still user-scoped but targeted for workspace ownership: +- search jobs +- deep research batches +- saved businesses +- search job results + +Should remain user-scoped: +- users +- sessions + +## Practical Implication + +The product currently operates in a mixed phase: +- billing, quotas, and memberships are workspace-based +- most saved operational data still behaves like personal user-owned data + +This means some commercial promises can be enforced now, while others should remain soft-gated or clearly phased. + +## Enforceability Matrix + +Hard enforce now: +- research credits + +Requires backend route before hard enforcement: +- exports + +Soft gate now: +- users included +- workspace limits + +Requires schema migration first: +- shared assets / shared history +- collaboration permissions +- tagging and notes +- shared lists + +Future implementation: +- saved searches +- deduplication +- export history +- scheduled research +- CRM integrations +- API access +- webhooks +- enrichments + +## Collaboration Phases + +### V1: Personal Data With Workspace Billing +- a user consumes usage against their primary workspace +- billing and quotas are tracked at the workspace level +- search history and saved businesses remain effectively personal + +### V2: Shared Workspace Data +- search jobs and deep research batches become workspace-owned +- saved businesses and results become shareable across members +- role-aware collaboration and shared asset rules become enforceable + +## Migration Target + +Before team/workspace promises are enforced as real collaboration features, these tables should gain `workspace_id` ownership: +- `search_jobs` +- `deep_research_batches` +- `businesses` +- `search_job_results` + +Recommended migration approach: +1. add nullable `workspace_id` +2. backfill from each user's primary workspace +3. update repositories/services to prefer workspace ownership +4. make `workspace_id` non-null once the transition is complete + +## Rule To Keep In Mind + +Until workspace-owned domain data exists, team and collaboration plan promises should be treated as: +- commercially described +- softly gated in product messaging +- not fully enforceable runtime collaboration behavior diff --git a/db/migrations/0003_billing_foundation.sql b/db/migrations/0003_billing_foundation.sql new file mode 100644 index 0000000..9bf5008 --- /dev/null +++ b/db/migrations/0003_billing_foundation.sql @@ -0,0 +1,132 @@ +create table if not exists public.workspace_billing_accounts ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces (id) on delete cascade, + plan_code text, + billing_interval text check (billing_interval in ('monthly', 'annual', 'custom')), + status text not null check (status in ('not_configured', 'inactive', 'active', 'past_due', 'canceled')), + current_period_starts_at timestamptz, + current_period_ends_at timestamptz, + cancel_at_period_end boolean not null default false, + canceled_at timestamptz, + trial_ends_at timestamptz, + external_customer_ref text, + external_subscription_ref text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint workspace_billing_accounts_workspace_id_key unique (workspace_id), + constraint workspace_billing_accounts_period_bounds_check check ( + (current_period_starts_at is null and current_period_ends_at is null) + or (current_period_starts_at is not null and current_period_ends_at is not null) + ) +); + +create table if not exists public.workspace_usage_periods ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces (id) on delete cascade, + billing_account_id uuid not null references public.workspace_billing_accounts (id) on delete cascade, + period_starts_at timestamptz not null, + period_ends_at timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint workspace_usage_periods_workspace_period_key unique (workspace_id, period_starts_at, period_ends_at), + constraint workspace_usage_periods_bounds_check check (period_starts_at < period_ends_at) +); + +create table if not exists public.workspace_usage_counters ( + id uuid primary key default gen_random_uuid(), + usage_period_id uuid not null references public.workspace_usage_periods (id) on delete cascade, + workspace_id uuid not null references public.workspaces (id) on delete cascade, + resource text not null check (resource in ('research_credits', 'exports', 'enrichments', 'api_requests')), + consumed_quantity integer not null default 0 check (consumed_quantity >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint workspace_usage_counters_period_resource_key unique (usage_period_id, resource) +); + +create table if not exists public.workspace_addon_purchases ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces (id) on delete cascade, + addon_code text not null check (addon_code in ('export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly')), + resource text not null check (resource in ('research_credits', 'exports', 'enrichments', 'api_requests')), + purchased_quantity integer not null check (purchased_quantity >= 0), + remaining_quantity integer not null check (remaining_quantity >= 0), + purchased_at timestamptz not null default now(), + expires_at timestamptz, + external_purchase_ref text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.workspace_addon_balances ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces (id) on delete cascade, + addon_code text not null check (addon_code in ('export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly')), + resource text not null check (resource in ('research_credits', 'exports', 'enrichments', 'api_requests')), + remaining_quantity integer not null default 0 check (remaining_quantity >= 0), + expires_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint workspace_addon_balances_workspace_addon_resource_key unique (workspace_id, addon_code, resource) +); + +create index if not exists workspace_billing_accounts_status_idx on public.workspace_billing_accounts (status); +create index if not exists workspace_billing_accounts_plan_code_idx on public.workspace_billing_accounts (plan_code); +create index if not exists workspace_usage_periods_workspace_id_idx on public.workspace_usage_periods (workspace_id); +create index if not exists workspace_usage_counters_workspace_id_idx on public.workspace_usage_counters (workspace_id); +create index if not exists workspace_addon_purchases_workspace_id_idx on public.workspace_addon_purchases (workspace_id); +create index if not exists workspace_addon_balances_workspace_id_idx on public.workspace_addon_balances (workspace_id); + +drop trigger if exists set_workspace_billing_accounts_updated_at on public.workspace_billing_accounts; +create trigger set_workspace_billing_accounts_updated_at +before update on public.workspace_billing_accounts +for each row +execute function public.set_updated_at(); + +drop trigger if exists set_workspace_usage_periods_updated_at on public.workspace_usage_periods; +create trigger set_workspace_usage_periods_updated_at +before update on public.workspace_usage_periods +for each row +execute function public.set_updated_at(); + +drop trigger if exists set_workspace_usage_counters_updated_at on public.workspace_usage_counters; +create trigger set_workspace_usage_counters_updated_at +before update on public.workspace_usage_counters +for each row +execute function public.set_updated_at(); + +drop trigger if exists set_workspace_addon_purchases_updated_at on public.workspace_addon_purchases; +create trigger set_workspace_addon_purchases_updated_at +before update on public.workspace_addon_purchases +for each row +execute function public.set_updated_at(); + +drop trigger if exists set_workspace_addon_balances_updated_at on public.workspace_addon_balances; +create trigger set_workspace_addon_balances_updated_at +before update on public.workspace_addon_balances +for each row +execute function public.set_updated_at(); + +insert into public.workspace_billing_accounts ( + workspace_id, + plan_code, + billing_interval, + status, + current_period_starts_at, + current_period_ends_at +) +select w.id, 'starter_monthly', 'monthly', 'active', now(), now() + interval '1 month' +from public.workspaces w +where not exists ( + select 1 + from public.workspace_billing_accounts billing + where billing.workspace_id = w.id +); + +update public.workspace_billing_accounts +set + plan_code = 'starter_monthly', + billing_interval = 'monthly', + status = 'active', + current_period_starts_at = coalesce(current_period_starts_at, now()), + current_period_ends_at = coalesce(current_period_ends_at, now() + interval '1 month') +where plan_code is null and status = 'not_configured'; diff --git a/server/src/account/repository.ts b/server/src/account/repository.ts index 94e8991..cb3b058 100644 --- a/server/src/account/repository.ts +++ b/server/src/account/repository.ts @@ -1,5 +1,6 @@ import type { Pool, PoolClient } from 'pg'; import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js'; +import { getWorkspaceBillingState } from '../billing/service.js'; type DbClient = Pool | PoolClient; @@ -152,17 +153,13 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise } const summary = await getAccountSummaryForUser(db, user.id); + const billing = await getWorkspaceBillingState(db, workspace.id); return { profile: user, workspace, summary, - billing: { - status: 'not_configured', - planCode: null, - billingInterval: null, - message: 'Subscription management is being prepared. Plan details, usage tracking, and billing controls will appear here in a future update.', - }, + billing, team: { canManageMembers: workspace.role === 'owner' || workspace.role === 'admin', message: 'Workspace member management is coming soon.', diff --git a/server/src/billing/enforcement-service.ts b/server/src/billing/enforcement-service.ts new file mode 100644 index 0000000..721cd86 --- /dev/null +++ b/server/src/billing/enforcement-service.ts @@ -0,0 +1,182 @@ +import type { Pool, PoolClient } from 'pg'; +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'; +import { ensureWorkspaceForUser } from '../account/repository.js'; +import { ensureBillingAccountForWorkspace, incrementUsageCounter } from './repository.js'; +import { ensureCurrentUsagePeriodForBillingAccount, getWorkspaceBillingState } from './service.js'; + +type DbClient = Pool | PoolClient; + +export interface WorkspaceEnforcementContext { + workspaceId: string; + billing: AccountBillingState; +} + +export interface EnforcementCheckInput { + userId: string; + workspaceId: string; + action: UsageAction; + costEstimate: UsageCostEstimate; +} + +export interface EnforcementCheckResult { + allowed: boolean; + decision: EntitlementDecision; +} + +export interface UsageRecordingInput { + workspaceId: string; + action: UsageAction; + costEstimate: UsageCostEstimate; +} + +export async function getWorkspaceEnforcementContext( + db: DbClient, + user: { id: string; email: string; displayName?: string | null }, +): Promise { + const workspace = await ensureWorkspaceForUser(db, user); + + if (!workspace) { + throw new Error('Failed to resolve enforcement workspace.'); + } + + const billing = await getWorkspaceBillingState(db, workspace.id); + + return { + workspaceId: workspace.id, + billing, + }; +} + +export async function checkActionEntitlementForWorkspace( + db: DbClient, + input: EnforcementCheckInput, +): Promise { + const billing = await getWorkspaceBillingState(db, input.workspaceId); + + if (!billing.planCode || !isActivePlanCodeForEntitlements(billing.planCode)) { + return { + allowed: false, + decision: { + status: 'blocked_upgrade_required', + denialReason: 'billing_not_configured', + action: input.action, + resource: getPrimaryUsageAmount(input.costEstimate)?.resource ?? 'research_credits', + requiredAmount: getPrimaryUsageAmount(input.costEstimate)?.amount ?? 0, + remainingAmount: 0, + currentPlanCode: null, + suggestedUpgradePlanCode: 'starter_monthly', + addonEligible: false, + contactSalesRequired: false, + }, + }; + } + + if (!input.costEstimate.isChargeable) { + return { + allowed: true, + decision: evaluateActionEntitlement({ + planCode: billing.planCode, + action: input.action, + resource: getPrimaryUsageAmount(input.costEstimate)?.resource ?? 'research_credits', + requiredAmount: getPrimaryUsageAmount(input.costEstimate)?.amount ?? 0, + remainingAmount: null, + }), + }; + } + + const primaryAmount = getPrimaryUsageAmount(input.costEstimate); + + if (!primaryAmount) { + throw new Error(`Missing usage amount for chargeable action '${input.action}'.`); + } + + const remainingAmount = getRemainingAmountForResource(billing, primaryAmount.resource); + const decision = evaluateActionEntitlement({ + planCode: billing.planCode, + action: input.action, + resource: primaryAmount.resource, + requiredAmount: primaryAmount.amount, + remainingAmount, + }); + + return { + allowed: decision.status === 'allowed', + decision, + }; +} + +export async function recordSuccessfulActionUsage( + db: DbClient, + input: UsageRecordingInput, +): Promise { + if (!input.costEstimate.isChargeable) { + return; + } + + const billingAccount = await ensureBillingAccountForWorkspace(db, input.workspaceId); + const usagePeriod = await ensureCurrentUsagePeriodForBillingAccount(db, billingAccount); + + if (!usagePeriod) { + return; + } + + for (const usageAmount of input.costEstimate.amounts) { + if (usageAmount.amount <= 0) { + continue; + } + + await incrementUsageCounter(db, { + usagePeriodId: usagePeriod.id, + workspaceId: input.workspaceId, + resource: usageAmount.resource, + delta: usageAmount.amount, + }); + } +} + +export function buildEntitlementErrorResponse(decision: EntitlementDecision) { + return { + statusCode: decision.denialReason === 'quota_exhausted' ? 409 : 403, + body: { + error: formatEntitlementErrorMessage(decision), + code: 'entitlement_blocked' as const, + entitlement: decision, + }, + }; +} + +function getRemainingAmountForResource(billing: AccountBillingState, resource: UsageResource): number | null { + const resourceSummary = billing.usage.find((usage) => usage.resource === resource); + + if (!resourceSummary) { + return 0; + } + + return resourceSummary.remaining; +} + +function getPrimaryUsageAmount(costEstimate: UsageCostEstimate): UsageAmount | null { + return costEstimate.amounts[0] ?? null; +} + +function formatEntitlementErrorMessage(decision: EntitlementDecision) { + switch (decision.denialReason) { + case 'billing_not_configured': + return 'A billing plan is required before this action can run.'; + case 'feature_not_available': + return 'Your current plan does not include this feature.'; + case 'not_launch_ready': + return 'This feature is included in plan definitions but is not launch-ready yet.'; + case 'custom_enterprise_only': + return 'This action requires an enterprise or custom sales engagement.'; + case 'quota_exhausted': + return 'Your current plan has exhausted the available allowance for this action.'; + default: + return 'This action is blocked by the current entitlement policy.'; + } +} + +// Export policy exists in shared billing modules, but route-level export enforcement +// stays deferred until export generation moves to a backend endpoint. diff --git a/server/src/billing/repository.ts b/server/src/billing/repository.ts new file mode 100644 index 0000000..b4d34db --- /dev/null +++ b/server/src/billing/repository.ts @@ -0,0 +1,521 @@ +import type { Pool, PoolClient } from 'pg'; +import type { AddonCode, BillingInterval, PlanCode } from '../../../shared/billing/plans.js'; +import type { AccountBillingStatus, BillingAddonBalanceSummary } from '../../../shared/types.js'; +import type { UsageResource } from '../../../shared/billing/entitlements.js'; + +type DbClient = Pool | PoolClient; + +export interface BillingAccountRecord { + id: string; + workspaceId: string; + planCode: PlanCode | null; + billingInterval: BillingInterval | null; + status: AccountBillingStatus; + currentPeriodStartsAt: string | null; + currentPeriodEndsAt: string | null; + cancelAtPeriodEnd: boolean; + canceledAt: string | null; + trialEndsAt: string | null; + externalCustomerRef: string | null; + externalSubscriptionRef: string | null; + createdAt: string; + updatedAt: string; +} + +export interface UsagePeriodRecord { + id: string; + workspaceId: string; + billingAccountId: string; + periodStartsAt: string; + periodEndsAt: string; + createdAt: string; + updatedAt: string; +} + +export interface UsageCounterRecord { + id: string; + usagePeriodId: string; + workspaceId: string; + resource: UsageResource; + consumedQuantity: number; + createdAt: string; + updatedAt: string; +} + +export interface AddonPurchaseRecord { + id: string; + workspaceId: string; + addonCode: AddonCode; + resource: UsageResource; + purchasedQuantity: number; + remainingQuantity: number; + purchasedAt: string; + expiresAt: string | null; + externalPurchaseRef: string | null; + createdAt: string; + updatedAt: string; +} + +type BillingAccountRow = { + id: string; + workspace_id: string; + plan_code: string | null; + billing_interval: BillingInterval | null; + status: AccountBillingStatus; + current_period_starts_at: string | null; + current_period_ends_at: string | null; + cancel_at_period_end: boolean; + canceled_at: string | null; + trial_ends_at: string | null; + external_customer_ref: string | null; + external_subscription_ref: string | null; + created_at: string; + updated_at: string; +}; + +type UsagePeriodRow = { + id: string; + workspace_id: string; + billing_account_id: string; + period_starts_at: string; + period_ends_at: string; + created_at: string; + updated_at: string; +}; + +type UsageCounterRow = { + id: string; + usage_period_id: string; + workspace_id: string; + resource: UsageResource; + consumed_quantity: string; + created_at: string; + updated_at: string; +}; + +type AddonBalanceRow = { + addon_code: AddonCode; + resource: UsageResource; + remaining_quantity: string; + expires_at: string | null; +}; + +type AddonPurchaseRow = { + id: string; + workspace_id: string; + addon_code: AddonCode; + resource: UsageResource; + purchased_quantity: string; + remaining_quantity: string; + purchased_at: string; + expires_at: string | null; + external_purchase_ref: string | null; + created_at: string; + updated_at: string; +}; + +export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: string): Promise { + const result = await db.query( + ` + 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, + external_customer_ref, external_subscription_ref, + created_at, updated_at + from public.workspace_billing_accounts + where workspace_id = $1 + limit 1 + `, + [workspaceId], + ); + + if (result.rowCount === 0) { + return null; + } + + return mapBillingAccountRow(result.rows[0]); +} + +export async function createDefaultBillingAccountForWorkspace(db: DbClient, workspaceId: string): Promise { + const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds(); + const result = await db.query( + ` + insert into public.workspace_billing_accounts ( + workspace_id, + plan_code, + billing_interval, + status, + current_period_starts_at, + current_period_ends_at + ) + values ($1, 'starter_monthly', 'monthly', 'active', $2, $3) + on conflict (workspace_id) do update set workspace_id = excluded.workspace_id + 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, + external_customer_ref, external_subscription_ref, + created_at, updated_at + `, + [workspaceId, currentPeriodStartsAt, currentPeriodEndsAt], + ); + + return mapBillingAccountRow(result.rows[0]); +} + +export async function ensureBillingAccountForWorkspace(db: DbClient, workspaceId: string) { + const existingAccount = await getBillingAccountForWorkspace(db, workspaceId); + if (existingAccount) { + if (!existingAccount.planCode && existingAccount.status === 'not_configured') { + return bootstrapDefaultBillingAccountState(db, workspaceId); + } + + return existingAccount; + } + + return createDefaultBillingAccountForWorkspace(db, workspaceId); +} + +export async function updateBillingAccountState( + db: DbClient, + input: { + workspaceId: string; + planCode: PlanCode | null; + billingInterval: BillingInterval | null; + status: AccountBillingStatus; + currentPeriodStartsAt?: string | null; + currentPeriodEndsAt?: string | null; + cancelAtPeriodEnd?: boolean; + canceledAt?: string | null; + trialEndsAt?: string | null; + externalCustomerRef?: string | null; + externalSubscriptionRef?: string | null; + }, +) { + const result = await db.query( + ` + insert into public.workspace_billing_accounts ( + workspace_id, + plan_code, + billing_interval, + status, + current_period_starts_at, + current_period_ends_at, + cancel_at_period_end, + canceled_at, + trial_ends_at, + external_customer_ref, + external_subscription_ref + ) + values ($1, $2, $3, $4, $5, $6, coalesce($7, false), $8, $9, $10, $11) + on conflict (workspace_id) + do update set + plan_code = excluded.plan_code, + billing_interval = excluded.billing_interval, + status = excluded.status, + current_period_starts_at = excluded.current_period_starts_at, + current_period_ends_at = excluded.current_period_ends_at, + cancel_at_period_end = excluded.cancel_at_period_end, + canceled_at = excluded.canceled_at, + trial_ends_at = excluded.trial_ends_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, + external_customer_ref, external_subscription_ref, + created_at, updated_at + `, + [ + input.workspaceId, + input.planCode, + input.billingInterval, + input.status, + input.currentPeriodStartsAt ?? null, + input.currentPeriodEndsAt ?? null, + input.cancelAtPeriodEnd ?? false, + input.canceledAt ?? null, + input.trialEndsAt ?? null, + input.externalCustomerRef ?? null, + input.externalSubscriptionRef ?? null, + ], + ); + + return mapBillingAccountRow(result.rows[0]); +} + +export async function getUsagePeriodForWorkspace( + db: DbClient, + workspaceId: string, + periodStartsAt: string, + periodEndsAt: string, +): Promise { + const result = await db.query( + ` + select id, workspace_id, billing_account_id, period_starts_at, period_ends_at, created_at, updated_at + from public.workspace_usage_periods + where workspace_id = $1 and period_starts_at = $2 and period_ends_at = $3 + limit 1 + `, + [workspaceId, periodStartsAt, periodEndsAt], + ); + + if (result.rowCount === 0) { + return null; + } + + return mapUsagePeriodRow(result.rows[0]); +} + +export async function createUsagePeriodForWorkspace( + db: DbClient, + input: { workspaceId: string; billingAccountId: string; periodStartsAt: string; periodEndsAt: string }, +): Promise { + const result = await db.query( + ` + insert into public.workspace_usage_periods (workspace_id, billing_account_id, period_starts_at, period_ends_at) + values ($1, $2, $3, $4) + on conflict (workspace_id, period_starts_at, period_ends_at) + do update set billing_account_id = excluded.billing_account_id + returning id, workspace_id, billing_account_id, period_starts_at, period_ends_at, created_at, updated_at + `, + [input.workspaceId, input.billingAccountId, input.periodStartsAt, input.periodEndsAt], + ); + + return mapUsagePeriodRow(result.rows[0]); +} + +export async function ensureUsagePeriodForWorkspace( + db: DbClient, + input: { workspaceId: string; billingAccountId: string; periodStartsAt: string; periodEndsAt: string }, +) { + const existingPeriod = await getUsagePeriodForWorkspace(db, input.workspaceId, input.periodStartsAt, input.periodEndsAt); + if (existingPeriod) { + return existingPeriod; + } + + return createUsagePeriodForWorkspace(db, input); +} + +export async function listUsageCountersForPeriod(db: DbClient, usagePeriodId: string): Promise { + const result = await db.query( + ` + select id, usage_period_id, workspace_id, resource, consumed_quantity, created_at, updated_at + from public.workspace_usage_counters + where usage_period_id = $1 + order by resource asc + `, + [usagePeriodId], + ); + + return result.rows.map(mapUsageCounterRow); +} + +export async function upsertUsageCounter( + db: DbClient, + input: { usagePeriodId: string; workspaceId: string; resource: UsageResource; consumedQuantity: number }, +) { + const result = await db.query( + ` + insert into public.workspace_usage_counters (usage_period_id, workspace_id, resource, consumed_quantity) + values ($1, $2, $3, $4) + on conflict (usage_period_id, resource) + do update set consumed_quantity = excluded.consumed_quantity + returning id, usage_period_id, workspace_id, resource, consumed_quantity, created_at, updated_at + `, + [input.usagePeriodId, input.workspaceId, input.resource, input.consumedQuantity], + ); + + return mapUsageCounterRow(result.rows[0]); +} + +export async function incrementUsageCounter( + db: DbClient, + input: { usagePeriodId: string; workspaceId: string; resource: UsageResource; delta: number }, +) { + const result = await db.query( + ` + insert into public.workspace_usage_counters (usage_period_id, workspace_id, resource, consumed_quantity) + values ($1, $2, $3, greatest($4, 0)) + on conflict (usage_period_id, resource) + do update set consumed_quantity = greatest(public.workspace_usage_counters.consumed_quantity + $4, 0) + returning id, usage_period_id, workspace_id, resource, consumed_quantity, created_at, updated_at + `, + [input.usagePeriodId, input.workspaceId, input.resource, input.delta], + ); + + return mapUsageCounterRow(result.rows[0]); +} + +export async function listAddonBalancesForWorkspace(db: DbClient, workspaceId: string): Promise { + const result = await db.query( + ` + select addon_code, resource, remaining_quantity, expires_at + from public.workspace_addon_balances + where workspace_id = $1 + order by addon_code asc, resource asc + `, + [workspaceId], + ); + + return result.rows.map((row) => ({ + addonCode: row.addon_code, + resource: row.resource, + remainingQuantity: Number(row.remaining_quantity), + expiresAt: row.expires_at, + })); +} + +export async function recordAddonPurchase( + db: DbClient, + input: { + workspaceId: string; + addonCode: AddonCode; + resource: UsageResource; + purchasedQuantity: number; + remainingQuantity: number; + purchasedAt?: string; + expiresAt?: string | null; + externalPurchaseRef?: string | null; + }, +): Promise { + const result = await db.query( + ` + insert into public.workspace_addon_purchases ( + workspace_id, addon_code, resource, purchased_quantity, remaining_quantity, + purchased_at, expires_at, external_purchase_ref + ) + values ($1, $2, $3, $4, $5, coalesce($6, now()), $7, $8) + returning id, workspace_id, addon_code, resource, purchased_quantity, remaining_quantity, + purchased_at, expires_at, external_purchase_ref, created_at, updated_at + `, + [ + input.workspaceId, + input.addonCode, + input.resource, + input.purchasedQuantity, + input.remainingQuantity, + input.purchasedAt ?? null, + input.expiresAt ?? null, + input.externalPurchaseRef ?? null, + ], + ); + + return mapAddonPurchaseRow(result.rows[0]); +} + +export async function upsertAddonBalance( + db: DbClient, + input: { + workspaceId: string; + addonCode: AddonCode; + resource: UsageResource; + remainingQuantity: number; + expiresAt?: string | null; + }, +): Promise { + const result = await db.query( + ` + insert into public.workspace_addon_balances (workspace_id, addon_code, resource, remaining_quantity, expires_at) + values ($1, $2, $3, $4, $5) + on conflict (workspace_id, addon_code, resource) + do update set + remaining_quantity = excluded.remaining_quantity, + expires_at = excluded.expires_at + returning addon_code, resource, remaining_quantity, expires_at + `, + [input.workspaceId, input.addonCode, input.resource, input.remainingQuantity, input.expiresAt ?? null], + ); + + const row = result.rows[0]; + + return { + addonCode: row.addon_code, + resource: row.resource, + remainingQuantity: Number(row.remaining_quantity), + expiresAt: row.expires_at, + }; +} + +function mapBillingAccountRow(row: BillingAccountRow): BillingAccountRecord { + return { + id: row.id, + workspaceId: row.workspace_id, + planCode: row.plan_code as PlanCode | null, + billingInterval: row.billing_interval, + status: row.status, + currentPeriodStartsAt: row.current_period_starts_at, + currentPeriodEndsAt: row.current_period_ends_at, + cancelAtPeriodEnd: row.cancel_at_period_end, + canceledAt: row.canceled_at, + trialEndsAt: row.trial_ends_at, + externalCustomerRef: row.external_customer_ref, + externalSubscriptionRef: row.external_subscription_ref, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapUsagePeriodRow(row: UsagePeriodRow): UsagePeriodRecord { + return { + id: row.id, + workspaceId: row.workspace_id, + billingAccountId: row.billing_account_id, + periodStartsAt: row.period_starts_at, + periodEndsAt: row.period_ends_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapUsageCounterRow(row: UsageCounterRow): UsageCounterRecord { + return { + id: row.id, + usagePeriodId: row.usage_period_id, + workspaceId: row.workspace_id, + resource: row.resource, + consumedQuantity: Number(row.consumed_quantity), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function getDefaultBillingPeriodBounds() { + const currentPeriodStartsAt = new Date(); + const currentPeriodEndsAt = new Date(currentPeriodStartsAt); + currentPeriodEndsAt.setUTCMonth(currentPeriodEndsAt.getUTCMonth() + 1); + + return { + currentPeriodStartsAt: currentPeriodStartsAt.toISOString(), + currentPeriodEndsAt: currentPeriodEndsAt.toISOString(), + }; +} + +async function bootstrapDefaultBillingAccountState(db: DbClient, workspaceId: string) { + const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds(); + + return updateBillingAccountState(db, { + workspaceId, + planCode: 'starter_monthly', + billingInterval: 'monthly', + status: 'active', + currentPeriodStartsAt, + currentPeriodEndsAt, + cancelAtPeriodEnd: false, + }); +} + +function mapAddonPurchaseRow(row: AddonPurchaseRow): AddonPurchaseRecord { + return { + id: row.id, + workspaceId: row.workspace_id, + addonCode: row.addon_code, + resource: row.resource, + purchasedQuantity: Number(row.purchased_quantity), + remainingQuantity: Number(row.remaining_quantity), + purchasedAt: row.purchased_at, + expiresAt: row.expires_at, + externalPurchaseRef: row.external_purchase_ref, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} diff --git a/server/src/billing/service.ts b/server/src/billing/service.ts new file mode 100644 index 0000000..7d0dd56 --- /dev/null +++ b/server/src/billing/service.ts @@ -0,0 +1,193 @@ +import type { Pool, PoolClient } from 'pg'; +import { getUsageAllowanceForPlan, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js'; +import type { BillingAddonBalanceSummary, BillingUsageResourceSummary, AccountBillingState } from '../../../shared/types.js'; +import type { UsageResource } from '../../../shared/billing/entitlements.js'; +import { + ensureBillingAccountForWorkspace, + ensureUsagePeriodForWorkspace, + listAddonBalancesForWorkspace, + listUsageCountersForPeriod, + type BillingAccountRecord, + type UsagePeriodRecord, +} from './repository.js'; + +type DbClient = Pool | PoolClient; + +export async function getWorkspaceBillingState(db: DbClient, workspaceId: string): Promise { + const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId); + + if (!billingAccount.planCode || !isActivePlanCodeForEntitlements(billingAccount.planCode)) { + return { + status: billingAccount.status, + planCode: billingAccount.planCode, + billingInterval: billingAccount.billingInterval, + currentPeriodStartsAt: billingAccount.currentPeriodStartsAt, + currentPeriodEndsAt: billingAccount.currentPeriodEndsAt, + cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd, + 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.', + }; + } + + const usageSnapshot = await getWorkspaceUsageSnapshot(db, billingAccount); + const addonBalances = await listAddonBalancesForWorkspace(db, workspaceId); + + return { + status: billingAccount.status, + planCode: billingAccount.planCode, + billingInterval: billingAccount.billingInterval, + currentPeriodStartsAt: usageSnapshot.currentPeriodStartsAt, + currentPeriodEndsAt: usageSnapshot.currentPeriodEndsAt, + cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd, + 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.', + }; +} + +export async function getWorkspaceUsageSnapshot(db: DbClient, billingAccount: BillingAccountRecord): Promise<{ + currentPeriodStartsAt: string | null; + currentPeriodEndsAt: string | null; + usage: BillingUsageResourceSummary[]; +}> { + if (!billingAccount.planCode || !isActivePlanCodeForEntitlements(billingAccount.planCode)) { + return { + currentPeriodStartsAt: billingAccount.currentPeriodStartsAt, + currentPeriodEndsAt: billingAccount.currentPeriodEndsAt, + usage: [], + }; + } + + const usageWindow = resolveUsageWindow(billingAccount, new Date()); + + if (!usageWindow) { + const allowance = getUsageAllowanceForPlan(billingAccount.planCode); + return { + currentPeriodStartsAt: billingAccount.currentPeriodStartsAt, + currentPeriodEndsAt: billingAccount.currentPeriodEndsAt, + usage: buildUsageSummaries(allowance, new Map()), + }; + } + + const usagePeriod = await ensureUsagePeriodForWorkspace(db, { + workspaceId: billingAccount.workspaceId, + billingAccountId: billingAccount.id, + periodStartsAt: usageWindow.periodStartsAt, + periodEndsAt: usageWindow.periodEndsAt, + }); + + const counters = await listUsageCountersForPeriod(db, usagePeriod.id); + const allowance = getUsageAllowanceForPlan(billingAccount.planCode); + + return { + currentPeriodStartsAt: usagePeriod.periodStartsAt, + currentPeriodEndsAt: usagePeriod.periodEndsAt, + usage: buildUsageSummaries( + allowance, + new Map(counters.map((counter) => [counter.resource, counter.consumedQuantity])), + ), + }; +} + +export async function ensureCurrentUsagePeriodForBillingAccount( + db: DbClient, + billingAccount: BillingAccountRecord, +): Promise { + if (!billingAccount.planCode || !isActivePlanCodeForEntitlements(billingAccount.planCode)) { + return null; + } + + const usageWindow = resolveUsageWindow(billingAccount, new Date()); + + if (!usageWindow) { + return null; + } + + return ensureUsagePeriodForWorkspace(db, { + workspaceId: billingAccount.workspaceId, + billingAccountId: billingAccount.id, + periodStartsAt: usageWindow.periodStartsAt, + periodEndsAt: usageWindow.periodEndsAt, + }); +} + +function resolveUsageWindow(billingAccount: BillingAccountRecord, referenceDate: Date) { + if ( + !billingAccount.currentPeriodStartsAt + || !billingAccount.currentPeriodEndsAt + || (billingAccount.status !== 'active' && billingAccount.status !== 'past_due') + ) { + return null; + } + + const billingPeriodStartsAt = new Date(billingAccount.currentPeriodStartsAt); + const billingPeriodEndsAt = new Date(billingAccount.currentPeriodEndsAt); + + if (Number.isNaN(billingPeriodStartsAt.getTime()) || Number.isNaN(billingPeriodEndsAt.getTime())) { + return null; + } + + // If the stored billing period is already over, do not treat it as the current + // usage window. Later subscription lifecycle handling can advance the period. + if (referenceDate >= billingPeriodEndsAt) { + return null; + } + + let usagePeriodStartsAt = billingPeriodStartsAt; + let usagePeriodEndsAt = minDate(addMonthsUtc(usagePeriodStartsAt, 1), billingPeriodEndsAt); + + while (usagePeriodEndsAt <= referenceDate && usagePeriodEndsAt < billingPeriodEndsAt) { + usagePeriodStartsAt = usagePeriodEndsAt; + usagePeriodEndsAt = minDate(addMonthsUtc(usagePeriodStartsAt, 1), billingPeriodEndsAt); + } + + return { + periodStartsAt: usagePeriodStartsAt.toISOString(), + periodEndsAt: usagePeriodEndsAt.toISOString(), + }; +} + +function buildUsageSummaries( + allowance: ReturnType, + consumedByResource: Map, +): BillingUsageResourceSummary[] { + return [allowance.researchCredits, allowance.exports, allowance.enrichments, allowance.apiRequests].map((usageAllowance) => { + const consumed = consumedByResource.get(usageAllowance.resource) ?? 0; + let remaining: number | null; + + // Unavailable resources should report a hard zero remaining balance so later + // enforcement and UI layers do not interpret them as unlimited or unknown. + if (usageAllowance.availability === 'not_available') { + remaining = 0; + } else if (usageAllowance.availability === 'custom' || usageAllowance.availability === 'unlimited') { + remaining = null; + } else { + remaining = usageAllowance.included === null ? 0 : Math.max(usageAllowance.included - consumed, 0); + } + + return { + resource: usageAllowance.resource, + availability: usageAllowance.availability, + included: usageAllowance.included, + consumed, + remaining, + }; + }); +} + +function addMonthsUtc(date: Date, monthsToAdd: number) { + const nextDate = new Date(date); + nextDate.setUTCMonth(nextDate.getUTCMonth() + monthsToAdd); + return nextDate; +} + +function minDate(a: Date, b: Date) { + return a.getTime() <= b.getTime() ? a : b; +} + +export async function getWorkspaceAddonBalances(db: DbClient, workspaceId: string): Promise { + return listAddonBalancesForWorkspace(db, workspaceId); +} diff --git a/server/src/deep-research/service.ts b/server/src/deep-research/service.ts index 685ab0b..9805745 100644 --- a/server/src/deep-research/service.ts +++ b/server/src/deep-research/service.ts @@ -1,4 +1,5 @@ import type { Pool } from 'pg'; +import { estimateDeepResearchBatchCost, type UsageCostEstimate } from '../../../shared/billing/entitlements.js'; import type { CreateDeepResearchBatchRequest, DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchPreviewRequest, JobStatus } from '../../../shared/types.js'; import { listPostalAreasByPropagation, findPostalAreaContainingPoint } from '../postal/repository.js'; import { previewDeepResearchForPoint } from '../postal/service.js'; @@ -23,12 +24,29 @@ export async function getDeepResearchBatchDetail(db: Pool, userId: string, batch return getDeepResearchBatchDetailForUser(db, userId, batchId); } +export async function getDeepResearchBatchEstimate( + db: Pool, + input: CreateDeepResearchBatchRequest, +): Promise<{ preview: Awaited>; costEstimate: UsageCostEstimate }> { + const preview = await previewDeepResearchForPoint(db, input as DeepResearchPreviewRequest); + const costEstimate = estimateDeepResearchBatchCost({ + estimatedChildJobs: preview.estimatedChildJobs, + totalAreas: preview.totalAreas, + }); + + return { + preview, + costEstimate, + }; +} + export async function createDeepResearchBatchForUser( db: Pool, userId: string, input: CreateDeepResearchBatchRequest, + options?: { preview?: Awaited> }, ): Promise { - const preview = await previewDeepResearchForPoint(db, input as DeepResearchPreviewRequest); + const preview = options?.preview ?? await previewDeepResearchForPoint(db, input as DeepResearchPreviewRequest); const baseArea = await findPostalAreaContainingPoint(db, input.lat, input.lng); if (!baseArea) { diff --git a/server/src/routes/deep-research.ts b/server/src/routes/deep-research.ts index e2e73fb..2a4b15c 100644 --- a/server/src/routes/deep-research.ts +++ b/server/src/routes/deep-research.ts @@ -1,8 +1,10 @@ import type { FastifyPluginAsync } from 'fastify'; import { ZodError, z } from 'zod'; +import { estimateDeepResearchBatchCost } from '../../../shared/billing/entitlements.js'; import { requireAuth } from '../auth/middleware.js'; +import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js'; import { getDbPool } from '../db/pool.js'; -import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, listDeepResearchBatches } from '../deep-research/service.js'; +import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, getDeepResearchBatchEstimate, listDeepResearchBatches } from '../deep-research/service.js'; import { previewDeepResearchForPoint } from '../postal/service.js'; const previewSchema = z.object({ @@ -40,7 +42,27 @@ export const deepResearchRoutes: FastifyPluginAsync = async (app) => { app.post('/deep-research/batches', { preHandler: requireAuth }, async (request, reply) => { try { const payload = previewSchema.parse(request.body); - const batch = await createDeepResearchBatchForUser(getDbPool(), request.authUser!.id, payload); + const db = getDbPool(); + const enforcementContext = await getWorkspaceEnforcementContext(db, request.authUser!); + const { preview, costEstimate } = await getDeepResearchBatchEstimate(db, payload); + const enforcement = await checkActionEntitlementForWorkspace(db, { + userId: request.authUser!.id, + workspaceId: enforcementContext.workspaceId, + action: 'deep_research_batch_run', + costEstimate, + }); + + if (!enforcement.allowed) { + const errorResponse = buildEntitlementErrorResponse(enforcement.decision); + return reply.code(errorResponse.statusCode).send(errorResponse.body); + } + + const batch = await createDeepResearchBatchForUser(db, request.authUser!.id, payload, { preview }); + await recordSuccessfulActionUsage(db, { + workspaceId: enforcementContext.workspaceId, + action: 'deep_research_batch_run', + costEstimate, + }); return reply.code(201).send({ batch }); } catch (error) { if (error instanceof ZodError) { diff --git a/server/src/routes/search-jobs.ts b/server/src/routes/search-jobs.ts index 74abfba..186a4d2 100644 --- a/server/src/routes/search-jobs.ts +++ b/server/src/routes/search-jobs.ts @@ -1,7 +1,9 @@ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { requireAuth } from '../auth/middleware.js'; +import { estimateBasicSearchCost } from '../../../shared/billing/entitlements.js'; 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'; @@ -29,7 +31,26 @@ export const searchJobRoutes: FastifyPluginAsync = async (app) => { app.post('/search-jobs', { preHandler: requireAuth }, async (request, reply) => { try { const payload = runSearchSchema.parse(request.body); - const result = await runSearchForUser(getDbPool(), request.authUser!.id, payload); + const db = getDbPool(); + const enforcementContext = await getWorkspaceEnforcementContext(db, request.authUser!); + const enforcement = await checkActionEntitlementForWorkspace(db, { + userId: request.authUser!.id, + workspaceId: enforcementContext.workspaceId, + action: 'basic_search_run', + costEstimate: estimateBasicSearchCost(), + }); + + if (!enforcement.allowed) { + const errorResponse = buildEntitlementErrorResponse(enforcement.decision); + return reply.code(errorResponse.statusCode).send(errorResponse.body); + } + + const result = await runSearchForUser(db, request.authUser!.id, payload); + await recordSuccessfulActionUsage(db, { + workspaceId: enforcementContext.workspaceId, + action: 'basic_search_run', + costEstimate: estimateBasicSearchCost(), + }); return reply.code(201).send(result); } catch (error) { if (error instanceof z.ZodError) { diff --git a/shared/billing/addons.ts b/shared/billing/addons.ts new file mode 100644 index 0000000..904e0e4 --- /dev/null +++ b/shared/billing/addons.ts @@ -0,0 +1,114 @@ +import type { AddonCode, ActivePlanCode } from './plans.js'; +import { getPlanByCode } from './plans.js'; +import type { UsageResource } from './entitlements.js'; + +export type AddonType = 'resource_pack' | 'feature_addon'; + +export type AddonPurchaseMode = 'one_time' | 'recurring'; + +export type AddonAvailability = 'active' | 'coming_soon' | 'internal_only'; + +export interface AddonDefinition { + code: AddonCode; + name: string; + type: AddonType; + resource: UsageResource | null; + quantity: number | null; + priceCents: number; + currencyCode: 'USD'; + purchaseMode: AddonPurchaseMode; + availability: AddonAvailability; + description: string; +} + +const addonCatalog: AddonDefinition[] = [ + { + code: 'export_pack_10k', + name: 'Export Pack 10k', + type: 'resource_pack', + resource: 'exports', + quantity: 10000, + priceCents: 2900, + currencyCode: 'USD', + purchaseMode: 'one_time', + availability: 'active', + description: 'Add 10,000 extra exports to a workspace. Base plan exports should be consumed before this pack is used.', + }, + { + code: 'export_pack_50k', + name: 'Export Pack 50k', + type: 'resource_pack', + resource: 'exports', + quantity: 50000, + priceCents: 9900, + currencyCode: 'USD', + purchaseMode: 'one_time', + availability: 'active', + description: 'Add 50,000 extra exports to a workspace. Base plan exports should be consumed before this pack is used.', + }, + { + code: 'enrichment_pack_1k', + name: 'Enrichment Pack 1k', + type: 'resource_pack', + resource: 'enrichments', + quantity: 1000, + priceCents: 4900, + currencyCode: 'USD', + purchaseMode: 'one_time', + availability: 'coming_soon', + description: 'Add 1,000 enrichment units once enrichment actions are live.', + }, + { + code: 'ai_assistant_monthly', + name: 'AI Prospecting Assistant', + type: 'feature_addon', + resource: null, + quantity: null, + priceCents: 4900, + currencyCode: 'USD', + purchaseMode: 'recurring', + availability: 'coming_soon', + description: 'Recurring feature add-on for AI-assisted market and territory prompts.', + }, + { + code: 'white_label_monthly', + name: 'White Label Toolkit', + type: 'feature_addon', + resource: null, + quantity: null, + priceCents: 19900, + currencyCode: 'USD', + purchaseMode: 'recurring', + availability: 'coming_soon', + description: 'Recurring agency add-on for branded outputs and white-label workflows.', + }, +]; + +const addonCatalogByCode = new Map(addonCatalog.map((addon) => [addon.code, addon])); + +export const ADDON_CATALOG = [...addonCatalog]; + +export function getAddonByCode(code: AddonCode) { + return addonCatalogByCode.get(code) ?? null; +} + +export function getActiveAddons() { + return ADDON_CATALOG.filter((addon) => addon.availability === 'active'); +} + +export function getAddonsForResource(resource: UsageResource) { + return ADDON_CATALOG.filter((addon) => addon.resource === resource); +} + +export function getEligibleAddonsForPlan(planCode: ActivePlanCode, options?: { includeComingSoon?: boolean }) { + const plan = getPlanByCode(planCode); + + if (!plan) { + return []; + } + + return plan.eligibleAddonCodes + .map((addonCode) => getAddonByCode(addonCode)) + .filter((addon): addon is AddonDefinition => addon !== null) + .filter((addon) => (options?.includeComingSoon ? addon.availability !== 'internal_only' : addon.availability === 'active')); +} diff --git a/shared/billing/entitlements.ts b/shared/billing/entitlements.ts index 4fd30ee..1f7779a 100644 --- a/shared/billing/entitlements.ts +++ b/shared/billing/entitlements.ts @@ -24,7 +24,7 @@ export type EntitlementDecisionStatus = | 'blocked_addon_available' | 'contact_sales_required'; -export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready'; +export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready' | 'billing_not_configured'; export interface UsageSubject { type: UsageSubjectType; @@ -75,7 +75,7 @@ export interface EntitlementDecision { resource: UsageResource; requiredAmount: number; remainingAmount: number | null; - currentPlanCode: ActivePlanCode; + currentPlanCode: ActivePlanCode | null; suggestedUpgradePlanCode: ActivePlanCode | null; addonEligible: boolean; contactSalesRequired: boolean; diff --git a/shared/billing/plans.ts b/shared/billing/plans.ts index 0fcebb1..19bab2f 100644 --- a/shared/billing/plans.ts +++ b/shared/billing/plans.ts @@ -524,6 +524,18 @@ export function getPublicPricingPlans() { return ACTIVE_PLAN_CATALOG.filter((plan) => plan.listingCategory === 'pricing_page_primary'); } +export function getPublicPricingPlansForInterval(billingInterval: BillingInterval) { + return ACTIVE_PLAN_CATALOG.filter((plan) => { + if (plan.code === 'enterprise_custom') { + return true; + } + + return plan.listingCategory === 'pricing_page_hidden' + ? plan.billingInterval === billingInterval + : plan.listingCategory === 'pricing_page_primary' && plan.billingInterval === billingInterval; + }); +} + export function getPlanVariant(tier: PlanTier, billingInterval: BillingInterval) { return ACTIVE_PLAN_CATALOG.find((plan) => plan.planFamily === tier && plan.billingInterval === billingInterval) ?? null; } diff --git a/shared/billing/workspace-readiness.ts b/shared/billing/workspace-readiness.ts new file mode 100644 index 0000000..ad07bed --- /dev/null +++ b/shared/billing/workspace-readiness.ts @@ -0,0 +1,252 @@ +export type OwnershipScope = + | 'workspace_scoped_now' + | 'user_scoped_now_target_workspace' + | 'user_scoped_permanent'; + +export type EnforceabilityState = + | 'hard_enforce_now' + | 'soft_gate_now' + | 'requires_backend_route' + | 'requires_schema_migration' + | 'future'; + +export type CollaborationPhase = 'v1_personal_data_with_workspace_billing' | 'v2_shared_workspace_data'; + +export type WorkspaceEntityKey = + | 'workspaces' + | 'workspace_memberships' + | 'workspace_billing_accounts' + | 'workspace_usage_periods' + | 'workspace_usage_counters' + | 'workspace_addon_purchases' + | 'workspace_addon_balances' + | 'search_jobs' + | 'deep_research_batches' + | 'businesses' + | 'search_job_results' + | 'users' + | 'sessions'; + +export interface WorkspaceEntityReadiness { + entity: WorkspaceEntityKey; + currentOwnership: OwnershipScope; + targetOwnership: OwnershipScope; + notes: string; +} + +export type WorkspaceCommercialCapability = + | 'research_credits' + | 'exports' + | 'users_included' + | 'workspace_limits' + | 'shared_assets' + | 'collaboration_permissions' + | 'saved_searches' + | 'deduplication' + | 'export_history' + | 'tagging_notes' + | 'shared_lists' + | 'scheduled_research' + | 'crm_integrations' + | 'api_access' + | 'webhooks' + | 'enrichments'; + +export interface WorkspaceCapabilityReadiness { + capability: WorkspaceCommercialCapability; + enforceability: EnforceabilityState; + notes: string; +} + +export interface CollaborationPhaseDefinition { + phase: CollaborationPhase; + description: string; + includedBehaviors: string[]; +} + +export const WORKSPACE_ENTITY_READINESS: WorkspaceEntityReadiness[] = [ + { + entity: 'workspaces', + currentOwnership: 'workspace_scoped_now', + targetOwnership: 'workspace_scoped_now', + notes: 'Workspace metadata already exists and is the anchor for billing and future company-level ownership.', + }, + { + entity: 'workspace_memberships', + currentOwnership: 'workspace_scoped_now', + targetOwnership: 'workspace_scoped_now', + notes: 'Memberships exist now, but most product data is not yet shared through them.', + }, + { + entity: 'workspace_billing_accounts', + currentOwnership: 'workspace_scoped_now', + targetOwnership: 'workspace_scoped_now', + notes: 'Billing ownership is intentionally workspace-scoped and should remain that way.', + }, + { + entity: 'workspace_usage_periods', + currentOwnership: 'workspace_scoped_now', + targetOwnership: 'workspace_scoped_now', + notes: 'Usage periods are workspace-scoped and power quota resets.', + }, + { + entity: 'workspace_usage_counters', + currentOwnership: 'workspace_scoped_now', + targetOwnership: 'workspace_scoped_now', + notes: 'Usage counters already align with workspace billing and should stay workspace-owned.', + }, + { + entity: 'workspace_addon_purchases', + currentOwnership: 'workspace_scoped_now', + targetOwnership: 'workspace_scoped_now', + notes: 'Add-on purchase history is workspace-owned so extra capacity can be shared later.', + }, + { + entity: 'workspace_addon_balances', + currentOwnership: 'workspace_scoped_now', + targetOwnership: 'workspace_scoped_now', + notes: 'Add-on balances follow the same workspace billing model as quota usage.', + }, + { + entity: 'search_jobs', + currentOwnership: 'user_scoped_now_target_workspace', + targetOwnership: 'workspace_scoped_now', + notes: 'Search jobs are still keyed by user and should gain workspace ownership before true team history is promised.', + }, + { + entity: 'deep_research_batches', + currentOwnership: 'user_scoped_now_target_workspace', + targetOwnership: 'workspace_scoped_now', + notes: 'Deep research batches are user-owned today but need workspace ownership for shared territory workflows.', + }, + { + entity: 'businesses', + currentOwnership: 'user_scoped_now_target_workspace', + targetOwnership: 'workspace_scoped_now', + notes: 'Saved businesses should become workspace-owned before collaboration or shared exports are sold as real capabilities.', + }, + { + entity: 'search_job_results', + currentOwnership: 'user_scoped_now_target_workspace', + targetOwnership: 'workspace_scoped_now', + notes: 'Result links follow search jobs and businesses and should migrate with them.', + }, + { + entity: 'users', + currentOwnership: 'user_scoped_permanent', + targetOwnership: 'user_scoped_permanent', + notes: 'Profiles and identities remain user-scoped even as product data becomes workspace-owned.', + }, + { + entity: 'sessions', + currentOwnership: 'user_scoped_permanent', + targetOwnership: 'user_scoped_permanent', + notes: 'Sessions stay tied to user authentication, not workspace data ownership.', + }, +]; + +export const WORKSPACE_CAPABILITY_READINESS: WorkspaceCapabilityReadiness[] = [ + { + capability: 'research_credits', + enforceability: 'hard_enforce_now', + notes: 'Workspace-scoped billing and counters already support hard enforcement for research actions.', + }, + { + capability: 'exports', + enforceability: 'requires_backend_route', + notes: 'Export policy exists, but hard enforcement waits on a backend export endpoint.', + }, + { + capability: 'users_included', + enforceability: 'soft_gate_now', + notes: 'Workspace membership counts exist, but product data is not yet shared enough for full seat enforcement.', + }, + { + capability: 'workspace_limits', + enforceability: 'soft_gate_now', + notes: 'Commercial workspace limits can be surfaced, but multi-workspace UX and switching are still limited.', + }, + { + capability: 'shared_assets', + enforceability: 'requires_schema_migration', + notes: 'Shared search history, saved businesses, and list ownership require workspace-scoped domain data first.', + }, + { + capability: 'collaboration_permissions', + enforceability: 'requires_schema_migration', + notes: 'Role-aware collaboration depends on moving core entities from user ownership to workspace ownership.', + }, + { + capability: 'saved_searches', + enforceability: 'future', + notes: 'Marketed feature today, but still needs product implementation and likely workspace-aware persistence.', + }, + { + capability: 'deduplication', + enforceability: 'future', + notes: 'Commercially positioned but not implemented as a workspace-level workflow yet.', + }, + { + capability: 'export_history', + enforceability: 'future', + notes: 'Requires backend export jobs and persistent export records.', + }, + { + capability: 'tagging_notes', + enforceability: 'requires_schema_migration', + notes: 'Tags and notes should land on workspace-owned business/search entities before collaboration is enabled.', + }, + { + capability: 'shared_lists', + enforceability: 'requires_schema_migration', + notes: 'Shared lists require workspace-owned saved entities and list permission rules.', + }, + { + capability: 'scheduled_research', + enforceability: 'future', + notes: 'Needs async job scheduling plus ownership decisions for who can see and manage scheduled runs.', + }, + { + capability: 'crm_integrations', + enforceability: 'future', + notes: 'Requires integration surfaces and likely workspace-scoped credentials/settings.', + }, + { + capability: 'api_access', + enforceability: 'future', + notes: 'Entitlement policy exists, but actual API route surfaces and auth scopes are not live yet.', + }, + { + capability: 'webhooks', + enforceability: 'future', + notes: 'Depends on integration/event infrastructure and workspace-level endpoint management.', + }, + { + capability: 'enrichments', + enforceability: 'future', + notes: 'Entitlement model exists, but enrichment jobs and resource consumption are not active yet.', + }, +]; + +export const COLLABORATION_PHASES: CollaborationPhaseDefinition[] = [ + { + phase: 'v1_personal_data_with_workspace_billing', + description: 'Billing, quotas, and memberships are workspace-based, but most saved operational data still behaves as personal user-owned data.', + includedBehaviors: [ + 'A user consumes usage against their primary workspace.', + 'Billing and quotas are tracked at the workspace level.', + 'Search history and saved businesses remain effectively personal even inside a workspace shell.', + ], + }, + { + phase: 'v2_shared_workspace_data', + description: 'Core research and saved-business entities become workspace-owned, enabling true shared history, shared lists, and role-aware collaboration.', + includedBehaviors: [ + 'Search jobs and deep research batches are workspace-owned.', + 'Saved businesses and results can be shared across members.', + 'Collaboration permissions and shared asset rules can be enforced meaningfully.', + ], + }, +]; + +export const CURRENT_COLLABORATION_PHASE: CollaborationPhase = 'v1_personal_data_with_workspace_billing'; diff --git a/shared/types.ts b/shared/types.ts index a693d15..0a6158f 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,4 +1,5 @@ -import type { BillingInterval, PlanCode } from './billing/plans.js'; +import type { AddonCode, BillingInterval, PlanCode } from './billing/plans.js'; +import type { UsageAllowanceAvailability, UsageResource } from './billing/entitlements.js'; export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped'; @@ -34,10 +35,30 @@ export interface AccountSummary { export type AccountBillingStatus = 'not_configured' | 'inactive' | 'active' | 'past_due' | 'canceled'; +export interface BillingUsageResourceSummary { + resource: UsageResource; + availability: UsageAllowanceAvailability; + included: number | null; + consumed: number; + remaining: number | null; +} + +export interface BillingAddonBalanceSummary { + addonCode: AddonCode; + resource: UsageResource; + remainingQuantity: number; + expiresAt: string | null; +} + export interface AccountBillingState { status: AccountBillingStatus; planCode: PlanCode | null; billingInterval: BillingInterval | null; + currentPeriodStartsAt: string | null; + currentPeriodEndsAt: string | null; + cancelAtPeriodEnd: boolean; + usage: BillingUsageResourceSummary[]; + addonBalances: BillingAddonBalanceSummary[]; message: string; } diff --git a/src/App.tsx b/src/App.tsx index 12c3cb8..4903eab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,10 +19,12 @@ import { Layout, type AppTab } from './components/Layout'; import { AccountPage } from './components/AccountPage'; import { Dashboard } from './components/Dashboard'; import { MapView } from './components/MapView'; +import { PricingCards } from './components/PricingCards'; +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 { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlans } from '../shared/billing/plans'; +import type { BillingInterval } from '../shared/billing/plans'; import type { SessionUser } from '../shared/types'; import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth'; import { hasApiConfig } from './lib/api'; @@ -302,30 +304,11 @@ function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'lan setPublicPage(page); } -function formatPlanPrice(priceCents: number | null, currencyCode: string) { - if (priceCents === null) { - return 'Custom'; - } - - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currencyCode, - maximumFractionDigits: 0, - }).format(priceCents / 100); -} - -function formatPlanPeriod(billingInterval: 'monthly' | 'annual' | 'custom', contactSalesRequired: boolean) { - if (contactSalesRequired || billingInterval === 'custom') { - return 'pricing'; - } - - return billingInterval === 'annual' ? '/year' : '/month'; -} - function LandingPage(props: { onGoToAuth: (mode: 'sign_in' | 'sign_up') => void; }) { const { onGoToAuth } = props; + const [pricingInterval, setPricingInterval] = useState>('monthly'); const featureCards = [ { @@ -368,8 +351,6 @@ function LandingPage(props: { }, ] as const; - const pricingPlans = getPublicPricingPlans(); - return (
@@ -516,62 +497,29 @@ function LandingPage(props: {

-
- {pricingPlans.map((plan) => { - const display = getPlanDisplayMeta(plan.code); - const isFeatured = display.badgeLabel === 'Best Value'; - - return ( -
+
+ {(['monthly', 'annual'] as const).map((interval) => ( + + ))} +
+
-
- {formatPlanPrice(plan.priceCents, plan.currencyCode)} - {formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)} -
+
+ +
-

{display.summary}

- - - -
- {getPlanCardBullets(plan.code).map((item) => ( -
-
- -
- {item} -
- ))} -
-
- ); - })} +
+
diff --git a/src/components/AccountPage.tsx b/src/components/AccountPage.tsx index 8057854..83bb649 100644 --- a/src/components/AccountPage.tsx +++ b/src/components/AccountPage.tsx @@ -1,8 +1,22 @@ -import { Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react'; +import { AlertCircle, ArrowUpRight, Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { 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 { + formatBillingIntervalLabel, + formatBillingStatusLabel, + formatDateLabel, + formatQuantity, + formatUsageResourceName, + getBillingStatusBadgeVariant, + getSuggestedUpgradePlanCode, + getUsageProgressPercent, + getUsageWarningBarClass, + getUsageWarningMessage, + getUsageWarningState, +} from '../lib/billing-ui'; import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui'; interface AccountPageProps { @@ -101,6 +115,9 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) { } const activePlan = account.billing.planCode ? getPlanByCode(account.billing.planCode) : null; + const suggestedUpgradePlanCode = getSuggestedUpgradePlanCode(account.billing.planCode, account.billing.billingInterval); + const suggestedUpgradePlan = suggestedUpgradePlanCode ? getPlanByCode(suggestedUpgradePlanCode) : null; + const eligibleAddons = activePlan ? getEligibleAddonsForPlan(activePlan.code, { includeComingSoon: true }) : []; return ( @@ -202,7 +219,138 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {

{activePlan ? activePlan.name : 'Subscription foundation in progress'}

+
+ {formatBillingStatusLabel(account.billing.status)} + {formatBillingIntervalLabel(account.billing.billingInterval)} + {account.billing.cancelAtPeriodEnd ? Cancels at period end : null} +
+
+
+ Current period starts + {formatDateLabel(account.billing.currentPeriodStartsAt)} +
+
+ Current period ends + {formatDateLabel(account.billing.currentPeriodEndsAt)} +
+

{account.billing.message}

+ {suggestedUpgradePlan ? ( +
+

Suggested next plan

+
+
+

{suggestedUpgradePlan.name}

+

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

+
+ +
+
+ ) : null} + + + +
+
+

Usage This Period

+

Quota visibility

+
+ {account.billing.usage.some((usage) => { + const state = getUsageWarningState(usage); + return state === 'warning' || state === 'critical'; + }) ? Needs attention : null} +
+
+ {account.billing.usage.map((usage) => { + const warningMessage = getUsageWarningMessage(usage); + const progressPercent = getUsageProgressPercent(usage); + + return ( +
+
+
+

{formatUsageResourceName(usage.resource)}

+

+ Included: {formatQuantity(usage.included)} · Consumed: {formatQuantity(usage.consumed)} · Remaining: {formatQuantity(usage.remaining)} +

+
+ + {usage.availability === 'not_available' ? 'Not included' : usage.availability === 'custom' ? 'Custom' : usage.availability === 'unlimited' ? 'Unlimited' : 'Tracked'} + +
+ {progressPercent !== null ? ( +
+
+
+ ) : null} + {warningMessage ? ( +
+ + {warningMessage} +
+ ) : null} +
+ ); + })} +
+ + + +
+

Add-On Balances

+

Extra capacity

+
+ {account.billing.addonBalances.length === 0 ? ( +

No add-on balances are active yet.

+ ) : ( +
+ {account.billing.addonBalances.map((balance) => ( +
+
+
+

{balance.addonCode}

+

{formatUsageResourceName(balance.resource)}

+
+ {formatQuantity(balance.remainingQuantity)} remaining +
+

Expires: {formatDateLabel(balance.expiresAt)}

+
+ ))} +
+ )} +
+ + +
+

Available Add-Ons

+

Optional capacity and feature packs

+
+ {eligibleAddons.length === 0 ? ( +

No add-ons are configured for this plan yet.

+ ) : ( +
+ {eligibleAddons.map((addon) => ( +
+
+
+

{addon.name}

+

{addon.description}

+
+ + {addon.availability === 'active' ? 'Available' : 'Coming soon'} + +
+
+ {addon.purchaseMode === 'one_time' ? 'One-time' : 'Recurring'} + {addon.quantity === null ? 'Feature add-on' : `${formatQuantity(addon.quantity)} units`} +
+
+ ))} +
+ )}
diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx new file mode 100644 index 0000000..9f84cad --- /dev/null +++ b/src/components/PricingCards.tsx @@ -0,0 +1,70 @@ +import { Check } from 'lucide-react'; +import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlansForInterval, type BillingInterval } from '../../shared/billing/plans'; +import { Button } from './ui'; +import { formatPlanPeriod, formatPlanPrice } from '../lib/billing-ui'; + +interface PricingCardsProps { + billingInterval: Extract; + onGoToAuth: (mode: 'sign_in' | 'sign_up') => void; +} + +export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps) { + const pricingPlans = getPublicPricingPlansForInterval(billingInterval); + + return ( +
+ {pricingPlans.map((plan) => { + const display = getPlanDisplayMeta(plan.code); + const isFeatured = display.badgeLabel === 'Best Value'; + + return ( +
+
+
+

{plan.name}

+

{display.audience}

+
+ {display.badgeLabel ? ( + + {display.badgeLabel} + + ) : null} +
+ +
+ {formatPlanPrice(plan.priceCents, plan.currencyCode)} + {formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)} +
+ +

{display.summary}

+ + + +
+ {getPlanCardBullets(plan.code).map((item) => ( +
+
+ +
+ {item} +
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/PricingComparisonTable.tsx b/src/components/PricingComparisonTable.tsx new file mode 100644 index 0000000..8ce26a0 --- /dev/null +++ b/src/components/PricingComparisonTable.tsx @@ -0,0 +1,132 @@ +import { Check, Minus } from 'lucide-react'; +import { getFeatureGate } from '../../shared/billing/feature-gates'; +import { getPlanByCode, getPublicPricingPlansForInterval, type ActivePlanCode, type BillingInterval, type PlanFeatures } from '../../shared/billing/plans'; +import { Card, Badge } from './ui'; +import { formatQuantity } from '../lib/billing-ui'; + +const FEATURE_ROWS: Array<{ feature: keyof PlanFeatures; label: string }> = [ + { feature: 'csvExport', label: 'CSV export' }, + { feature: 'mapSearch', label: 'Map search' }, + { feature: 'radiusSearch', label: 'Radius search' }, + { feature: 'advancedFilters', label: 'Advanced filtering' }, + { feature: 'savedSearches', label: 'Saved searches' }, + { feature: 'territoryMapping', label: 'Territory mapping' }, + { feature: 'deduplication', label: 'Deduplication' }, + { feature: 'exportHistory', label: 'Export history' }, + { feature: 'taggingNotes', label: 'Tagging & notes' }, + { feature: 'sharedLists', label: 'Shared lists' }, + { feature: 'scheduledResearch', label: 'Scheduled research' }, + { feature: 'bulkExports', label: 'Bulk exports' }, + { feature: 'crmIntegrations', label: 'CRM integrations' }, + { feature: 'apiAccess', label: 'API access' }, + { feature: 'webhooks', label: 'Webhooks' }, + { feature: 'collaboration', label: 'Collaboration' }, + { feature: 'enrichments', label: 'Enrichments' }, + { feature: 'prioritySupport', label: 'Priority support' }, + { feature: 'sso', label: 'SSO' }, + { feature: 'sla', label: 'SLA' }, + { feature: 'whiteLabel', label: 'White-labeling' }, +]; + +interface PricingComparisonTableProps { + billingInterval: Extract; +} + +export function PricingComparisonTable({ billingInterval }: PricingComparisonTableProps) { + const plans = getPublicPricingPlansForInterval(billingInterval); + + return ( + +
+

Plan Comparison

+

Compare capabilities across plans

+

Included-but-not-ready capabilities are labeled as coming soon so the table stays honest with the current rollout phase.

+
+ +
+ + + + + {plans.map((plan) => ( + + ))} + + + + (plan.limits.researchRunsPerMonth === null ? 'Custom' : formatQuantity(plan.limits.researchRunsPerMonth)))} + /> + (plan.limits.exportsPerMonth === null ? 'Custom' : formatQuantity(plan.limits.exportsPerMonth)))} + /> + (plan.limits.usersIncluded === null ? 'Custom' : formatQuantity(plan.limits.usersIncluded)))} + /> + (plan.limits.workspacesIncluded === null ? 'Unlimited' : formatQuantity(plan.limits.workspacesIncluded)))} + /> + {FEATURE_ROWS.map(({ feature, label }) => ( + plan.code)} feature={feature} /> + ))} + +
Capability +
+ {plan.name} + {plan.code === 'growth_monthly' || plan.code === 'growth_annual' ? Best Value : null} +
+
+
+
+ ); +} + +function ComparisonValueRow({ label, values }: { label: string; values: string[] }) { + return ( + + {label} + {values.map((value, index) => ( + {value} + ))} + + ); +} + +function ComparisonFeatureRow({ label, planCodes, feature }: { label: string; planCodes: ActivePlanCode[]; feature: keyof PlanFeatures }) { + return ( + + {label} + {planCodes.map((planCode) => ( + + + + ))} + + ); +} + +function FeatureGateCell({ planCode, feature }: { planCode: ActivePlanCode; feature: keyof PlanFeatures }) { + const plan = getPlanByCode(planCode); + const gate = getFeatureGate(planCode, feature); + + if (!plan) { + return null; + } + + switch (gate.state) { + case 'available': + return Included; + case 'coming_soon': + return Coming soon; + case 'contact_sales': + return plan.code === 'enterprise_custom' && plan.features[feature] ? Coming soon : Enterprise; + case 'upgrade_required': + return Higher tier; + case 'hidden': + return Not included; + } +} diff --git a/src/lib/billing-ui.ts b/src/lib/billing-ui.ts new file mode 100644 index 0000000..2cb1b43 --- /dev/null +++ b/src/lib/billing-ui.ts @@ -0,0 +1,182 @@ +import type { ActivePlanCode, BillingInterval, PlanCode } from '../../shared/billing/plans'; +import { getPlanByCode, getPlanVariant } from '../../shared/billing/plans'; +import type { AccountBillingStatus, BillingUsageResourceSummary } from '../../shared/types'; + +export type UsageWarningState = 'healthy' | 'warning' | 'critical' | 'unavailable'; + +export function formatPlanPrice(priceCents: number | null, currencyCode: string) { + if (priceCents === null) { + return 'Custom'; + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + maximumFractionDigits: 0, + }).format(priceCents / 100); +} + +export function formatPlanPeriod(billingInterval: BillingInterval, contactSalesRequired: boolean) { + if (contactSalesRequired || billingInterval === 'custom') { + return 'pricing'; + } + + return billingInterval === 'annual' ? '/year' : '/month'; +} + +export function formatBillingIntervalLabel(billingInterval: BillingInterval | null) { + if (!billingInterval) { + return 'Not scheduled'; + } + + switch (billingInterval) { + case 'monthly': + return 'Monthly'; + case 'annual': + return 'Annual'; + case 'custom': + return 'Custom'; + } +} + +export function formatBillingStatusLabel(status: AccountBillingStatus) { + switch (status) { + case 'active': + return 'Active'; + case 'past_due': + return 'Past due'; + case 'inactive': + return 'Inactive'; + case 'canceled': + return 'Canceled'; + case 'not_configured': + return 'Bootstrap'; + } +} + +export function getBillingStatusBadgeVariant(status: AccountBillingStatus) { + switch (status) { + case 'active': + return 'success' as const; + case 'past_due': + return 'warning' as const; + case 'inactive': + case 'canceled': + return 'danger' as const; + case 'not_configured': + return 'info' as const; + } +} + +export function formatUsageResourceName(resource: BillingUsageResourceSummary['resource']) { + switch (resource) { + case 'research_credits': + return 'Research credits'; + case 'exports': + return 'Exports'; + case 'enrichments': + return 'Enrichments'; + case 'api_requests': + return 'API requests'; + } +} + +export function formatQuantity(value: number | null) { + if (value === null) { + return 'Custom'; + } + + return new Intl.NumberFormat('en-US').format(value); +} + +export function getUsageWarningState(summary: BillingUsageResourceSummary): UsageWarningState { + if (summary.availability === 'not_available') { + return 'unavailable'; + } + + if (summary.included === null || summary.remaining === null || summary.included <= 0) { + return 'healthy'; + } + + const remainingRatio = summary.remaining / summary.included; + + if (summary.remaining === 0 || remainingRatio <= 0.1) { + return 'critical'; + } + + if (remainingRatio <= 0.2) { + return 'warning'; + } + + return 'healthy'; +} + +export function getUsageWarningMessage(summary: BillingUsageResourceSummary) { + const warningState = getUsageWarningState(summary); + const resourceName = formatUsageResourceName(summary.resource); + + switch (warningState) { + case 'unavailable': + return `${resourceName} are not included on this plan.`; + case 'critical': + return `You are close to exhausting your ${resourceName.toLowerCase()}.`; + case 'warning': + return `${resourceName} are running low for this period.`; + case 'healthy': + return null; + } +} + +export function getUsageProgressPercent(summary: BillingUsageResourceSummary) { + if (summary.included === null || summary.included <= 0) { + return null; + } + + const consumedRatio = summary.consumed / summary.included; + return Math.max(0, Math.min(consumedRatio * 100, 100)); +} + +export function getUsageWarningBarClass(summary: BillingUsageResourceSummary) { + switch (getUsageWarningState(summary)) { + case 'critical': + return 'bg-red-500'; + case 'warning': + return 'bg-amber-500'; + case 'unavailable': + return 'bg-stone-300'; + case 'healthy': + return 'bg-emerald-500'; + } +} + +export function getSuggestedUpgradePlanCode(planCode: PlanCode | null, billingInterval: BillingInterval | null) { + if (!planCode) { + return 'starter_monthly' as const; + } + + const plan = getPlanByCode(planCode); + if (!plan) { + return null; + } + + const targetInterval = billingInterval === 'annual' ? 'annual' : 'monthly'; + + switch (plan.planFamily) { + case 'starter': + return getPlanVariant('growth', targetInterval)?.code ?? null; + case 'growth': + return getPlanVariant('pro', targetInterval)?.code ?? null; + case 'pro': + return 'enterprise_custom'; + case 'enterprise': + return null; + } +} + +export function formatDateLabel(value: string | null) { + if (!value) { + return 'Not set'; + } + + return new Date(value).toLocaleDateString(); +}