GenerAI / worldmonitor /src /services /billing-state.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 7)
9d2d895 verified
Raw
History Blame Contribute Delete
7.41 kB
/**
* 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`;
}