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 `A plan change to ${pendingPlanCode} is scheduled for ${formatLifecycleDate(pendingPlanEffectiveAt)}. If usage is above the new limits after that date, chargeable actions may pause until usage resets or additional capacity is added.`; } export function formatLifecycleDate(value: string | null) { if (!value) { return 'Not set'; } return new Date(value).toLocaleDateString(); }