5508e15da1
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
127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
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();
|
|
}
|