File size: 7,414 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | /**
* 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`;
}
|