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:
@@ -0,0 +1,30 @@
|
||||
export type AnalyticsEventName =
|
||||
| 'pricing_plan_selected'
|
||||
| 'checkout_started'
|
||||
| 'checkout_completed'
|
||||
| 'checkout_canceled'
|
||||
| 'portal_opened'
|
||||
| 'quota_warning_shown'
|
||||
| 'quota_exhausted_blocked'
|
||||
| 'feature_gate_encountered'
|
||||
| 'addon_checkout_started'
|
||||
| 'addon_purchase_completed'
|
||||
| 'plan_changed'
|
||||
| 'payment_failed'
|
||||
| 'subscription_canceled';
|
||||
|
||||
export type AnalyticsEventSource = 'web_app' | 'api' | 'stripe_webhook' | 'system';
|
||||
|
||||
export interface AnalyticsEventInput {
|
||||
eventName: AnalyticsEventName;
|
||||
eventSource: AnalyticsEventSource;
|
||||
userId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
planCode?: string | null;
|
||||
addonCode?: string | null;
|
||||
resource?: string | null;
|
||||
amount?: number | null;
|
||||
currency?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
occurredAt?: string;
|
||||
}
|
||||
@@ -24,7 +24,15 @@ export type EntitlementDecisionStatus =
|
||||
| 'blocked_addon_available'
|
||||
| 'contact_sales_required';
|
||||
|
||||
export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready' | 'billing_not_configured';
|
||||
export type EntitlementDenialReason =
|
||||
| 'feature_not_available'
|
||||
| 'quota_exhausted'
|
||||
| 'custom_enterprise_only'
|
||||
| 'not_launch_ready'
|
||||
| 'billing_not_configured'
|
||||
| 'billing_past_due'
|
||||
| 'billing_canceled'
|
||||
| 'billing_inactive';
|
||||
|
||||
export interface UsageSubject {
|
||||
type: UsageSubjectType;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { AccountBillingStatus } from '../types.js';
|
||||
import type { PlanCode } from './plans.js';
|
||||
|
||||
export type BillingAccessMode = 'full' | 'degraded' | 'blocked';
|
||||
|
||||
export type BillingSyncStatus = 'ok' | 'stale' | 'error';
|
||||
|
||||
export type BillingTimelineEventType =
|
||||
| 'checkout_completed'
|
||||
| 'subscription_created'
|
||||
| 'subscription_updated'
|
||||
| 'subscription_deleted'
|
||||
| 'invoice_paid'
|
||||
| 'invoice_payment_failed'
|
||||
| 'portal_returned'
|
||||
| 'checkout_returned'
|
||||
| 'addon_purchased'
|
||||
| 'billing_status_changed'
|
||||
| 'plan_change_scheduled';
|
||||
|
||||
export interface BillingLifecycleState {
|
||||
status: AccountBillingStatus;
|
||||
currentPeriodEndsAt: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
gracePeriodEndsAt: string | null;
|
||||
}
|
||||
|
||||
export interface BillingAccessResolution {
|
||||
accessMode: BillingAccessMode;
|
||||
shouldWarn: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_BILLING_GRACE_PERIOD_DAYS = 7;
|
||||
export const DEFAULT_BILLING_SYNC_STALE_AFTER_HOURS = 24;
|
||||
|
||||
export function getDefaultBillingGracePeriodEndsAt(referenceDate: Date) {
|
||||
const nextDate = new Date(referenceDate);
|
||||
nextDate.setUTCDate(nextDate.getUTCDate() + DEFAULT_BILLING_GRACE_PERIOD_DAYS);
|
||||
return nextDate.toISOString();
|
||||
}
|
||||
|
||||
export function resolveBillingAccessState(state: BillingLifecycleState, referenceDate = new Date()): BillingAccessResolution {
|
||||
switch (state.status) {
|
||||
case 'active':
|
||||
if (state.cancelAtPeriodEnd && state.currentPeriodEndsAt) {
|
||||
return {
|
||||
accessMode: 'full',
|
||||
shouldWarn: true,
|
||||
message: `Your subscription is scheduled to cancel on ${formatLifecycleDate(state.currentPeriodEndsAt)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accessMode: 'full',
|
||||
shouldWarn: false,
|
||||
message: null,
|
||||
};
|
||||
case 'past_due': {
|
||||
if (state.gracePeriodEndsAt) {
|
||||
const graceEndsAt = new Date(state.gracePeriodEndsAt);
|
||||
if (!Number.isNaN(graceEndsAt.getTime()) && referenceDate <= graceEndsAt) {
|
||||
return {
|
||||
accessMode: 'degraded',
|
||||
shouldWarn: true,
|
||||
message: `Payment is overdue. Update billing before ${formatLifecycleDate(state.gracePeriodEndsAt)} to avoid blocked usage.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessMode: 'blocked',
|
||||
shouldWarn: true,
|
||||
message: 'Payment is overdue and the grace period has ended. Chargeable actions are blocked until billing is resolved.',
|
||||
};
|
||||
}
|
||||
case 'inactive':
|
||||
return {
|
||||
accessMode: 'blocked',
|
||||
shouldWarn: true,
|
||||
message: 'Billing is inactive. Start or restore a subscription to resume chargeable actions.',
|
||||
};
|
||||
case 'canceled':
|
||||
return {
|
||||
accessMode: 'blocked',
|
||||
shouldWarn: true,
|
||||
message: 'This subscription is canceled. Reactivate billing to resume chargeable actions.',
|
||||
};
|
||||
case 'not_configured':
|
||||
return {
|
||||
accessMode: 'blocked',
|
||||
shouldWarn: true,
|
||||
message: 'Billing is not configured for this workspace yet.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isBillingSyncStale(lastStripeSyncAt: string | null, referenceDate = new Date()) {
|
||||
if (!lastStripeSyncAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const syncedAt = new Date(lastStripeSyncAt);
|
||||
if (Number.isNaN(syncedAt.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const staleAfterMs = DEFAULT_BILLING_SYNC_STALE_AFTER_HOURS * 60 * 60 * 1000;
|
||||
return referenceDate.getTime() - syncedAt.getTime() > staleAfterMs;
|
||||
}
|
||||
|
||||
export function getPendingPlanChangeMessage(pendingPlanCode: PlanCode | null, pendingPlanEffectiveAt: string | null) {
|
||||
if (!pendingPlanCode || !pendingPlanEffectiveAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `Plan change to ${pendingPlanCode} takes effect on ${formatLifecycleDate(pendingPlanEffectiveAt)}.`;
|
||||
}
|
||||
|
||||
export function formatLifecycleDate(value: string | null) {
|
||||
if (!value) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
+102
@@ -1,5 +1,6 @@
|
||||
import type { AddonCode, BillingInterval, PlanCode } from './billing/plans.js';
|
||||
import type { UsageAllowanceAvailability, UsageResource } from './billing/entitlements.js';
|
||||
import type { BillingSyncStatus, BillingTimelineEventType } from './billing/lifecycle.js';
|
||||
|
||||
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
|
||||
|
||||
@@ -50,6 +51,34 @@ export interface BillingAddonBalanceSummary {
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface BillingWebhookEventSummary {
|
||||
id: string;
|
||||
provider: 'stripe';
|
||||
externalEventId: string;
|
||||
eventType: string;
|
||||
status: 'received' | 'processed' | 'failed' | 'ignored';
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
errorMessage: string | null;
|
||||
receivedAt: string;
|
||||
processedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BillingTimelineEventSummary {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
billingAccountId: string | null;
|
||||
eventType: BillingTimelineEventType;
|
||||
source: 'stripe' | 'app' | 'system';
|
||||
payloadJson: Record<string, unknown>;
|
||||
externalEventId: string | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
occurredAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AccountBillingState {
|
||||
status: AccountBillingStatus;
|
||||
planCode: PlanCode | null;
|
||||
@@ -57,11 +86,83 @@ export interface AccountBillingState {
|
||||
currentPeriodStartsAt: string | null;
|
||||
currentPeriodEndsAt: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
canceledAt?: string | null;
|
||||
trialEndsAt?: string | null;
|
||||
gracePeriodEndsAt: string | null;
|
||||
pendingPlanCode: PlanCode | null;
|
||||
pendingPlanEffectiveAt: string | null;
|
||||
billingSyncStatus: BillingSyncStatus;
|
||||
lastStripeSyncAt: string | null;
|
||||
provider: 'stripe' | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
usage: BillingUsageResourceSummary[];
|
||||
addonBalances: BillingAddonBalanceSummary[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BillingCheckoutSessionResponse {
|
||||
checkoutUrl: string;
|
||||
}
|
||||
|
||||
export interface BillingPortalSessionResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BillingDebugData {
|
||||
events: BillingWebhookEventSummary[];
|
||||
}
|
||||
|
||||
export interface BillingAdminWorkspaceSummary {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
workspaceType: WorkspaceType;
|
||||
memberCount: number;
|
||||
status: AccountBillingStatus;
|
||||
planCode: PlanCode | null;
|
||||
billingInterval: BillingInterval | null;
|
||||
currentPeriodEndsAt: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
gracePeriodEndsAt: string | null;
|
||||
pendingPlanCode: PlanCode | null;
|
||||
pendingPlanEffectiveAt: string | null;
|
||||
billingSyncStatus: BillingSyncStatus;
|
||||
lastStripeSyncAt: string | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
}
|
||||
|
||||
export interface BillingAdminWorkspaceDetail {
|
||||
summary: BillingAdminWorkspaceSummary;
|
||||
billing: AccountBillingState;
|
||||
usagePeriodId: string | null;
|
||||
timeline: BillingTimelineEventSummary[];
|
||||
webhookEvents: BillingWebhookEventSummary[];
|
||||
}
|
||||
|
||||
export interface BillingAdminWorkspaceListResponse {
|
||||
workspaces: BillingAdminWorkspaceSummary[];
|
||||
}
|
||||
|
||||
export interface BillingAdminWorkspaceDetailResponse {
|
||||
workspace: BillingAdminWorkspaceDetail;
|
||||
}
|
||||
|
||||
export interface AnalyticsMetricBucket {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AdminAnalyticsSummary {
|
||||
pricingConversionByPlan: AnalyticsMetricBucket[];
|
||||
quotaExhaustionEvents: AnalyticsMetricBucket[];
|
||||
upgradeTriggers: AnalyticsMetricBucket[];
|
||||
addonAttach: AnalyticsMetricBucket[];
|
||||
planMix: AnalyticsMetricBucket[];
|
||||
churnSignals: AnalyticsMetricBucket[];
|
||||
expansionSignals: AnalyticsMetricBucket[];
|
||||
}
|
||||
|
||||
export interface AccountTeamPlaceholder {
|
||||
canManageMembers: boolean;
|
||||
message: string;
|
||||
@@ -73,6 +174,7 @@ export interface AccountPageData {
|
||||
summary: AccountSummary;
|
||||
billing: AccountBillingState;
|
||||
team: AccountTeamPlaceholder;
|
||||
isBillingAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateAccountProfileRequest {
|
||||
|
||||
Reference in New Issue
Block a user