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`;
}