Public Access
1
0

feat: launch Stripe billing flows with lifecycle hardening and analytics

add Stripe checkout, portal, webhook ingestion, and idempotent event persistence

add billing lifecycle state (grace/sync/timeline/admin visibility) and stronger entitlement handling

add analytics event tracking and admin summary APIs plus account/pricing UI integration
This commit is contained in:
pguerrerox
2026-05-22 22:55:04 +00:00
parent 94b8c357b4
commit 5508e15da1
35 changed files with 2851 additions and 50 deletions
+2
View File
@@ -1,6 +1,7 @@
import type { Pool, PoolClient } from 'pg';
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
import { getWorkspaceBillingState } from '../billing/service.js';
import { isBillingAdminEmail } from '../config/env.js';
type DbClient = Pool | PoolClient;
@@ -164,6 +165,7 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
message: 'Workspace member management is coming soon.',
},
isBillingAdmin: isBillingAdminEmail(user.email),
};
}
+105
View File
@@ -0,0 +1,105 @@
import type { Pool, PoolClient } from 'pg';
import type { AnalyticsEventInput } from '../../../shared/analytics/events.js';
import type { AnalyticsMetricBucket } from '../../../shared/types.js';
type DbClient = Pool | PoolClient;
type AnalyticsBucketRow = {
key: string;
count: string;
};
export async function insertAnalyticsEvent(db: DbClient, input: AnalyticsEventInput) {
await db.query(
`
insert into public.analytics_events (
event_name,
event_source,
user_id,
workspace_id,
plan_code,
addon_code,
resource,
amount,
currency,
metadata_json,
occurred_at
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, coalesce($11::timestamptz, now()))
`,
[
input.eventName,
input.eventSource,
input.userId ?? null,
input.workspaceId ?? null,
input.planCode ?? null,
input.addonCode ?? null,
input.resource ?? null,
input.amount ?? null,
input.currency ?? null,
input.metadata ?? {},
input.occurredAt ?? null,
],
);
}
export function listAnalyticsCountsByEvent(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, 'event_name', `event_name`);
}
export function listPricingPlanSelectionCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(plan_code, metadata_json->>'planCode', 'unknown')`, `event_name = 'pricing_plan_selected'`);
}
export function listQuotaExhaustionCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(resource, metadata_json->>'resource', 'unknown')`, `event_name = 'quota_exhausted_blocked'`);
}
export function listUpgradeTriggerCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(
db,
sinceIso,
`coalesce(metadata_json->>'denialReason', event_name)`,
`event_name in ('quota_exhausted_blocked', 'feature_gate_encountered')`,
);
}
export function listAddonAttachCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(addon_code, metadata_json->>'addonCode', 'unknown')`, `event_name = 'addon_purchase_completed'`);
}
export function listPlanMixCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(plan_code, metadata_json->>'planCode', 'unknown')`, `event_name in ('checkout_completed', 'plan_changed')`);
}
export function listChurnSignalCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `event_name`, `event_name in ('subscription_canceled', 'payment_failed')`);
}
export function listExpansionSignalCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `event_name`, `event_name in ('checkout_completed', 'addon_purchase_completed', 'plan_changed')`);
}
async function listBuckets(
db: DbClient,
sinceIso: string,
keyExpression: string,
filterSql: string,
): Promise<AnalyticsMetricBucket[]> {
const result = await db.query<AnalyticsBucketRow>(
`
select ${keyExpression} as key, count(*)::text as count
from public.analytics_events
where occurred_at >= $1
and ${filterSql}
group by 1
order by count(*) desc, 1 asc
`,
[sinceIso],
);
return result.rows.map((row) => ({
key: row.key,
count: Number(row.count),
}));
}
+53
View File
@@ -0,0 +1,53 @@
import type { Pool, PoolClient } from 'pg';
import type { AnalyticsEventInput } from '../../../shared/analytics/events.js';
import type { AdminAnalyticsSummary } from '../../../shared/types.js';
import {
insertAnalyticsEvent,
listAddonAttachCounts,
listChurnSignalCounts,
listExpansionSignalCounts,
listPlanMixCounts,
listPricingPlanSelectionCounts,
listQuotaExhaustionCounts,
listUpgradeTriggerCounts,
} from './repository.js';
type DbClient = Pool | PoolClient;
export async function recordAnalyticsEvent(db: DbClient, input: AnalyticsEventInput) {
await insertAnalyticsEvent(db, input);
}
export async function getAdminAnalyticsSummary(db: DbClient, days = 30): Promise<AdminAnalyticsSummary> {
const now = Date.now();
const clampedDays = Math.min(Math.max(days, 7), 90);
const sinceIso = new Date(now - clampedDays * 24 * 60 * 60 * 1000).toISOString();
const [
pricingConversionByPlan,
quotaExhaustionEvents,
upgradeTriggers,
addonAttach,
planMix,
churnSignals,
expansionSignals,
] = await Promise.all([
listPricingPlanSelectionCounts(db, sinceIso),
listQuotaExhaustionCounts(db, sinceIso),
listUpgradeTriggerCounts(db, sinceIso),
listAddonAttachCounts(db, sinceIso),
listPlanMixCounts(db, sinceIso),
listChurnSignalCounts(db, sinceIso),
listExpansionSignalCounts(db, sinceIso),
]);
return {
pricingConversionByPlan,
quotaExhaustionEvents,
upgradeTriggers,
addonAttach,
planMix,
churnSignals,
expansionSignals,
};
}
+4
View File
@@ -5,8 +5,10 @@ import { getEnv } from './config/env.js';
import { deepResearchRoutes } from './routes/deep-research.js';
import { authRoutes } from './routes/auth.js';
import { accountRoutes } from './routes/account.js';
import { billingRoutes } from './routes/billing.js';
import { healthRoutes } from './routes/health.js';
import { searchJobRoutes } from './routes/search-jobs.js';
import { analyticsRoutes } from './routes/analytics.js';
function parseAllowedOrigins(rawOrigins: string) {
return rawOrigins
@@ -50,8 +52,10 @@ export async function buildApp() {
await app.register(healthRoutes, { prefix: '/api' });
await app.register(authRoutes, { prefix: '/api' });
await app.register(accountRoutes, { prefix: '/api' });
await app.register(billingRoutes, { prefix: '/api' });
await app.register(searchJobRoutes, { prefix: '/api' });
await app.register(deepResearchRoutes, { prefix: '/api' });
await app.register(analyticsRoutes, { prefix: '/api' });
return app;
}
+46
View File
@@ -1,4 +1,5 @@
import type { Pool, PoolClient } from 'pg';
import { resolveBillingAccessState } from '../../../shared/billing/lifecycle.js';
import type { AccountBillingState } from '../../../shared/types.js';
import type { EntitlementDecision, UsageAction, UsageAmount, UsageCostEstimate, UsageResource } from '../../../shared/billing/entitlements.js';
import { evaluateActionEntitlement, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js';
@@ -73,6 +74,31 @@ export async function checkActionEntitlementForWorkspace(
};
}
const billingAccess = resolveBillingAccessState({
status: billing.status,
currentPeriodEndsAt: billing.currentPeriodEndsAt,
cancelAtPeriodEnd: billing.cancelAtPeriodEnd,
gracePeriodEndsAt: billing.gracePeriodEndsAt,
});
if (input.costEstimate.isChargeable && billingAccess.accessMode === 'blocked') {
return {
allowed: false,
decision: {
status: 'blocked_upgrade_required',
denialReason: mapBillingStatusToDenialReason(billing.status),
action: input.action,
resource: getPrimaryUsageAmount(input.costEstimate)?.resource ?? 'research_credits',
requiredAmount: getPrimaryUsageAmount(input.costEstimate)?.amount ?? 0,
remainingAmount: 0,
currentPlanCode: billing.planCode,
suggestedUpgradePlanCode: billing.planCode,
addonEligible: false,
contactSalesRequired: false,
},
};
}
if (!input.costEstimate.isChargeable) {
return {
allowed: true,
@@ -165,6 +191,12 @@ function formatEntitlementErrorMessage(decision: EntitlementDecision) {
switch (decision.denialReason) {
case 'billing_not_configured':
return 'A billing plan is required before this action can run.';
case 'billing_past_due':
return 'Payment is overdue and billing access is currently blocked.';
case 'billing_canceled':
return 'This subscription is canceled. Reactivate billing to continue.';
case 'billing_inactive':
return 'Billing is inactive for this workspace.';
case 'feature_not_available':
return 'Your current plan does not include this feature.';
case 'not_launch_ready':
@@ -178,5 +210,19 @@ function formatEntitlementErrorMessage(decision: EntitlementDecision) {
}
}
function mapBillingStatusToDenialReason(status: AccountBillingState['status']) {
switch (status) {
case 'past_due':
return 'billing_past_due' as const;
case 'canceled':
return 'billing_canceled' as const;
case 'inactive':
case 'not_configured':
return 'billing_inactive' as const;
case 'active':
return 'billing_inactive' as const;
}
}
// Export policy exists in shared billing modules, but route-level export enforcement
// stays deferred until export generation moves to a backend endpoint.
+283 -1
View File
@@ -1,5 +1,6 @@
import type { Pool, PoolClient } from 'pg';
import type { AddonCode, BillingInterval, PlanCode } from '../../../shared/billing/plans.js';
import type { BillingSyncStatus, BillingTimelineEventType } from '../../../shared/billing/lifecycle.js';
import type { AccountBillingStatus, BillingAddonBalanceSummary } from '../../../shared/types.js';
import type { UsageResource } from '../../../shared/billing/entitlements.js';
@@ -16,12 +17,32 @@ export interface BillingAccountRecord {
cancelAtPeriodEnd: boolean;
canceledAt: string | null;
trialEndsAt: string | null;
gracePeriodEndsAt: string | null;
pendingPlanCode: PlanCode | null;
pendingPlanEffectiveAt: string | null;
billingSyncStatus: BillingSyncStatus;
lastStripeSyncAt: string | null;
externalCustomerRef: string | null;
externalSubscriptionRef: string | null;
createdAt: string;
updatedAt: string;
}
export interface BillingTimelineEventRecord {
id: string;
workspaceId: string;
billingAccountId: string | null;
eventType: BillingTimelineEventType;
source: 'stripe' | 'app' | 'system';
payloadJson: Record<string, unknown>;
externalEventId: string | null;
externalCustomerRef: string | null;
externalSubscriptionRef: string | null;
occurredAt: string;
createdAt: string;
updatedAt: string;
}
export interface UsagePeriodRecord {
id: string;
workspaceId: string;
@@ -67,12 +88,51 @@ type BillingAccountRow = {
cancel_at_period_end: boolean;
canceled_at: string | null;
trial_ends_at: string | null;
grace_period_ends_at: string | null;
pending_plan_code: string | null;
pending_plan_effective_at: string | null;
billing_sync_status: BillingSyncStatus;
last_stripe_sync_at: string | null;
external_customer_ref: string | null;
external_subscription_ref: string | null;
created_at: string;
updated_at: string;
};
type BillingTimelineEventRow = {
id: string;
workspace_id: string;
billing_account_id: string | null;
event_type: BillingTimelineEventType;
source: 'stripe' | 'app' | 'system';
payload_json: Record<string, unknown>;
external_event_id: string | null;
external_customer_ref: string | null;
external_subscription_ref: string | null;
occurred_at: string;
created_at: string;
updated_at: string;
};
type BillingAdminWorkspaceSummaryRow = {
workspace_id: string;
workspace_name: string;
workspace_type: 'personal' | 'company';
member_count: string;
status: AccountBillingStatus;
plan_code: string | null;
billing_interval: BillingInterval | null;
current_period_ends_at: string | null;
cancel_at_period_end: boolean;
grace_period_ends_at: string | null;
pending_plan_code: string | null;
pending_plan_effective_at: string | null;
billing_sync_status: BillingSyncStatus;
last_stripe_sync_at: string | null;
external_customer_ref: string | null;
external_subscription_ref: string | null;
};
type UsagePeriodRow = {
id: string;
workspace_id: string;
@@ -120,6 +180,8 @@ export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: s
select id, workspace_id, plan_code, billing_interval, status,
current_period_starts_at, current_period_ends_at,
cancel_at_period_end, canceled_at, trial_ends_at,
grace_period_ends_at, pending_plan_code, pending_plan_effective_at,
billing_sync_status, last_stripe_sync_at,
external_customer_ref, external_subscription_ref,
created_at, updated_at
from public.workspace_billing_accounts
@@ -153,6 +215,8 @@ export async function createDefaultBillingAccountForWorkspace(db: DbClient, work
returning id, workspace_id, plan_code, billing_interval, status,
current_period_starts_at, current_period_ends_at,
cancel_at_period_end, canceled_at, trial_ends_at,
grace_period_ends_at, pending_plan_code, pending_plan_effective_at,
billing_sync_status, last_stripe_sync_at,
external_customer_ref, external_subscription_ref,
created_at, updated_at
`,
@@ -187,6 +251,11 @@ export async function updateBillingAccountState(
cancelAtPeriodEnd?: boolean;
canceledAt?: string | null;
trialEndsAt?: string | null;
gracePeriodEndsAt?: string | null;
pendingPlanCode?: PlanCode | null;
pendingPlanEffectiveAt?: string | null;
billingSyncStatus?: BillingSyncStatus;
lastStripeSyncAt?: string | null;
externalCustomerRef?: string | null;
externalSubscriptionRef?: string | null;
},
@@ -203,10 +272,15 @@ export async function updateBillingAccountState(
cancel_at_period_end,
canceled_at,
trial_ends_at,
grace_period_ends_at,
pending_plan_code,
pending_plan_effective_at,
billing_sync_status,
last_stripe_sync_at,
external_customer_ref,
external_subscription_ref
)
values ($1, $2, $3, $4, $5, $6, coalesce($7, false), $8, $9, $10, $11)
values ($1, $2, $3, $4, $5, $6, coalesce($7, false), $8, $9, $10, $11, $12, coalesce($13, 'ok'), $14, $15, $16)
on conflict (workspace_id)
do update set
plan_code = excluded.plan_code,
@@ -217,11 +291,18 @@ export async function updateBillingAccountState(
cancel_at_period_end = excluded.cancel_at_period_end,
canceled_at = excluded.canceled_at,
trial_ends_at = excluded.trial_ends_at,
grace_period_ends_at = excluded.grace_period_ends_at,
pending_plan_code = excluded.pending_plan_code,
pending_plan_effective_at = excluded.pending_plan_effective_at,
billing_sync_status = excluded.billing_sync_status,
last_stripe_sync_at = excluded.last_stripe_sync_at,
external_customer_ref = excluded.external_customer_ref,
external_subscription_ref = excluded.external_subscription_ref
returning id, workspace_id, plan_code, billing_interval, status,
current_period_starts_at, current_period_ends_at,
cancel_at_period_end, canceled_at, trial_ends_at,
grace_period_ends_at, pending_plan_code, pending_plan_effective_at,
billing_sync_status, last_stripe_sync_at,
external_customer_ref, external_subscription_ref,
created_at, updated_at
`,
@@ -235,6 +316,11 @@ export async function updateBillingAccountState(
input.cancelAtPeriodEnd ?? false,
input.canceledAt ?? null,
input.trialEndsAt ?? null,
input.gracePeriodEndsAt ?? null,
input.pendingPlanCode ?? null,
input.pendingPlanEffectiveAt ?? null,
input.billingSyncStatus ?? 'ok',
input.lastStripeSyncAt ?? null,
input.externalCustomerRef ?? null,
input.externalSubscriptionRef ?? null,
],
@@ -365,6 +451,180 @@ export async function listAddonBalancesForWorkspace(db: DbClient, workspaceId: s
}));
}
export async function createBillingTimelineEvent(
db: DbClient,
input: {
workspaceId: string;
billingAccountId?: string | null;
eventType: BillingTimelineEventType;
source: 'stripe' | 'app' | 'system';
payloadJson?: Record<string, unknown>;
externalEventId?: string | null;
externalCustomerRef?: string | null;
externalSubscriptionRef?: string | null;
occurredAt?: string;
},
) {
const result = await db.query<BillingTimelineEventRow>(
`
insert into public.workspace_billing_timeline_events (
workspace_id, billing_account_id, event_type, source, payload_json,
external_event_id, external_customer_ref, external_subscription_ref, occurred_at
)
values ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, coalesce($9::timestamptz, now()))
returning id, workspace_id, billing_account_id, event_type, source, payload_json,
external_event_id, external_customer_ref, external_subscription_ref,
occurred_at, created_at, updated_at
`,
[
input.workspaceId,
input.billingAccountId ?? null,
input.eventType,
input.source,
JSON.stringify(input.payloadJson ?? {}),
input.externalEventId ?? null,
input.externalCustomerRef ?? null,
input.externalSubscriptionRef ?? null,
input.occurredAt ?? null,
],
);
return mapBillingTimelineEventRow(result.rows[0]);
}
export async function listRecentBillingTimelineEventsForWorkspace(db: DbClient, workspaceId: string, limit = 20) {
const result = await db.query<BillingTimelineEventRow>(
`
select id, workspace_id, billing_account_id, event_type, source, payload_json,
external_event_id, external_customer_ref, external_subscription_ref,
occurred_at, created_at, updated_at
from public.workspace_billing_timeline_events
where workspace_id = $1
order by occurred_at desc, created_at desc
limit $2
`,
[workspaceId, limit],
);
return result.rows.map(mapBillingTimelineEventRow);
}
export async function listBillingAdminWorkspaceSummaries(db: DbClient, search: string | null, limit = 50) {
const result = await db.query<BillingAdminWorkspaceSummaryRow>(
`
select
w.id as workspace_id,
w.name as workspace_name,
w.workspace_type,
(
select count(*)::text
from public.workspace_memberships member
where member.workspace_id = w.id
) as member_count,
billing.status,
billing.plan_code,
billing.billing_interval,
billing.current_period_ends_at,
billing.cancel_at_period_end,
billing.grace_period_ends_at,
billing.pending_plan_code,
billing.pending_plan_effective_at,
billing.billing_sync_status,
billing.last_stripe_sync_at,
billing.external_customer_ref,
billing.external_subscription_ref
from public.workspaces w
join public.workspace_billing_accounts billing on billing.workspace_id = w.id
where (
$1::text is null
or w.name ilike '%' || $1 || '%'
or billing.external_customer_ref ilike '%' || $1 || '%'
or billing.external_subscription_ref ilike '%' || $1 || '%'
)
order by w.updated_at desc
limit $2
`,
[search, limit],
);
return result.rows.map((row) => ({
workspaceId: row.workspace_id,
workspaceName: row.workspace_name,
workspaceType: row.workspace_type,
memberCount: Number(row.member_count),
status: row.status,
planCode: row.plan_code as PlanCode | null,
billingInterval: row.billing_interval,
currentPeriodEndsAt: row.current_period_ends_at,
cancelAtPeriodEnd: row.cancel_at_period_end,
gracePeriodEndsAt: row.grace_period_ends_at,
pendingPlanCode: row.pending_plan_code as PlanCode | null,
pendingPlanEffectiveAt: row.pending_plan_effective_at,
billingSyncStatus: row.billing_sync_status,
lastStripeSyncAt: row.last_stripe_sync_at,
externalCustomerRef: row.external_customer_ref,
externalSubscriptionRef: row.external_subscription_ref,
}));
}
export async function getBillingAdminWorkspaceSummaryByWorkspaceId(db: DbClient, workspaceId: string) {
const result = await db.query<BillingAdminWorkspaceSummaryRow>(
`
select
w.id as workspace_id,
w.name as workspace_name,
w.workspace_type,
(
select count(*)::text
from public.workspace_memberships member
where member.workspace_id = w.id
) as member_count,
billing.status,
billing.plan_code,
billing.billing_interval,
billing.current_period_ends_at,
billing.cancel_at_period_end,
billing.grace_period_ends_at,
billing.pending_plan_code,
billing.pending_plan_effective_at,
billing.billing_sync_status,
billing.last_stripe_sync_at,
billing.external_customer_ref,
billing.external_subscription_ref
from public.workspaces w
join public.workspace_billing_accounts billing on billing.workspace_id = w.id
where w.id = $1
limit 1
`,
[workspaceId],
);
if (result.rowCount === 0) {
return null;
}
const row = result.rows[0];
return {
workspaceId: row.workspace_id,
workspaceName: row.workspace_name,
workspaceType: row.workspace_type,
memberCount: Number(row.member_count),
status: row.status,
planCode: row.plan_code as PlanCode | null,
billingInterval: row.billing_interval,
currentPeriodEndsAt: row.current_period_ends_at,
cancelAtPeriodEnd: row.cancel_at_period_end,
gracePeriodEndsAt: row.grace_period_ends_at,
pendingPlanCode: row.pending_plan_code as PlanCode | null,
pendingPlanEffectiveAt: row.pending_plan_effective_at,
billingSyncStatus: row.billing_sync_status,
lastStripeSyncAt: row.last_stripe_sync_at,
externalCustomerRef: row.external_customer_ref,
externalSubscriptionRef: row.external_subscription_ref,
};
}
export async function recordAddonPurchase(
db: DbClient,
input: {
@@ -448,6 +708,11 @@ function mapBillingAccountRow(row: BillingAccountRow): BillingAccountRecord {
cancelAtPeriodEnd: row.cancel_at_period_end,
canceledAt: row.canceled_at,
trialEndsAt: row.trial_ends_at,
gracePeriodEndsAt: row.grace_period_ends_at,
pendingPlanCode: row.pending_plan_code as PlanCode | null,
pendingPlanEffectiveAt: row.pending_plan_effective_at,
billingSyncStatus: row.billing_sync_status,
lastStripeSyncAt: row.last_stripe_sync_at,
externalCustomerRef: row.external_customer_ref,
externalSubscriptionRef: row.external_subscription_ref,
createdAt: row.created_at,
@@ -490,6 +755,23 @@ function getDefaultBillingPeriodBounds() {
};
}
function mapBillingTimelineEventRow(row: BillingTimelineEventRow): BillingTimelineEventRecord {
return {
id: row.id,
workspaceId: row.workspace_id,
billingAccountId: row.billing_account_id,
eventType: row.event_type,
source: row.source,
payloadJson: row.payload_json,
externalEventId: row.external_event_id,
externalCustomerRef: row.external_customer_ref,
externalSubscriptionRef: row.external_subscription_ref,
occurredAt: row.occurred_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async function bootstrapDefaultBillingAccountState(db: DbClient, workspaceId: string) {
const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds();
+44 -3
View File
@@ -1,5 +1,6 @@
import type { Pool, PoolClient } from 'pg';
import { getUsageAllowanceForPlan, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js';
import { getPendingPlanChangeMessage, isBillingSyncStale, resolveBillingAccessState } from '../../../shared/billing/lifecycle.js';
import type { BillingAddonBalanceSummary, BillingUsageResourceSummary, AccountBillingState } from '../../../shared/types.js';
import type { UsageResource } from '../../../shared/billing/entitlements.js';
import {
@@ -24,6 +25,16 @@ export async function getWorkspaceBillingState(db: DbClient, workspaceId: string
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
canceledAt: billingAccount.canceledAt,
trialEndsAt: billingAccount.trialEndsAt,
gracePeriodEndsAt: billingAccount.gracePeriodEndsAt,
pendingPlanCode: billingAccount.pendingPlanCode,
pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt,
billingSyncStatus: billingAccount.billingSyncStatus,
lastStripeSyncAt: billingAccount.lastStripeSyncAt,
provider: billingAccount.externalCustomerRef ? 'stripe' : null,
externalCustomerRef: billingAccount.externalCustomerRef,
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
usage: [],
addonBalances: await listAddonBalancesForWorkspace(db, workspaceId),
message: 'Subscription management is being prepared. Plan details, usage tracking, and billing controls will appear here in a future update.',
@@ -40,11 +51,19 @@ export async function getWorkspaceBillingState(db: DbClient, workspaceId: string
currentPeriodStartsAt: usageSnapshot.currentPeriodStartsAt,
currentPeriodEndsAt: usageSnapshot.currentPeriodEndsAt,
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
canceledAt: billingAccount.canceledAt,
trialEndsAt: billingAccount.trialEndsAt,
gracePeriodEndsAt: billingAccount.gracePeriodEndsAt,
pendingPlanCode: billingAccount.pendingPlanCode,
pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt,
billingSyncStatus: isBillingSyncStale(billingAccount.lastStripeSyncAt) ? 'stale' : billingAccount.billingSyncStatus,
lastStripeSyncAt: billingAccount.lastStripeSyncAt,
provider: billingAccount.externalCustomerRef ? 'stripe' : null,
externalCustomerRef: billingAccount.externalCustomerRef,
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
usage: usageSnapshot.usage,
addonBalances,
message: billingAccount.status === 'active'
? 'Billing state is active. Usage tracking is available and entitlement enforcement can build on this foundation.'
: 'Billing state is stored, but subscription automation and enforcement are still being built.',
message: buildBillingAccountMessage(billingAccount),
};
}
@@ -191,3 +210,25 @@ function minDate(a: Date, b: Date) {
export async function getWorkspaceAddonBalances(db: DbClient, workspaceId: string): Promise<BillingAddonBalanceSummary[]> {
return listAddonBalancesForWorkspace(db, workspaceId);
}
function buildBillingAccountMessage(billingAccount: BillingAccountRecord) {
const lifecycle = resolveBillingAccessState({
status: billingAccount.status,
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
gracePeriodEndsAt: billingAccount.gracePeriodEndsAt,
});
const pendingPlanMessage = getPendingPlanChangeMessage(
billingAccount.pendingPlanCode,
billingAccount.pendingPlanEffectiveAt,
);
const syncMessage = isBillingSyncStale(billingAccount.lastStripeSyncAt)
? 'Stripe sync looks stale and may need support follow-up.'
: billingAccount.externalSubscriptionRef
? 'Billing is synced from Stripe.'
: 'Billing state is managed locally until Stripe subscription data is attached.';
return [lifecycle.message, pendingPlanMessage, syncMessage].filter(Boolean).join(' ');
}
+27
View File
@@ -26,6 +26,19 @@ const envSchema = z.object({
GOOGLE_MAPS_SERVER_KEY: z.string().optional(),
PG_BOSS_SCHEMA: z.string().default('pgboss'),
SESSION_TTL_DAYS: z.coerce.number().int().positive().default(30),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PRICE_STARTER_MONTHLY: z.string().optional(),
STRIPE_PRICE_STARTER_ANNUAL: z.string().optional(),
STRIPE_PRICE_GROWTH_MONTHLY: z.string().optional(),
STRIPE_PRICE_GROWTH_ANNUAL: z.string().optional(),
STRIPE_PRICE_PRO_MONTHLY: z.string().optional(),
STRIPE_PRICE_PRO_ANNUAL: z.string().optional(),
STRIPE_PRICE_EXPORT_PACK_10K: z.string().optional(),
STRIPE_PRICE_EXPORT_PACK_50K: z.string().optional(),
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
BILLING_ADMIN_EMAILS: z.string().optional(),
});
export type AppEnv = z.infer<typeof envSchema>;
@@ -40,3 +53,17 @@ export function getEnv(): AppEnv {
cachedEnv = envSchema.parse(process.env);
return cachedEnv;
}
export function isBillingAdminEmail(email: string) {
const allowlist = getEnv().BILLING_ADMIN_EMAILS;
if (!allowlist) {
return false;
}
return allowlist
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean)
.includes(email.trim().toLowerCase());
}
+106
View File
@@ -0,0 +1,106 @@
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
import { getAddonByCode } from '../../../shared/billing/addons.js';
import { getPlanByCode } from '../../../shared/billing/plans.js';
import type { AppEnv } from '../config/env.js';
export type SupportedSubscriptionPlanCode = Exclude<ActivePlanCode, 'enterprise_custom'>;
export type SupportedAddonCode = 'export_pack_10k' | 'export_pack_50k';
const supportedSubscriptionPlanCodes = [
'starter_monthly',
'starter_annual',
'growth_monthly',
'growth_annual',
'pro_monthly',
'pro_annual',
] as const satisfies readonly SupportedSubscriptionPlanCode[];
const supportedAddonCodes = ['export_pack_10k', 'export_pack_50k'] as const satisfies readonly SupportedAddonCode[];
export function isSupportedSubscriptionPlanCode(planCode: ActivePlanCode): planCode is SupportedSubscriptionPlanCode {
return supportedSubscriptionPlanCodes.includes(planCode as SupportedSubscriptionPlanCode);
}
export function isSupportedAddonCode(addonCode: AddonCode): addonCode is SupportedAddonCode {
return supportedAddonCodes.includes(addonCode as SupportedAddonCode);
}
export function getStripePriceIdForPlan(env: AppEnv, planCode: SupportedSubscriptionPlanCode) {
const mapping: Record<SupportedSubscriptionPlanCode, string | undefined> = {
starter_monthly: env.STRIPE_PRICE_STARTER_MONTHLY,
starter_annual: env.STRIPE_PRICE_STARTER_ANNUAL,
growth_monthly: env.STRIPE_PRICE_GROWTH_MONTHLY,
growth_annual: env.STRIPE_PRICE_GROWTH_ANNUAL,
pro_monthly: env.STRIPE_PRICE_PRO_MONTHLY,
pro_annual: env.STRIPE_PRICE_PRO_ANNUAL,
};
const priceId = mapping[planCode];
if (!priceId) {
throw new Error(`Missing Stripe price ID for plan '${planCode}'.`);
}
return priceId;
}
export function getStripePriceIdForAddon(env: AppEnv, addonCode: SupportedAddonCode) {
const mapping: Record<SupportedAddonCode, string | undefined> = {
export_pack_10k: env.STRIPE_PRICE_EXPORT_PACK_10K,
export_pack_50k: env.STRIPE_PRICE_EXPORT_PACK_50K,
};
const priceId = mapping[addonCode];
if (!priceId) {
throw new Error(`Missing Stripe price ID for add-on '${addonCode}'.`);
}
return priceId;
}
export function getPlanCodeForStripePriceId(env: AppEnv, priceId: string): SupportedSubscriptionPlanCode | null {
for (const planCode of supportedSubscriptionPlanCodes) {
if (getStripePriceIdForPlan(env, planCode) === priceId) {
return planCode;
}
}
return null;
}
export function getAddonCodeForStripePriceId(env: AppEnv, priceId: string): SupportedAddonCode | null {
for (const addonCode of supportedAddonCodes) {
if (getStripePriceIdForAddon(env, addonCode) === priceId) {
return addonCode;
}
}
return null;
}
export function assertSelfServePlanSupportsStripeCheckout(planCode: ActivePlanCode) {
const plan = getPlanByCode(planCode);
if (!plan) {
throw new Error(`Unknown plan '${planCode}'.`);
}
if (!plan.isSelfServe || plan.contactSalesRequired || !isSupportedSubscriptionPlanCode(planCode)) {
throw new Error(`Plan '${planCode}' is not available for self-serve Stripe checkout.`);
}
return plan;
}
export function assertAddonSupportsStripeCheckout(addonCode: AddonCode) {
const addon = getAddonByCode(addonCode);
if (!addon) {
throw new Error(`Unknown add-on '${addonCode}'.`);
}
if (addon.availability !== 'active' || addon.purchaseMode !== 'one_time' || !isSupportedAddonCode(addonCode)) {
throw new Error(`Add-on '${addonCode}' is not available for Stripe checkout.`);
}
return addon;
}
+166
View File
@@ -0,0 +1,166 @@
import type { Pool, PoolClient } from 'pg';
type DbClient = Pool | PoolClient;
export type BillingWebhookEventStatus = 'received' | 'processed' | 'failed' | 'ignored';
export interface BillingWebhookEventRecord {
id: string;
provider: 'stripe';
externalEventId: string;
eventType: string;
status: BillingWebhookEventStatus;
workspaceId: string | null;
externalCustomerRef: string | null;
externalSubscriptionRef: string | null;
payloadJson: Record<string, unknown>;
errorMessage: string | null;
receivedAt: string;
processedAt: string | null;
createdAt: string;
updatedAt: string;
}
type BillingWebhookEventRow = {
id: string;
provider: 'stripe';
external_event_id: string;
event_type: string;
status: BillingWebhookEventStatus;
workspace_id: string | null;
external_customer_ref: string | null;
external_subscription_ref: string | null;
payload_json: Record<string, unknown>;
error_message: string | null;
received_at: string;
processed_at: string | null;
created_at: string;
updated_at: string;
};
export async function recordIncomingWebhookEvent(
db: DbClient,
input: {
provider: 'stripe';
externalEventId: string;
eventType: string;
workspaceId?: string | null;
externalCustomerRef?: string | null;
externalSubscriptionRef?: string | null;
payloadJson: Record<string, unknown>;
},
) {
const result = await db.query<BillingWebhookEventRow>(
`
insert into public.billing_webhook_events (
provider, external_event_id, event_type, status,
workspace_id, external_customer_ref, external_subscription_ref, payload_json
)
values ($1, $2, $3, 'received', $4, $5, $6, $7::jsonb)
on conflict (provider, external_event_id) do nothing
returning id, provider, external_event_id, event_type, status,
workspace_id, external_customer_ref, external_subscription_ref,
payload_json, error_message, received_at, processed_at, created_at, updated_at
`,
[
input.provider,
input.externalEventId,
input.eventType,
input.workspaceId ?? null,
input.externalCustomerRef ?? null,
input.externalSubscriptionRef ?? null,
JSON.stringify(input.payloadJson),
],
);
if (result.rowCount === 0) {
const existing = await getWebhookEventByExternalId(db, input.provider, input.externalEventId);
return {
record: existing,
inserted: false,
};
}
return {
record: mapBillingWebhookEventRow(result.rows[0]),
inserted: true,
};
}
export async function getWebhookEventByExternalId(db: DbClient, provider: 'stripe', externalEventId: string) {
const result = await db.query<BillingWebhookEventRow>(
`
select id, provider, external_event_id, event_type, status,
workspace_id, external_customer_ref, external_subscription_ref,
payload_json, error_message, received_at, processed_at, created_at, updated_at
from public.billing_webhook_events
where provider = $1 and external_event_id = $2
limit 1
`,
[provider, externalEventId],
);
if (result.rowCount === 0) {
return null;
}
return mapBillingWebhookEventRow(result.rows[0]);
}
export async function markWebhookEventProcessed(db: DbClient, id: string, status: Extract<BillingWebhookEventStatus, 'processed' | 'ignored'>) {
await db.query(
`
update public.billing_webhook_events
set status = $2, processed_at = now(), error_message = null
where id = $1
`,
[id, status],
);
}
export async function markWebhookEventFailed(db: DbClient, id: string, errorMessage: string) {
await db.query(
`
update public.billing_webhook_events
set status = 'failed', processed_at = now(), error_message = $2
where id = $1
`,
[id, errorMessage],
);
}
export async function listRecentWebhookEventsForWorkspace(db: DbClient, workspaceId: string, limit = 20) {
const result = await db.query<BillingWebhookEventRow>(
`
select id, provider, external_event_id, event_type, status,
workspace_id, external_customer_ref, external_subscription_ref,
payload_json, error_message, received_at, processed_at, created_at, updated_at
from public.billing_webhook_events
where workspace_id = $1
order by received_at desc
limit $2
`,
[workspaceId, limit],
);
return result.rows.map(mapBillingWebhookEventRow);
}
function mapBillingWebhookEventRow(row: BillingWebhookEventRow): BillingWebhookEventRecord {
return {
id: row.id,
provider: row.provider,
externalEventId: row.external_event_id,
eventType: row.event_type,
status: row.status,
workspaceId: row.workspace_id,
externalCustomerRef: row.external_customer_ref,
externalSubscriptionRef: row.external_subscription_ref,
payloadJson: row.payload_json,
errorMessage: row.error_message,
receivedAt: row.received_at,
processedAt: row.processed_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
+395
View File
@@ -0,0 +1,395 @@
import type Stripe from 'stripe';
import type { Pool, PoolClient } from 'pg';
import { getDefaultBillingGracePeriodEndsAt } from '../../../shared/billing/lifecycle.js';
import { getAddonByCode } from '../../../shared/billing/addons.js';
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
import { getPlanByCode } from '../../../shared/billing/plans.js';
import { ensureWorkspaceForUser } from '../account/repository.js';
import { ensureBillingAccountForWorkspace, listAddonBalancesForWorkspace, recordAddonPurchase, updateBillingAccountState, upsertAddonBalance } from '../billing/repository.js';
import { getEnv } from '../config/env.js';
import {
assertAddonSupportsStripeCheckout,
assertSelfServePlanSupportsStripeCheckout,
getPlanCodeForStripePriceId,
type SupportedAddonCode,
type SupportedSubscriptionPlanCode,
getStripePriceIdForAddon,
getStripePriceIdForPlan,
} from './catalog.js';
import { getStripeClient, isStripeConfigured } from './stripe-client.js';
type DbClient = Pool | PoolClient;
export interface BillingActor {
id: string;
email: string;
displayName?: string | null;
}
export async function createSubscriptionCheckoutSession(
db: DbClient,
input: { user: BillingActor; planCode: ActivePlanCode },
) {
ensureStripeReady('checkout');
const workspace = await ensureWorkspaceForUser(db, input.user);
if (!workspace) {
throw new Error('Failed to resolve billing workspace.');
}
const plan = assertSelfServePlanSupportsStripeCheckout(input.planCode);
const env = getEnv();
const stripe = getStripeClient();
const billingAccount = await ensureBillingAccountForWorkspace(db, workspace.id);
const customerId = await ensureStripeCustomer(db, stripe, {
workspaceId: workspace.id,
workspaceName: workspace.name,
user: input.user,
existingCustomerRef: billingAccount.externalCustomerRef,
});
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
success_url: buildStripeReturnUrl('/account?billing=success'),
cancel_url: buildStripeReturnUrl('/account?billing=cancelled'),
line_items: [
{
price: getStripePriceIdForPlan(env, plan.code as SupportedSubscriptionPlanCode),
quantity: 1,
},
],
allow_promotion_codes: true,
client_reference_id: workspace.id,
metadata: {
workspaceId: workspace.id,
checkoutKind: 'subscription',
planCode: plan.code,
billingInterval: plan.billingInterval,
initiatedByUserId: input.user.id,
},
subscription_data: {
metadata: {
workspaceId: workspace.id,
planCode: plan.code,
billingInterval: plan.billingInterval,
},
},
customer_update: {
name: 'auto',
address: 'auto',
},
});
if (!session.url) {
throw new Error('Stripe did not return a checkout URL.');
}
return {
checkoutUrl: session.url,
};
}
export async function createAddonCheckoutSession(
db: DbClient,
input: { user: BillingActor; addonCode: AddonCode },
) {
ensureStripeReady('checkout');
const workspace = await ensureWorkspaceForUser(db, input.user);
if (!workspace) {
throw new Error('Failed to resolve billing workspace.');
}
const billingAccount = await ensureBillingAccountForWorkspace(db, workspace.id);
const addon = assertAddonSupportsStripeCheckout(input.addonCode);
if (!billingAccount.planCode) {
throw new Error('A billing plan is required before buying add-ons.');
}
const plan = getPlanByCode(billingAccount.planCode);
if (!plan || !plan.eligibleAddonCodes.includes(addon.code)) {
throw new Error('This add-on is not eligible for the current plan.');
}
const env = getEnv();
const stripe = getStripeClient();
const customerId = await ensureStripeCustomer(db, stripe, {
workspaceId: workspace.id,
workspaceName: workspace.name,
user: input.user,
existingCustomerRef: billingAccount.externalCustomerRef,
});
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer: customerId,
success_url: buildStripeReturnUrl('/account?billing=addon-success'),
cancel_url: buildStripeReturnUrl('/account?billing=cancelled'),
line_items: [
{
price: getStripePriceIdForAddon(env, addon.code as SupportedAddonCode),
quantity: 1,
},
],
client_reference_id: workspace.id,
metadata: {
workspaceId: workspace.id,
checkoutKind: 'addon',
addonCode: addon.code,
initiatedByUserId: input.user.id,
},
});
if (!session.url) {
throw new Error('Stripe did not return a checkout URL.');
}
return {
checkoutUrl: session.url,
};
}
export async function createBillingPortalSession(db: DbClient, input: { user: BillingActor }) {
ensureStripeReady('billing portal');
const workspace = await ensureWorkspaceForUser(db, input.user);
if (!workspace) {
throw new Error('Failed to resolve billing workspace.');
}
const billingAccount = await ensureBillingAccountForWorkspace(db, workspace.id);
if (!billingAccount.externalCustomerRef) {
throw new Error('This workspace is not connected to Stripe billing yet.');
}
const stripe = getStripeClient();
const session = await stripe.billingPortal.sessions.create({
customer: billingAccount.externalCustomerRef,
return_url: buildStripeReturnUrl('/account'),
configuration: getEnv().STRIPE_BILLING_PORTAL_CONFIGURATION_ID || undefined,
});
return {
url: session.url,
};
}
export async function syncWorkspaceBillingFromStripeSubscription(
db: DbClient,
subscription: Stripe.Subscription,
workspaceIdHint?: string | null,
) {
const env = getEnv();
const priceId = subscription.items.data[0]?.price?.id;
if (!priceId) {
throw new Error(`Stripe subscription '${subscription.id}' is missing a primary price.`);
}
const planCode = getPlanCodeForStripePriceId(env, priceId);
if (!planCode) {
throw new Error(`Unsupported Stripe price '${priceId}' for subscription sync.`);
}
const workspaceId = workspaceIdHint
|| getMetadataValue(subscription.metadata, 'workspaceId')
|| getMetadataValue(subscription.items.data[0]?.metadata, 'workspaceId');
if (!workspaceId) {
throw new Error(`Stripe subscription '${subscription.id}' is missing workspace metadata.`);
}
const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId);
const plan = getPlanByCode(planCode);
const nextStatus = mapStripeSubscriptionStatus(subscription.status);
if (!plan) {
throw new Error(`Unknown internal plan '${planCode}'.`);
}
await updateBillingAccountState(db, {
workspaceId,
planCode,
billingInterval: plan.billingInterval,
status: nextStatus,
currentPeriodStartsAt: toIsoStringOrNull(subscription.items.data[0]?.current_period_start ?? null),
currentPeriodEndsAt: toIsoStringOrNull(subscription.items.data[0]?.current_period_end ?? null),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: toIsoStringOrNull(subscription.canceled_at),
trialEndsAt: toIsoStringOrNull(subscription.trial_end),
gracePeriodEndsAt: nextStatus === 'past_due'
? billingAccount.gracePeriodEndsAt ?? getDefaultBillingGracePeriodEndsAt(new Date())
: null,
pendingPlanCode: null,
pendingPlanEffectiveAt: null,
billingSyncStatus: 'ok',
lastStripeSyncAt: new Date().toISOString(),
externalCustomerRef: extractStripeCustomerId(subscription.customer) ?? billingAccount.externalCustomerRef,
externalSubscriptionRef: subscription.id,
});
return workspaceId;
}
export async function fulfillAddonCheckoutSession(db: DbClient, session: Stripe.Checkout.Session) {
const workspaceId = session.client_reference_id || getMetadataValue(session.metadata, 'workspaceId');
const addonCode = getMetadataValue(session.metadata, 'addonCode') as AddonCode | null;
if (!workspaceId || !addonCode) {
throw new Error(`Stripe checkout session '${session.id}' is missing add-on metadata.`);
}
const addon = getAddonByCode(addonCode);
if (!addon || addon.resource === null || addon.quantity === null) {
throw new Error(`Unsupported add-on fulfillment for '${addonCode}'.`);
}
await recordAddonPurchase(db, {
workspaceId,
addonCode,
resource: addon.resource,
purchasedQuantity: addon.quantity,
remainingQuantity: addon.quantity,
purchasedAt: session.created ? new Date(session.created * 1000).toISOString() : undefined,
expiresAt: null,
externalPurchaseRef: session.payment_intent ? String(session.payment_intent) : session.id,
});
const existingBalance = (await listAddonBalancesForWorkspace(db, workspaceId)).find(
(balance) => balance.addonCode === addonCode && balance.resource === addon.resource,
);
await upsertAddonBalance(db, {
workspaceId,
addonCode,
resource: addon.resource,
remainingQuantity: (existingBalance?.remainingQuantity ?? 0) + addon.quantity,
expiresAt: null,
});
return workspaceId;
}
export async function retrieveStripeSubscription(subscriptionId: string) {
const stripe = getStripeClient();
return stripe.subscriptions.retrieve(subscriptionId);
}
export function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): 'active' | 'inactive' | 'past_due' | 'canceled' {
switch (status) {
case 'active':
case 'trialing':
return 'active';
case 'past_due':
case 'unpaid':
return 'past_due';
case 'canceled':
case 'incomplete_expired':
return 'canceled';
case 'incomplete':
case 'paused':
return 'inactive';
default:
return 'inactive';
}
}
export function getWorkspaceIdFromStripeObject(input: { metadata?: Stripe.Metadata | null; customer?: string | Stripe.Customer | Stripe.DeletedCustomer | null; client_reference_id?: string | null }) {
return input.client_reference_id || getMetadataValue(input.metadata ?? null, 'workspaceId');
}
export function getStripeCustomerIdFromObject(input: { customer?: string | Stripe.Customer | Stripe.DeletedCustomer | null }) {
return extractStripeCustomerId(input.customer ?? null);
}
export function getStripeSubscriptionIdFromInvoice(invoice: Stripe.Invoice) {
return extractSubscriptionId(invoice.parent?.subscription_details?.subscription ?? null);
}
function ensureStripeReady(context: string) {
if (!isStripeConfigured()) {
throw new Error(`Stripe ${context} is not configured in this environment yet.`);
}
}
async function ensureStripeCustomer(
db: DbClient,
stripe: Stripe,
input: {
workspaceId: string;
workspaceName: string;
user: BillingActor;
existingCustomerRef: string | null;
},
) {
if (input.existingCustomerRef) {
return input.existingCustomerRef;
}
const customer = await stripe.customers.create({
email: input.user.email,
name: input.workspaceName,
metadata: {
workspaceId: input.workspaceId,
environment: getEnv().NODE_ENV,
ownerUserId: input.user.id,
},
});
const billingAccount = await ensureBillingAccountForWorkspace(db, input.workspaceId);
await updateBillingAccountState(db, {
workspaceId: input.workspaceId,
planCode: billingAccount.planCode,
billingInterval: billingAccount.billingInterval,
status: billingAccount.status,
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
canceledAt: billingAccount.canceledAt,
trialEndsAt: billingAccount.trialEndsAt,
gracePeriodEndsAt: billingAccount.gracePeriodEndsAt,
pendingPlanCode: billingAccount.pendingPlanCode,
pendingPlanEffectiveAt: billingAccount.pendingPlanEffectiveAt,
billingSyncStatus: billingAccount.billingSyncStatus,
lastStripeSyncAt: billingAccount.lastStripeSyncAt,
externalCustomerRef: customer.id,
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
});
return customer.id;
}
function buildStripeReturnUrl(path: string) {
const origin = getEnv().APP_ORIGIN.split(',')[0]?.trim() || 'http://localhost:3000';
return `${origin.replace(/\/+$/, '')}${path}`;
}
function getMetadataValue(metadata: Stripe.Metadata | null | undefined, key: string) {
const value = metadata?.[key];
return typeof value === 'string' && value.trim() ? value : null;
}
function extractStripeCustomerId(customer: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined) {
if (!customer) {
return null;
}
return typeof customer === 'string' ? customer : customer.id;
}
function extractSubscriptionId(subscription: string | Stripe.Subscription | null) {
if (!subscription) {
return null;
}
return typeof subscription === 'string' ? subscription : subscription.id;
}
function toIsoStringOrNull(timestamp: number | null) {
return typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : null;
}
+27
View File
@@ -0,0 +1,27 @@
import Stripe from 'stripe';
import { getEnv } from '../config/env.js';
let cachedStripeClient: Stripe | null = null;
export function isStripeConfigured() {
const env = getEnv();
return Boolean(env.STRIPE_SECRET_KEY);
}
export function getStripeClient() {
if (cachedStripeClient) {
return cachedStripeClient;
}
const env = getEnv();
if (!env.STRIPE_SECRET_KEY) {
throw new Error('Stripe is not configured. Set STRIPE_SECRET_KEY to enable payments.');
}
cachedStripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2026-04-22.dahlia',
});
return cachedStripeClient;
}
+298
View File
@@ -0,0 +1,298 @@
import type Stripe from 'stripe';
import type { Pool, PoolClient } from 'pg';
import { getDbPool } from '../db/pool.js';
import { getEnv } from '../config/env.js';
import { createBillingTimelineEvent, ensureBillingAccountForWorkspace } from '../billing/repository.js';
import { markWebhookEventFailed, markWebhookEventProcessed, recordIncomingWebhookEvent } from './repository.js';
import { getStripeClient } from './stripe-client.js';
import {
fulfillAddonCheckoutSession,
getStripeCustomerIdFromObject,
getStripeSubscriptionIdFromInvoice,
getWorkspaceIdFromStripeObject,
retrieveStripeSubscription,
syncWorkspaceBillingFromStripeSubscription,
} from './service.js';
import { recordAnalyticsEvent } from '../analytics/service.js';
type DbClient = Pool | PoolClient;
export function constructStripeWebhookEvent(payload: string, signature: string) {
const webhookSecret = getEnv().STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('Stripe webhook secret is not configured.');
}
return getStripeClient().webhooks.constructEvent(payload, signature, webhookSecret);
}
export async function processStripeWebhookEvent(event: Stripe.Event, db: DbClient = getDbPool()) {
const workspaceId = getWorkspaceIdForWebhookEvent(event);
const externalCustomerRef = getStripeCustomerIdForWebhookEvent(event);
const externalSubscriptionRef = getStripeSubscriptionIdForWebhookEvent(event);
const incoming = await recordIncomingWebhookEvent(db, {
provider: 'stripe',
externalEventId: event.id,
eventType: event.type,
workspaceId,
externalCustomerRef,
externalSubscriptionRef,
payloadJson: event as unknown as Record<string, unknown>,
});
if (!incoming.inserted) {
return {
status: 'duplicate' as const,
workspaceId: incoming.record?.workspaceId ?? workspaceId,
};
}
try {
const processed = await handleStripeWebhookEvent(db, event, workspaceId);
if (workspaceId) {
const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId);
await createBillingTimelineEvent(db, {
workspaceId,
billingAccountId: billingAccount.id,
eventType: mapTimelineEventType(event),
source: 'stripe',
payloadJson: {
eventType: event.type,
livemode: event.livemode,
},
externalEventId: event.id,
externalCustomerRef,
externalSubscriptionRef,
occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
});
}
await markWebhookEventProcessed(db, incoming.record!.id, processed ? 'processed' : 'ignored');
return {
status: processed ? 'processed' as const : 'ignored' as const,
workspaceId,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Webhook processing failed.';
await markWebhookEventFailed(db, incoming.record!.id, message);
throw error;
}
}
function mapTimelineEventType(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
return session.mode === 'payment' ? 'addon_purchased' : 'checkout_completed';
}
case 'customer.subscription.created':
return 'subscription_created';
case 'customer.subscription.updated':
return 'subscription_updated';
case 'customer.subscription.deleted':
return 'subscription_deleted';
case 'invoice.paid':
return 'invoice_paid';
case 'invoice.payment_failed':
return 'invoice_payment_failed';
default:
return 'billing_status_changed';
}
}
async function handleStripeWebhookEvent(db: DbClient, event: Stripe.Event, workspaceIdHint?: string | null) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const workspaceId = workspaceIdHint ?? session.client_reference_id ?? null;
if (session.mode === 'payment' && session.payment_status === 'paid') {
await fulfillAddonCheckoutSession(db, session);
await safeRecordAnalyticsEvent(db, {
eventName: 'addon_purchase_completed',
eventSource: 'stripe_webhook',
workspaceId,
addonCode: session.metadata?.addonCode ?? null,
amount: typeof session.amount_total === 'number' ? session.amount_total / 100 : null,
currency: session.currency ?? null,
metadata: {
stripeEventType: event.type,
stripeSessionId: session.id,
stripeMode: session.mode,
},
occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
});
return true;
}
if (session.mode === 'subscription' && typeof session.subscription === 'string') {
const subscription = await retrieveStripeSubscription(session.subscription);
await syncWorkspaceBillingFromStripeSubscription(db, subscription, workspaceId);
await safeRecordAnalyticsEvent(db, {
eventName: 'checkout_completed',
eventSource: 'stripe_webhook',
workspaceId,
planCode: subscription.metadata?.planCode ?? null,
metadata: {
stripeEventType: event.type,
stripeSessionId: session.id,
stripeMode: session.mode,
stripeSubscriptionId: subscription.id,
},
occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
});
return true;
}
return false;
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await syncWorkspaceBillingFromStripeSubscription(db, subscription, workspaceIdHint);
if (event.type === 'customer.subscription.deleted') {
await safeRecordAnalyticsEvent(db, {
eventName: 'subscription_canceled',
eventSource: 'stripe_webhook',
workspaceId: workspaceIdHint ?? null,
planCode: subscription.metadata?.planCode ?? null,
metadata: {
stripeEventType: event.type,
stripeSubscriptionId: subscription.id,
status: subscription.status,
},
occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
});
}
if (event.type === 'customer.subscription.updated') {
const previousAttributes = (event.data as { previous_attributes?: Record<string, unknown> }).previous_attributes ?? {};
const changed = 'status' in previousAttributes || 'items' in previousAttributes || 'cancel_at_period_end' in previousAttributes;
if (changed) {
await safeRecordAnalyticsEvent(db, {
eventName: 'plan_changed',
eventSource: 'stripe_webhook',
workspaceId: workspaceIdHint ?? null,
planCode: subscription.metadata?.planCode ?? null,
metadata: {
stripeEventType: event.type,
stripeSubscriptionId: subscription.id,
status: subscription.status,
previousAttributes,
},
occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
});
}
}
return true;
}
case 'invoice.paid':
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = getStripeSubscriptionIdFromInvoice(invoice);
if (event.type === 'invoice.payment_failed') {
await safeRecordAnalyticsEvent(db, {
eventName: 'payment_failed',
eventSource: 'stripe_webhook',
workspaceId: workspaceIdHint ?? null,
amount: typeof invoice.amount_due === 'number' ? invoice.amount_due / 100 : null,
currency: invoice.currency ?? null,
metadata: {
stripeEventType: event.type,
stripeInvoiceId: invoice.id,
stripeSubscriptionId: subscriptionId,
},
occurredAt: new Date((event.created ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
});
}
if (!subscriptionId) {
return false;
}
const subscription = await retrieveStripeSubscription(subscriptionId);
await syncWorkspaceBillingFromStripeSubscription(db, subscription, workspaceIdHint);
return true;
}
default:
return false;
}
}
function getWorkspaceIdForWebhookEvent(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
return getWorkspaceIdFromStripeObject(session);
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
return getWorkspaceIdFromStripeObject(subscription);
}
case 'invoice.paid':
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
return getWorkspaceIdFromStripeObject({ metadata: invoice.parent?.subscription_details?.metadata ?? invoice.lines.data[0]?.metadata ?? null });
}
default:
return null;
}
}
function getStripeCustomerIdForWebhookEvent(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
return getStripeCustomerIdFromObject(session);
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
return getStripeCustomerIdFromObject(subscription);
}
case 'invoice.paid':
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
return getStripeCustomerIdFromObject(invoice);
}
default:
return null;
}
}
function getStripeSubscriptionIdForWebhookEvent(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
return typeof session.subscription === 'string' ? session.subscription : session.subscription?.id ?? null;
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
return subscription.id;
}
case 'invoice.paid':
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
return getStripeSubscriptionIdFromInvoice(invoice);
}
default:
return null;
}
}
async function safeRecordAnalyticsEvent(db: DbClient, input: Parameters<typeof recordAnalyticsEvent>[1]) {
try {
await recordAnalyticsEvent(db, input);
} catch {
return;
}
}
+92
View File
@@ -0,0 +1,92 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import { z, ZodError } from 'zod';
import { ensureWorkspaceForUser } from '../account/repository.js';
import { hydrateAuthUser, requireAuth } from '../auth/middleware.js';
import { isBillingAdminEmail } from '../config/env.js';
import { getDbPool } from '../db/pool.js';
import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js';
const eventInputSchema = z.object({
eventName: z.enum([
'pricing_plan_selected',
'checkout_started',
'checkout_completed',
'checkout_canceled',
'portal_opened',
'quota_warning_shown',
'quota_exhausted_blocked',
'feature_gate_encountered',
'addon_checkout_started',
'addon_purchase_completed',
'plan_changed',
'payment_failed',
'subscription_canceled',
]),
eventSource: z.enum(['web_app', 'api', 'stripe_webhook', 'system']).default('web_app'),
workspaceId: z.string().uuid().optional(),
planCode: z.string().trim().min(1).max(128).optional(),
addonCode: z.string().trim().min(1).max(128).optional(),
resource: z.string().trim().min(1).max(128).optional(),
amount: z.number().finite().optional(),
currency: z.string().trim().min(1).max(16).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
occurredAt: z.string().datetime().optional(),
});
const summaryQuerySchema = z.object({
days: z.coerce.number().int().min(7).max(90).optional(),
});
async function requireBillingAdmin(request: FastifyRequest, reply: FastifyReply) {
if (!request.authUser || !isBillingAdminEmail(request.authUser.email)) {
return reply.code(403).send({ error: 'Billing admin access is required.' });
}
return undefined;
}
export const analyticsRoutes: FastifyPluginAsync = async (app) => {
app.post('/analytics/events', async (request, reply) => {
try {
const payload = eventInputSchema.parse(request.body ?? {});
const db = getDbPool();
const authUser = await hydrateAuthUser(request);
let workspaceId = payload.workspaceId ?? null;
if (authUser) {
const workspace = await ensureWorkspaceForUser(db, authUser);
workspaceId = workspace?.id ?? workspaceId;
}
await recordAnalyticsEvent(db, {
...payload,
userId: authUser?.id ?? null,
workspaceId,
});
return { ok: true };
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid analytics payload.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to record analytics event.' });
}
});
app.get('/admin/analytics/summary', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => {
try {
const query = summaryQuerySchema.parse(request.query ?? {});
const summary = await getAdminAnalyticsSummary(getDbPool(), query.days ?? 30);
return { summary };
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid analytics summary query.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to load analytics summary.' });
}
});
};
+198
View File
@@ -0,0 +1,198 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import { z, ZodError } from 'zod';
import { getDbPool } from '../db/pool.js';
import { requireAuth } from '../auth/middleware.js';
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js';
import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js';
import { constructStripeWebhookEvent, processStripeWebhookEvent } from '../payments/webhooks.js';
import { ensureWorkspaceForUser } from '../account/repository.js';
import { isBillingAdminEmail } from '../config/env.js';
import {
ensureBillingAccountForWorkspace,
getBillingAdminWorkspaceSummaryByWorkspaceId,
listBillingAdminWorkspaceSummaries,
listRecentBillingTimelineEventsForWorkspace,
} from '../billing/repository.js';
import { ensureCurrentUsagePeriodForBillingAccount, getWorkspaceBillingState } from '../billing/service.js';
const subscriptionCheckoutSchema = z.object({
planCode: z.enum(['starter_monthly', 'starter_annual', 'growth_monthly', 'growth_annual', 'pro_monthly', 'pro_annual', 'enterprise_custom']),
});
const addonCheckoutSchema = z.object({
addonCode: z.enum(['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly']),
});
const adminWorkspaceQuerySchema = z.object({
query: z.string().trim().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
});
const adminWorkspaceParamsSchema = z.object({
workspaceId: z.string().uuid(),
});
function parseJsonBody<T>(body: unknown, schema: z.ZodSchema<T>) {
if (typeof body !== 'string') {
throw new Error('Expected raw JSON request body.');
}
return schema.parse(JSON.parse(body) as unknown);
}
async function requireBillingAdmin(request: FastifyRequest, reply: FastifyReply) {
if (!request.authUser || !isBillingAdminEmail(request.authUser.email)) {
return reply.code(403).send({ error: 'Billing admin access is required.' });
}
return undefined;
}
export const billingRoutes: FastifyPluginAsync = async (app) => {
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_request, body, done) => {
done(null, body);
});
app.post('/billing/checkout/subscription', { preHandler: requireAuth }, async (request, reply) => {
try {
const payload = parseJsonBody<{ planCode: ActivePlanCode }>(request.body, subscriptionCheckoutSchema);
const result = await createSubscriptionCheckoutSession(getDbPool(), {
user: request.authUser!,
planCode: payload.planCode,
});
return result;
} catch (error) {
if (error instanceof SyntaxError || error instanceof ZodError) {
return reply.code(400).send({ error: 'Invalid subscription checkout payload.' });
}
request.log.error(error);
return reply.code(400).send({ error: error instanceof Error ? error.message : 'Failed to create subscription checkout session.' });
}
});
app.post('/billing/checkout/addon', { preHandler: requireAuth }, async (request, reply) => {
try {
const payload = parseJsonBody<{ addonCode: AddonCode }>(request.body, addonCheckoutSchema);
const result = await createAddonCheckoutSession(getDbPool(), {
user: request.authUser!,
addonCode: payload.addonCode,
});
return result;
} catch (error) {
if (error instanceof SyntaxError || error instanceof ZodError) {
return reply.code(400).send({ error: 'Invalid add-on checkout payload.' });
}
request.log.error(error);
return reply.code(400).send({ error: error instanceof Error ? error.message : 'Failed to create add-on checkout session.' });
}
});
app.post('/billing/portal', { preHandler: requireAuth }, async (request, reply) => {
try {
const result = await createBillingPortalSession(getDbPool(), {
user: request.authUser!,
});
return result;
} catch (error) {
request.log.error(error);
return reply.code(400).send({ error: error instanceof Error ? error.message : 'Failed to create billing portal session.' });
}
});
app.get('/billing/debug', { preHandler: requireAuth }, async (request, reply) => {
try {
const workspace = await ensureWorkspaceForUser(getDbPool(), request.authUser!);
if (!workspace) {
return reply.code(500).send({ error: 'Failed to resolve workspace.' });
}
const events = await listRecentWebhookEventsForWorkspace(getDbPool(), workspace.id);
return { events };
} catch (error) {
request.log.error(error);
return reply.code(500).send({ error: 'Failed to load billing debug events.' });
}
});
app.get('/admin/billing/workspaces', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => {
try {
const query = adminWorkspaceQuerySchema.parse(request.query ?? {});
const workspaces = await listBillingAdminWorkspaceSummaries(
getDbPool(),
query.query ?? null,
query.limit ?? 50,
);
return { workspaces };
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid billing admin query.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to load admin billing workspaces.' });
}
});
app.get('/admin/billing/workspaces/:workspaceId', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => {
try {
const { workspaceId } = adminWorkspaceParamsSchema.parse(request.params);
const db = getDbPool();
const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId);
if (!summary) {
return reply.code(404).send({ error: 'Billing workspace not found.' });
}
const billing = await getWorkspaceBillingState(db, workspaceId);
const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId);
const usagePeriod = await ensureCurrentUsagePeriodForBillingAccount(db, billingAccount);
const timeline = await listRecentBillingTimelineEventsForWorkspace(db, workspaceId, 25);
const webhookEvents = await listRecentWebhookEventsForWorkspace(db, workspaceId, 25);
return {
workspace: {
summary,
billing,
usagePeriodId: usagePeriod?.id ?? null,
timeline,
webhookEvents,
},
};
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid billing workspace id.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to load admin billing workspace detail.' });
}
});
app.post('/billing/webhooks/stripe', async (request, reply) => {
const signature = request.headers['stripe-signature'];
if (!signature || Array.isArray(signature)) {
return reply.code(400).send({ error: 'Missing Stripe signature.' });
}
try {
if (typeof request.body !== 'string') {
return reply.code(400).send({ error: 'Expected raw Stripe webhook payload.' });
}
const event = constructStripeWebhookEvent(request.body, signature);
const result = await processStripeWebhookEvent(event);
return { received: true, status: result.status };
} catch (error) {
request.log.error(error);
return reply.code(400).send({ error: error instanceof Error ? error.message : 'Failed to process Stripe webhook.' });
}
});
};
+43
View File
@@ -6,6 +6,7 @@ import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getW
import { getDbPool } from '../db/pool.js';
import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, getDeepResearchBatchEstimate, listDeepResearchBatches } from '../deep-research/service.js';
import { previewDeepResearchForPoint } from '../postal/service.js';
import { recordAnalyticsEvent } from '../analytics/service.js';
const previewSchema = z.object({
lat: z.number().finite().min(-90).max(90),
@@ -53,6 +54,48 @@ export const deepResearchRoutes: FastifyPluginAsync = async (app) => {
});
if (!enforcement.allowed) {
try {
await recordAnalyticsEvent(db, {
eventName: 'quota_exhausted_blocked',
eventSource: 'api',
userId: request.authUser!.id,
workspaceId: enforcementContext.workspaceId,
planCode: enforcement.decision.currentPlanCode,
resource: enforcement.decision.resource,
metadata: {
denialReason: enforcement.decision.denialReason,
action: enforcement.decision.action,
resource: enforcement.decision.resource,
currentPlan: enforcement.decision.currentPlanCode,
suggestedPlan: enforcement.decision.suggestedUpgradePlanCode,
},
});
} catch (analyticsError) {
request.log.warn(analyticsError, 'Failed to record quota block analytics event.');
}
if (enforcement.decision.denialReason === 'feature_not_available' || enforcement.decision.denialReason === 'not_launch_ready') {
try {
await recordAnalyticsEvent(db, {
eventName: 'feature_gate_encountered',
eventSource: 'api',
userId: request.authUser!.id,
workspaceId: enforcementContext.workspaceId,
planCode: enforcement.decision.currentPlanCode,
resource: enforcement.decision.resource,
metadata: {
denialReason: enforcement.decision.denialReason,
action: enforcement.decision.action,
resource: enforcement.decision.resource,
currentPlan: enforcement.decision.currentPlanCode,
suggestedPlan: enforcement.decision.suggestedUpgradePlanCode,
},
});
} catch (analyticsError) {
request.log.warn(analyticsError, 'Failed to record feature-gate analytics event.');
}
}
const errorResponse = buildEntitlementErrorResponse(enforcement.decision);
return reply.code(errorResponse.statusCode).send(errorResponse.body);
}
+43
View File
@@ -6,6 +6,7 @@ import { getDbPool } from '../db/pool.js';
import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js';
import { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js';
import { runSearchForUser } from '../search/run-search.js';
import { recordAnalyticsEvent } from '../analytics/service.js';
const runSearchSchema = z.object({
name: z.string().trim().min(1).max(160).optional(),
@@ -41,6 +42,48 @@ export const searchJobRoutes: FastifyPluginAsync = async (app) => {
});
if (!enforcement.allowed) {
try {
await recordAnalyticsEvent(db, {
eventName: 'quota_exhausted_blocked',
eventSource: 'api',
userId: request.authUser!.id,
workspaceId: enforcementContext.workspaceId,
planCode: enforcement.decision.currentPlanCode,
resource: enforcement.decision.resource,
metadata: {
denialReason: enforcement.decision.denialReason,
action: enforcement.decision.action,
resource: enforcement.decision.resource,
currentPlan: enforcement.decision.currentPlanCode,
suggestedPlan: enforcement.decision.suggestedUpgradePlanCode,
},
});
} catch (analyticsError) {
request.log.warn(analyticsError, 'Failed to record quota block analytics event.');
}
if (enforcement.decision.denialReason === 'feature_not_available' || enforcement.decision.denialReason === 'not_launch_ready') {
try {
await recordAnalyticsEvent(db, {
eventName: 'feature_gate_encountered',
eventSource: 'api',
userId: request.authUser!.id,
workspaceId: enforcementContext.workspaceId,
planCode: enforcement.decision.currentPlanCode,
resource: enforcement.decision.resource,
metadata: {
denialReason: enforcement.decision.denialReason,
action: enforcement.decision.action,
resource: enforcement.decision.resource,
currentPlan: enforcement.decision.currentPlanCode,
suggestedPlan: enforcement.decision.suggestedUpgradePlanCode,
},
});
} catch (analyticsError) {
request.log.warn(analyticsError, 'Failed to record feature-gate analytics event.');
}
}
const errorResponse = buildEntitlementErrorResponse(enforcement.decision);
return reply.code(errorResponse.statusCode).send(errorResponse.body);
}