/** * Pure billing UX state derivation (#4771). * * Turns the two reactive client snapshots (Convex subscription row + * entitlement row) into one explicit customer-facing billing state, so the * UI can distinguish "we are verifying your renewal" from "you are a free * user" instead of showing a generic Upgrade CTA to a paying customer whose * local renewal evidence went stale (missed/exhausted webhook). * * Server-side counterparts: the on-demand Dodo re-check (#4770/#5447) writes * `subscriptions.renewalVerificationState`, and the gateway/MCP already emit * the matching stable codes (`renewal_verification_pending/failed`, * `subscription_lapsed`) via `server/_shared/entitlement-check.ts`. * * MUST stay a zero-import leaf: it is unit-tested under `tsx --test` * (no jsdom, no Vite globals), and both services and components import it. */ export interface BillingSubscriptionSnapshot { status: 'active' | 'on_hold' | 'cancelled' | 'expired'; /** Epoch ms end of the currently-paid period. */ currentPeriodEnd: number; /** Verdict of the request-path renewal verification (#4770), if any. */ renewalVerificationState?: 'pending' | 'failed' | 'lapsed' | null; } export interface BillingEntitlementSnapshot { planKey: string; /** Epoch ms until which the entitlement row grants access (0 = never). */ validUntil: number; } export type BillingUxState = | 'free' | 'active' | 'on_hold' | 'renewal_verification_pending' | 'renewal_verification_failed' | 'lapsed'; /** * Precedence, mirroring the affirmative-denial philosophy of panel gating * (never over-gate on missing data): * * 1. `on_hold` always surfaces — the payment-failed banner must show even * while the retry-window entitlement is still valid. * 2. A currently-valid paid entitlement means access works: `active`. * 3. No subscription row and no valid entitlement: plain `free`. * 4. An `active` subscription row without a valid entitlement is *stale paid * evidence*: the verification verdict decides (`failed`/`lapsed`), an * in-period row stays `active` (entitlement snapshot late/skipped), and a * past-period row is `renewal_verification_pending` — reconciliation is * queued (#4794) or in flight (#4770) even when no verdict is recorded yet. * 5. `cancelled` still inside its paid window keeps coverage (`active`) even * when the entitlement snapshot is late — mirrors `isCoveringAt` in * convex/payments/subscriptionHelpers.ts ("cancelled-but-paid-through"). * `cancelled` past the window and `expired` (never covering, same helper): * provider-confirmed end of coverage — `lapsed`, not `free`, so copy can * say "resubscribe". */ export function deriveBillingUxState( sub: BillingSubscriptionSnapshot | null, ent: BillingEntitlementSnapshot | null, now: number, ): BillingUxState { const entitledNow = ent !== null && ent.planKey !== 'free' && ent.validUntil >= now; if (!sub) return entitledNow ? 'active' : 'free'; if (sub.status === 'on_hold') return 'on_hold'; if (entitledNow) return 'active'; if (sub.status === 'active') { if (sub.renewalVerificationState === 'failed') return 'renewal_verification_failed'; if (sub.renewalVerificationState === 'lapsed') return 'lapsed'; if (sub.currentPeriodEnd >= now) return 'active'; return 'renewal_verification_pending'; } if (sub.status === 'cancelled' && sub.currentPeriodEnd >= now) return 'active'; return 'lapsed'; } // Per-state sessionStorage dismissal keys. Distinct keys so dismissing the // pending banner never suppresses a later failed banner. The bare // 'pf-banner-dismissed' value predates #4771 and must stay unchanged so // in-flight sessions keep their on_hold dismissal. const ON_HOLD_DISMISS_KEY = 'pf-banner-dismissed'; const RENEWAL_PENDING_DISMISS_KEY = 'pf-banner-dismissed-renewal-pending'; const RENEWAL_FAILED_DISMISS_KEY = 'pf-banner-dismissed-renewal-failed'; /** Every dismissal key, for clearing when billing state recovers. */ export const BILLING_BANNER_DISMISS_KEYS: readonly string[] = [ ON_HOLD_DISMISS_KEY, RENEWAL_PENDING_DISMISS_KEY, RENEWAL_FAILED_DISMISS_KEY, ]; export interface BillingBannerVariant { tone: 'error' | 'warning'; /** i18n key (components.billingState.*) for the banner message. */ messageKey: string; /** i18n key for the action button label; null renders no action button. */ actionLabelKey: string | null; /** What the action button does. Only present when actionLabelKey is set. */ action?: 'billing-portal'; /** sessionStorage key scoping manual dismissal to this state. */ dismissKey: string; } /** * Top-of-page banner content per state, as i18n keys — the same * components.billingState.* strings the panel CTA uses, so the two surfaces * cannot drift and the banner localizes. `on_hold` must stay compatible with * the pre-#4771 payment-failure banner (issue #4771 requires it intact): * onHoldBannerMessage's English value is byte-identical to the previously * hardcoded copy, and the dismiss key is unchanged. `lapsed` intentionally * renders no persistent banner: the panel CTA carries the resubscribe * message, and a permanent banner for long-lapsed users would just be * nagware. */ export function getBillingBannerVariant(state: BillingUxState): BillingBannerVariant | null { switch (state) { case 'on_hold': return { tone: 'error', messageKey: 'components.billingState.onHoldBannerMessage', actionLabelKey: 'components.billingState.updatePayment', action: 'billing-portal', dismissKey: ON_HOLD_DISMISS_KEY, }; case 'renewal_verification_pending': return { tone: 'warning', messageKey: 'components.billingState.renewalPendingDesc', actionLabelKey: null, dismissKey: RENEWAL_PENDING_DISMISS_KEY, }; case 'renewal_verification_failed': return { tone: 'error', messageKey: 'components.billingState.renewalFailedDesc', actionLabelKey: 'components.billingState.manageBilling', action: 'billing-portal', dismissKey: RENEWAL_FAILED_DISMISS_KEY, }; default: return null; } } export type BillingGateOverride = | 'payment_on_hold' | 'renewal_pending' | 'renewal_failed' | 'lapsed'; /** * Which billing-specific gate reason (if any) should replace the generic * FREE_TIER "Upgrade to Pro" CTA for a locked premium panel. Values mirror * the PanelGateReason string enum in panel-gating.ts (kept as plain strings * here so this module stays a leaf). */ export function getBillingGateOverride(state: BillingUxState): BillingGateOverride | null { switch (state) { case 'on_hold': return 'payment_on_hold'; case 'renewal_verification_pending': return 'renewal_pending'; case 'renewal_verification_failed': return 'renewal_failed'; case 'lapsed': return 'lapsed'; default: return null; } } /** * Build the pricing link used by returning subscribers. The plan key only * controls the pricing page's monthly/annual preference; checkout still * resolves the product from the live pricing catalog. */ export function getReactivationHref(planKey?: string | null): string { const planParam = planKey ? `?wm_reactivate_plan=${encodeURIComponent(planKey)}` : ''; return `/pro${planParam}#pricing`; }