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
This commit is contained in:
+6
-2
@@ -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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [2026-05-22]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Added an authenticated `Account` page with editable profile settings, workspace details, usage summaries, and placeholders for upcoming billing and team management.
|
- 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 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 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
|
### Changed
|
||||||
- Normalized the product UI around shared design primitives for buttons, cards, alerts, tabs, and page shells to keep public, auth, research, results, dashboard, map, and account surfaces visually aligned.
|
- Normalized the product UI around shared design primitives for buttons, cards, alerts, tabs, and page shells to keep public, auth, research, results, dashboard, map, and account surfaces visually aligned.
|
||||||
- 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.
|
- 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.
|
- 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.
|
- Updated dashboard, map, and results copy to describe saved businesses and research outputs instead of lead-focused terminology.
|
||||||
|
|
||||||
## [2026-05-01]
|
## [2026-05-01]
|
||||||
|
|||||||
+118
-60
@@ -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.
|
- [ ] 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
|
## 5) Billing & Data Model Design
|
||||||
- [ ] Design subscription/account state separately from the canonical plan catalog.
|
- [x] Design subscription/account state separately from the canonical plan catalog.
|
||||||
- [ ] Keep billing-provider identifiers out of the canonical catalog until payments integration work begins.
|
- [x] 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.
|
- [x] Design subscription state storage for current plan, billing interval, and status.
|
||||||
- [ ] Design a monthly usage ledger for:
|
- [x] Design a monthly usage ledger for:
|
||||||
- research credits/runs
|
- research credits/runs
|
||||||
- exports
|
- exports
|
||||||
- enrichments
|
- enrichments
|
||||||
- API usage (future)
|
- API usage (future)
|
||||||
- [ ] Design add-on purchases and remaining balances.
|
- [x] Design add-on purchases and remaining balances.
|
||||||
- [ ] Define renewal/reset behavior for monthly quotas.
|
- [x] Define renewal/reset behavior for monthly quotas.
|
||||||
- [ ] Define annual billing behavior and renewal terms.
|
- [x] Define annual billing behavior and renewal terms.
|
||||||
- [ ] Define LTD handling with monthly quotas and non-unlimited usage.
|
- [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
|
## 6) Enforcement Architecture
|
||||||
- [ ] Create a centralized entitlement/usage policy service on the backend.
|
- [x] Create a centralized entitlement/usage policy service on the backend.
|
||||||
- [ ] Ensure all high-cost actions check entitlements before execution.
|
- [x] Ensure all high-cost actions check entitlements before execution.
|
||||||
- [ ] Start with enforcement on:
|
- [x] Start with enforcement on:
|
||||||
- research routes
|
- [x] research routes
|
||||||
- export 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.
|
||||||
- enrichment routes (future)
|
- [x] Reuse a shared deep-research estimate path so entitlement cost estimation and batch creation use the same preview-derived basis.
|
||||||
- [ ] Add queue prioritization by plan tier.
|
- [x] Add clear API responses for quota exhaustion and upgrade flows.
|
||||||
- [ ] Add throttling/fair-usage controls.
|
- [ ] Future note: the current enforcement slice should be treated as the core entitlement/runtime gate, not the full operational control layer.
|
||||||
- [ ] Add clear API responses for quota exhaustion and upgrade flows.
|
- [ ] 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
|
## 7) Workspace, User, and Collaboration Readiness
|
||||||
- [ ] Review whether current data ownership is sufficiently workspace-scoped for plan promises.
|
- [x] 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.
|
- [x] 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.
|
- [x] Document which catalog limits can be enforced immediately versus only represented commercially at launch.
|
||||||
- [ ] Define how to enforce:
|
- [x] Define how to enforce:
|
||||||
- users included
|
- [x] users included
|
||||||
- workspace limits
|
- [x] workspace limits
|
||||||
- shared assets/lists
|
- [x] shared assets/lists
|
||||||
- collaboration permissions
|
- [x] collaboration permissions
|
||||||
- [ ] Decide whether some collaboration features need phased rollout rather than immediate sale.
|
- [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
|
## 8) Pricing Page & Account UX
|
||||||
- [ ] Define export add-ons:
|
- [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
|
- +10k exports = $29
|
||||||
- +50k exports = $99
|
- +50k exports = $99
|
||||||
- [ ] Define enrichment packs:
|
- [x] Define enrichment packs:
|
||||||
- 1,000 enrichments = $49
|
- 1,000 enrichments = $49
|
||||||
- [ ] Reserve future add-ons for:
|
- [x] Reserve future add-ons for:
|
||||||
- AI prospecting assistant
|
- AI prospecting assistant
|
||||||
- white-label / agency tools
|
- white-label / agency tools
|
||||||
- higher API capacity
|
- 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.
|
- [ ] Decide whether to launch founder LTD at all.
|
||||||
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
|
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
|
||||||
- [ ] Define founder SKUs:
|
- [ ] Define founder SKUs:
|
||||||
@@ -217,29 +278,6 @@
|
|||||||
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
|
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
|
||||||
- [ ] Define which future features are excluded from LTD plans.
|
- [ ] 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
|
## 12) Analytics, Ops, and Revenue Instrumentation
|
||||||
- [ ] Track pricing-page conversion by plan.
|
- [ ] Track pricing-page conversion by plan.
|
||||||
- [ ] Track quota exhaustion events.
|
- [ ] Track quota exhaustion events.
|
||||||
@@ -251,13 +289,33 @@
|
|||||||
- [ ] Track plan mix, churn, expansion revenue, and annual conversion.
|
- [ ] Track plan mix, churn, expansion revenue, and annual conversion.
|
||||||
- [ ] Add internal dashboards for billing and usage health.
|
- [ ] 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 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model.
|
||||||
- [ ] Phase 2: implement usage ledger and backend enforcement.
|
- [ ] Phase 2: implement usage ledger and backend enforcement.
|
||||||
- [ ] Phase 3: update pricing page and account/billing UI.
|
- [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises.
|
||||||
- [ ] Phase 4: integrate payments and subscription lifecycle handling.
|
- [ ] Phase 4: update pricing page and account/billing UI based on the workspace/collaboration readiness decisions.
|
||||||
- [ ] Phase 5: launch add-ons and annual billing.
|
- [ ] Phase 5: finalize add-on strategy before wiring payment products.
|
||||||
- [ ] Phase 6: launch collaboration, API, enrichment, and enterprise features as architecture matures.
|
- [ ] 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
|
## Open Questions
|
||||||
- [ ] Will research capacity be marketed as runs, credits, or both?
|
- [ ] Will research capacity be marketed as runs, credits, or both?
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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';
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Pool, PoolClient } from 'pg';
|
import type { Pool, PoolClient } from 'pg';
|
||||||
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
|
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
|
||||||
|
import { getWorkspaceBillingState } from '../billing/service.js';
|
||||||
|
|
||||||
type DbClient = Pool | PoolClient;
|
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 summary = await getAccountSummaryForUser(db, user.id);
|
||||||
|
const billing = await getWorkspaceBillingState(db, workspace.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile: user,
|
profile: user,
|
||||||
workspace,
|
workspace,
|
||||||
summary,
|
summary,
|
||||||
billing: {
|
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.',
|
|
||||||
},
|
|
||||||
team: {
|
team: {
|
||||||
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
|
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
|
||||||
message: 'Workspace member management is coming soon.',
|
message: 'Workspace member management is coming soon.',
|
||||||
|
|||||||
@@ -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<WorkspaceEnforcementContext> {
|
||||||
|
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<EnforcementCheckResult> {
|
||||||
|
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<void> {
|
||||||
|
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.
|
||||||
@@ -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<BillingAccountRecord | null> {
|
||||||
|
const result = await db.query<BillingAccountRow>(
|
||||||
|
`
|
||||||
|
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<BillingAccountRecord> {
|
||||||
|
const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds();
|
||||||
|
const result = await db.query<BillingAccountRow>(
|
||||||
|
`
|
||||||
|
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<BillingAccountRow>(
|
||||||
|
`
|
||||||
|
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<UsagePeriodRecord | null> {
|
||||||
|
const result = await db.query<UsagePeriodRow>(
|
||||||
|
`
|
||||||
|
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<UsagePeriodRecord> {
|
||||||
|
const result = await db.query<UsagePeriodRow>(
|
||||||
|
`
|
||||||
|
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<UsageCounterRecord[]> {
|
||||||
|
const result = await db.query<UsageCounterRow>(
|
||||||
|
`
|
||||||
|
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<UsageCounterRow>(
|
||||||
|
`
|
||||||
|
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<UsageCounterRow>(
|
||||||
|
`
|
||||||
|
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<BillingAddonBalanceSummary[]> {
|
||||||
|
const result = await db.query<AddonBalanceRow>(
|
||||||
|
`
|
||||||
|
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<AddonPurchaseRecord> {
|
||||||
|
const result = await db.query<AddonPurchaseRow>(
|
||||||
|
`
|
||||||
|
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<BillingAddonBalanceSummary> {
|
||||||
|
const result = await db.query<AddonBalanceRow>(
|
||||||
|
`
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<AccountBillingState> {
|
||||||
|
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<UsagePeriodRecord | null> {
|
||||||
|
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<typeof getUsageAllowanceForPlan>,
|
||||||
|
consumedByResource: Map<UsageResource, number>,
|
||||||
|
): 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<BillingAddonBalanceSummary[]> {
|
||||||
|
return listAddonBalancesForWorkspace(db, workspaceId);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Pool } from 'pg';
|
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 type { CreateDeepResearchBatchRequest, DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchPreviewRequest, JobStatus } from '../../../shared/types.js';
|
||||||
import { listPostalAreasByPropagation, findPostalAreaContainingPoint } from '../postal/repository.js';
|
import { listPostalAreasByPropagation, findPostalAreaContainingPoint } from '../postal/repository.js';
|
||||||
import { previewDeepResearchForPoint } from '../postal/service.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);
|
return getDeepResearchBatchDetailForUser(db, userId, batchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDeepResearchBatchEstimate(
|
||||||
|
db: Pool,
|
||||||
|
input: CreateDeepResearchBatchRequest,
|
||||||
|
): Promise<{ preview: Awaited<ReturnType<typeof previewDeepResearchForPoint>>; 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(
|
export async function createDeepResearchBatchForUser(
|
||||||
db: Pool,
|
db: Pool,
|
||||||
userId: string,
|
userId: string,
|
||||||
input: CreateDeepResearchBatchRequest,
|
input: CreateDeepResearchBatchRequest,
|
||||||
|
options?: { preview?: Awaited<ReturnType<typeof previewDeepResearchForPoint>> },
|
||||||
): Promise<DeepResearchBatchDetail> {
|
): Promise<DeepResearchBatchDetail> {
|
||||||
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);
|
const baseArea = await findPostalAreaContainingPoint(db, input.lat, input.lng);
|
||||||
|
|
||||||
if (!baseArea) {
|
if (!baseArea) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
import { ZodError, z } from 'zod';
|
import { ZodError, z } from 'zod';
|
||||||
|
import { estimateDeepResearchBatchCost } from '../../../shared/billing/entitlements.js';
|
||||||
import { requireAuth } from '../auth/middleware.js';
|
import { requireAuth } from '../auth/middleware.js';
|
||||||
|
import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js';
|
||||||
import { getDbPool } from '../db/pool.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';
|
import { previewDeepResearchForPoint } from '../postal/service.js';
|
||||||
|
|
||||||
const previewSchema = z.object({
|
const previewSchema = z.object({
|
||||||
@@ -40,7 +42,27 @@ export const deepResearchRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post('/deep-research/batches', { preHandler: requireAuth }, async (request, reply) => {
|
app.post('/deep-research/batches', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const payload = previewSchema.parse(request.body);
|
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 });
|
return reply.code(201).send({ batch });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { requireAuth } from '../auth/middleware.js';
|
import { requireAuth } from '../auth/middleware.js';
|
||||||
|
import { estimateBasicSearchCost } from '../../../shared/billing/entitlements.js';
|
||||||
import { getDbPool } from '../db/pool.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 { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js';
|
||||||
import { runSearchForUser } from '../search/run-search.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) => {
|
app.post('/search-jobs', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const payload = runSearchSchema.parse(request.body);
|
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);
|
return reply.code(201).send(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export type EntitlementDecisionStatus =
|
|||||||
| 'blocked_addon_available'
|
| 'blocked_addon_available'
|
||||||
| 'contact_sales_required';
|
| 'contact_sales_required';
|
||||||
|
|
||||||
export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready';
|
export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready' | 'billing_not_configured';
|
||||||
|
|
||||||
export interface UsageSubject {
|
export interface UsageSubject {
|
||||||
type: UsageSubjectType;
|
type: UsageSubjectType;
|
||||||
@@ -75,7 +75,7 @@ export interface EntitlementDecision {
|
|||||||
resource: UsageResource;
|
resource: UsageResource;
|
||||||
requiredAmount: number;
|
requiredAmount: number;
|
||||||
remainingAmount: number | null;
|
remainingAmount: number | null;
|
||||||
currentPlanCode: ActivePlanCode;
|
currentPlanCode: ActivePlanCode | null;
|
||||||
suggestedUpgradePlanCode: ActivePlanCode | null;
|
suggestedUpgradePlanCode: ActivePlanCode | null;
|
||||||
addonEligible: boolean;
|
addonEligible: boolean;
|
||||||
contactSalesRequired: boolean;
|
contactSalesRequired: boolean;
|
||||||
|
|||||||
@@ -524,6 +524,18 @@ export function getPublicPricingPlans() {
|
|||||||
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.listingCategory === 'pricing_page_primary');
|
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) {
|
export function getPlanVariant(tier: PlanTier, billingInterval: BillingInterval) {
|
||||||
return ACTIVE_PLAN_CATALOG.find((plan) => plan.planFamily === tier && plan.billingInterval === billingInterval) ?? null;
|
return ACTIVE_PLAN_CATALOG.find((plan) => plan.planFamily === tier && plan.billingInterval === billingInterval) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
+22
-1
@@ -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';
|
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 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 {
|
export interface AccountBillingState {
|
||||||
status: AccountBillingStatus;
|
status: AccountBillingStatus;
|
||||||
planCode: PlanCode | null;
|
planCode: PlanCode | null;
|
||||||
billingInterval: BillingInterval | null;
|
billingInterval: BillingInterval | null;
|
||||||
|
currentPeriodStartsAt: string | null;
|
||||||
|
currentPeriodEndsAt: string | null;
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
usage: BillingUsageResourceSummary[];
|
||||||
|
addonBalances: BillingAddonBalanceSummary[];
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-71
@@ -19,10 +19,12 @@ import { Layout, type AppTab } from './components/Layout';
|
|||||||
import { AccountPage } from './components/AccountPage';
|
import { AccountPage } from './components/AccountPage';
|
||||||
import { Dashboard } from './components/Dashboard';
|
import { Dashboard } from './components/Dashboard';
|
||||||
import { MapView } from './components/MapView';
|
import { MapView } from './components/MapView';
|
||||||
|
import { PricingCards } from './components/PricingCards';
|
||||||
|
import { PricingComparisonTable } from './components/PricingComparisonTable';
|
||||||
import { ResearchWorkspace } from './components/ResearchWorkspace';
|
import { ResearchWorkspace } from './components/ResearchWorkspace';
|
||||||
import { ResultsWorkspace } from './components/ResultsWorkspace';
|
import { ResultsWorkspace } from './components/ResultsWorkspace';
|
||||||
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
|
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
|
||||||
import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlans } from '../shared/billing/plans';
|
import type { BillingInterval } from '../shared/billing/plans';
|
||||||
import type { SessionUser } from '../shared/types';
|
import type { SessionUser } from '../shared/types';
|
||||||
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
|
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
|
||||||
import { hasApiConfig } from './lib/api';
|
import { hasApiConfig } from './lib/api';
|
||||||
@@ -302,30 +304,11 @@ function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'lan
|
|||||||
setPublicPage(page);
|
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: {
|
function LandingPage(props: {
|
||||||
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
|
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
|
||||||
}) {
|
}) {
|
||||||
const { onGoToAuth } = props;
|
const { onGoToAuth } = props;
|
||||||
|
const [pricingInterval, setPricingInterval] = useState<Extract<BillingInterval, 'monthly' | 'annual'>>('monthly');
|
||||||
|
|
||||||
const featureCards = [
|
const featureCards = [
|
||||||
{
|
{
|
||||||
@@ -368,8 +351,6 @@ function LandingPage(props: {
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const pricingPlans = getPublicPricingPlans();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.08),_transparent_28%),linear-gradient(180deg,#fafaf9_0%,#f5f5f4_100%)] text-stone-900">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.08),_transparent_28%),linear-gradient(180deg,#fafaf9_0%,#f5f5f4_100%)] text-stone-900">
|
||||||
<div className="mx-auto max-w-7xl px-4 pb-16 pt-4 sm:px-6 lg:px-8 lg:pb-24">
|
<div className="mx-auto max-w-7xl px-4 pb-16 pt-4 sm:px-6 lg:px-8 lg:pb-24">
|
||||||
@@ -516,62 +497,29 @@ function LandingPage(props: {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-5 xl:grid-cols-3">
|
<div className="mt-8 flex justify-center">
|
||||||
{pricingPlans.map((plan) => {
|
<div className="inline-flex rounded-full border border-stone-200 bg-white p-1 shadow-sm">
|
||||||
const display = getPlanDisplayMeta(plan.code);
|
{(['monthly', 'annual'] as const).map((interval) => (
|
||||||
const isFeatured = display.badgeLabel === 'Best Value';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={plan.code}
|
|
||||||
className={`rounded-[2rem] border p-7 shadow-sm ${
|
|
||||||
isFeatured ? 'border-emerald-300 bg-emerald-50/60 shadow-emerald-100' : 'border-stone-200 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xl font-bold tracking-tight text-stone-900">{plan.name}</p>
|
|
||||||
<p className="mt-2 text-sm text-stone-600">{display.audience}</p>
|
|
||||||
</div>
|
|
||||||
{display.badgeLabel ? (
|
|
||||||
<span className="rounded-full bg-emerald-600 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
|
|
||||||
{display.badgeLabel}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 flex items-end gap-1">
|
|
||||||
<span className="text-4xl font-bold tracking-tight text-stone-950">{formatPlanPrice(plan.priceCents, plan.currencyCode)}</span>
|
|
||||||
<span className="pb-1 text-sm text-stone-500">{formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-3 text-sm leading-7 text-stone-600">{display.summary}</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
key={interval}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onGoToAuth(display.ctaMode)}
|
onClick={() => setPricingInterval(interval)}
|
||||||
className={`mt-8 inline-flex w-full items-center justify-center rounded-2xl px-4 py-3 text-sm font-semibold transition ${
|
className={`rounded-full px-5 py-2 text-sm font-semibold transition ${
|
||||||
isFeatured
|
pricingInterval === interval ? 'bg-stone-900 text-white' : 'text-stone-600 hover:bg-stone-100 hover:text-stone-900'
|
||||||
? 'bg-emerald-600 text-white hover:bg-emerald-700'
|
|
||||||
: 'border border-stone-200 bg-white text-stone-800 hover:bg-stone-50'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{display.ctaLabel}
|
{interval === 'monthly' ? 'Monthly' : 'Annual'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-8 space-y-3">
|
|
||||||
{getPlanCardBullets(plan.code).map((item) => (
|
|
||||||
<div key={item} className="flex items-start gap-3 text-sm text-stone-700">
|
|
||||||
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
<span>{item}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
<div className="mt-8">
|
||||||
|
<PricingCards billingInterval={pricingInterval} onGoToAuth={onGoToAuth} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<PricingComparisonTable billingInterval={pricingInterval} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from 'react';
|
||||||
|
import { getEligibleAddonsForPlan } from '../../shared/billing/addons';
|
||||||
import { getPlanByCode } from '../../shared/billing/plans';
|
import { getPlanByCode } from '../../shared/billing/plans';
|
||||||
import type { AccountPageData, AppUser } from '../../shared/types';
|
import type { AccountPageData, AppUser } from '../../shared/types';
|
||||||
import { getAccountPageData, updateAccountProfile } from '../lib/account';
|
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';
|
import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui';
|
||||||
|
|
||||||
interface AccountPageProps {
|
interface AccountPageProps {
|
||||||
@@ -101,6 +115,9 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activePlan = account.billing.planCode ? getPlanByCode(account.billing.planCode) : null;
|
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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -202,7 +219,138 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
|||||||
<h3 className="text-lg font-semibold text-stone-950">{activePlan ? activePlan.name : 'Subscription foundation in progress'}</h3>
|
<h3 className="text-lg font-semibold text-stone-950">{activePlan ? activePlan.name : 'Subscription foundation in progress'}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Badge variant={getBillingStatusBadgeVariant(account.billing.status)}>{formatBillingStatusLabel(account.billing.status)}</Badge>
|
||||||
|
<Badge>{formatBillingIntervalLabel(account.billing.billingInterval)}</Badge>
|
||||||
|
{account.billing.cancelAtPeriodEnd ? <Badge variant="warning">Cancels at period end</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 rounded-2xl bg-stone-50 p-4 text-sm text-stone-600">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span>Current period starts</span>
|
||||||
|
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.currentPeriodStartsAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span>Current period ends</span>
|
||||||
|
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.currentPeriodEndsAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className="mt-4 text-sm text-stone-600">{account.billing.message}</p>
|
<p className="mt-4 text-sm text-stone-600">{account.billing.message}</p>
|
||||||
|
{suggestedUpgradePlan ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||||
|
<p className="text-sm font-semibold text-emerald-900">Suggested next plan</p>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-semibold text-emerald-950">{suggestedUpgradePlan.name}</p>
|
||||||
|
<p className="text-sm text-emerald-800">Step up when you need more usage headroom or premium workflows.</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" className="shrink-0">
|
||||||
|
Upgrade
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Usage This Period</p>
|
||||||
|
<h3 className="text-lg font-semibold text-stone-950">Quota visibility</h3>
|
||||||
|
</div>
|
||||||
|
{account.billing.usage.some((usage) => {
|
||||||
|
const state = getUsageWarningState(usage);
|
||||||
|
return state === 'warning' || state === 'critical';
|
||||||
|
}) ? <Badge variant="warning">Needs attention</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{account.billing.usage.map((usage) => {
|
||||||
|
const warningMessage = getUsageWarningMessage(usage);
|
||||||
|
const progressPercent = getUsageProgressPercent(usage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={usage.resource} className="rounded-2xl border border-stone-200 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-stone-900">{formatUsageResourceName(usage.resource)}</p>
|
||||||
|
<p className="mt-1 text-xs text-stone-500">
|
||||||
|
Included: {formatQuantity(usage.included)} · Consumed: {formatQuantity(usage.consumed)} · Remaining: {formatQuantity(usage.remaining)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={usage.availability === 'not_available' ? 'neutral' : getUsageWarningState(usage) === 'critical' ? 'danger' : getUsageWarningState(usage) === 'warning' ? 'warning' : 'success'}>
|
||||||
|
{usage.availability === 'not_available' ? 'Not included' : usage.availability === 'custom' ? 'Custom' : usage.availability === 'unlimited' ? 'Unlimited' : 'Tracked'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{progressPercent !== null ? (
|
||||||
|
<div className="mt-3 h-2 rounded-full bg-stone-100">
|
||||||
|
<div className={`h-2 rounded-full ${getUsageWarningBarClass(usage)}`} style={{ width: `${progressPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{warningMessage ? (
|
||||||
|
<div className="mt-3 flex items-start gap-2 rounded-xl bg-stone-50 px-3 py-2 text-xs text-stone-600">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>{warningMessage}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Add-On Balances</p>
|
||||||
|
<h3 className="text-lg font-semibold text-stone-950">Extra capacity</h3>
|
||||||
|
</div>
|
||||||
|
{account.billing.addonBalances.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-stone-600">No add-on balances are active yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{account.billing.addonBalances.map((balance) => (
|
||||||
|
<div key={`${balance.addonCode}-${balance.resource}`} className="rounded-2xl border border-stone-200 p-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-900">{balance.addonCode}</p>
|
||||||
|
<p className="text-xs text-stone-500">{formatUsageResourceName(balance.resource)}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="info">{formatQuantity(balance.remainingQuantity)} remaining</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-stone-500">Expires: {formatDateLabel(balance.expiresAt)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Available Add-Ons</p>
|
||||||
|
<h3 className="text-lg font-semibold text-stone-950">Optional capacity and feature packs</h3>
|
||||||
|
</div>
|
||||||
|
{eligibleAddons.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-stone-600">No add-ons are configured for this plan yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{eligibleAddons.map((addon) => (
|
||||||
|
<div key={addon.code} className="rounded-2xl border border-stone-200 p-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-900">{addon.name}</p>
|
||||||
|
<p className="mt-1 text-xs text-stone-500">{addon.description}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={addon.availability === 'active' ? 'success' : 'warning'}>
|
||||||
|
{addon.availability === 'active' ? 'Available' : 'Coming soon'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="info">{addon.purchaseMode === 'one_time' ? 'One-time' : 'Recurring'}</Badge>
|
||||||
|
<Badge>{addon.quantity === null ? 'Feature add-on' : `${formatQuantity(addon.quantity)} units`}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
|
|||||||
@@ -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<BillingInterval, 'monthly' | 'annual'>;
|
||||||
|
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps) {
|
||||||
|
const pricingPlans = getPublicPricingPlansForInterval(billingInterval);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5 xl:grid-cols-4">
|
||||||
|
{pricingPlans.map((plan) => {
|
||||||
|
const display = getPlanDisplayMeta(plan.code);
|
||||||
|
const isFeatured = display.badgeLabel === 'Best Value';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={plan.code}
|
||||||
|
className={`rounded-[2rem] border p-7 shadow-sm ${
|
||||||
|
isFeatured ? 'border-emerald-300 bg-emerald-50/60 shadow-emerald-100' : 'border-stone-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold tracking-tight text-stone-900">{plan.name}</p>
|
||||||
|
<p className="mt-2 text-sm text-stone-600">{display.audience}</p>
|
||||||
|
</div>
|
||||||
|
{display.badgeLabel ? (
|
||||||
|
<span className="rounded-full bg-emerald-600 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
|
||||||
|
{display.badgeLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-end gap-1">
|
||||||
|
<span className="text-4xl font-bold tracking-tight text-stone-950">{formatPlanPrice(plan.priceCents, plan.currencyCode)}</span>
|
||||||
|
<span className="pb-1 text-sm text-stone-500">{formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-sm leading-7 text-stone-600">{display.summary}</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onGoToAuth(display.ctaMode)}
|
||||||
|
className={`mt-8 w-full rounded-2xl ${isFeatured ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
|
||||||
|
variant={isFeatured ? 'primary' : 'secondary'}
|
||||||
|
>
|
||||||
|
{display.ctaLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-3">
|
||||||
|
{getPlanCardBullets(plan.code).map((item) => (
|
||||||
|
<div key={item} className="flex items-start gap-3 text-sm text-stone-700">
|
||||||
|
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<BillingInterval, 'monthly' | 'annual'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingComparisonTable({ billingInterval }: PricingComparisonTableProps) {
|
||||||
|
const plans = getPublicPricingPlansForInterval(billingInterval);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="border-b border-stone-200 px-6 py-5">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Plan Comparison</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-semibold tracking-tight text-stone-950">Compare capabilities across plans</h3>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-stone-600">Included-but-not-ready capabilities are labeled as coming soon so the table stays honest with the current rollout phase.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full border-collapse text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-stone-200 bg-stone-50">
|
||||||
|
<th className="px-6 py-4 font-semibold text-stone-500">Capability</th>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<th key={plan.code} className="px-6 py-4 font-semibold text-stone-900">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>{plan.name}</span>
|
||||||
|
{plan.code === 'growth_monthly' || plan.code === 'growth_annual' ? <Badge variant="primary">Best Value</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<ComparisonValueRow
|
||||||
|
label="Research runs / month"
|
||||||
|
values={plans.map((plan) => (plan.limits.researchRunsPerMonth === null ? 'Custom' : formatQuantity(plan.limits.researchRunsPerMonth)))}
|
||||||
|
/>
|
||||||
|
<ComparisonValueRow
|
||||||
|
label="Exports / month"
|
||||||
|
values={plans.map((plan) => (plan.limits.exportsPerMonth === null ? 'Custom' : formatQuantity(plan.limits.exportsPerMonth)))}
|
||||||
|
/>
|
||||||
|
<ComparisonValueRow
|
||||||
|
label="Users included"
|
||||||
|
values={plans.map((plan) => (plan.limits.usersIncluded === null ? 'Custom' : formatQuantity(plan.limits.usersIncluded)))}
|
||||||
|
/>
|
||||||
|
<ComparisonValueRow
|
||||||
|
label="Workspaces included"
|
||||||
|
values={plans.map((plan) => (plan.limits.workspacesIncluded === null ? 'Unlimited' : formatQuantity(plan.limits.workspacesIncluded)))}
|
||||||
|
/>
|
||||||
|
{FEATURE_ROWS.map(({ feature, label }) => (
|
||||||
|
<ComparisonFeatureRow key={feature} label={label} planCodes={plans.map((plan) => plan.code)} feature={feature} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonValueRow({ label, values }: { label: string; values: string[] }) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-stone-100 align-top">
|
||||||
|
<td className="px-6 py-4 font-medium text-stone-700">{label}</td>
|
||||||
|
{values.map((value, index) => (
|
||||||
|
<td key={`${label}-${index}`} className="px-6 py-4 text-stone-600">{value}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonFeatureRow({ label, planCodes, feature }: { label: string; planCodes: ActivePlanCode[]; feature: keyof PlanFeatures }) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-stone-100 align-top">
|
||||||
|
<td className="px-6 py-4 font-medium text-stone-700">{label}</td>
|
||||||
|
{planCodes.map((planCode) => (
|
||||||
|
<td key={`${label}-${planCode}`} className="px-6 py-4">
|
||||||
|
<FeatureGateCell planCode={planCode} feature={feature} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <span className="inline-flex items-center gap-2 text-emerald-700"><Check className="h-4 w-4" />Included</span>;
|
||||||
|
case 'coming_soon':
|
||||||
|
return <Badge variant="warning">Coming soon</Badge>;
|
||||||
|
case 'contact_sales':
|
||||||
|
return plan.code === 'enterprise_custom' && plan.features[feature] ? <Badge variant="warning">Coming soon</Badge> : <Badge variant="info">Enterprise</Badge>;
|
||||||
|
case 'upgrade_required':
|
||||||
|
return <span className="text-stone-500">Higher tier</span>;
|
||||||
|
case 'hidden':
|
||||||
|
return <span className="inline-flex items-center gap-2 text-stone-300"><Minus className="h-4 w-4" />Not included</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user