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