← back to Charge And Explore

backend/src/core/tiers.ts

79 lines

// Tier → entitlement map for Charge & Explore billing (TK-10227).
// Pure data + pure functions — no Stripe, no I/O — so the gating rules are
// unit-testable and the server/webhook/UI all read ONE source of truth.
//
// FREE  = 7-day history, 1 vehicle's stats.
// PLUS  = 90-day history, all vehicles.               ($4.99/mo placeholder)
// PRO   = full history + parked/phantom-drain analytics + priority sampling.
//                                                     ($9.99/mo placeholder)

export type Tier = "free" | "plus" | "pro";
export const TIER_ORDER: readonly Tier[] = ["free", "plus", "pro"];

export const PAID_TIERS = ["plus", "pro"] as const;
export type PaidTier = (typeof PAID_TIERS)[number];

export interface Entitlements {
  label: string;
  priceCentsMonthly: number;   // placeholder pricing until Steve sets real prices
  historyDays: number | null;  // null = unlimited (full history)
  maxVehicles: number | null;  // null = all vehicles on the account
  parkedAnalytics: boolean;    // parked periods / phantom-drain view
  prioritySampling: boolean;   // background sampler prefers these accounts
}

export const TIERS: Record<Tier, Entitlements> = {
  free: { label: "Free", priceCentsMonthly: 0,   historyDays: 7,    maxVehicles: 1,    parkedAnalytics: false, prioritySampling: false },
  plus: { label: "Plus", priceCentsMonthly: 499, historyDays: 90,   maxVehicles: null, parkedAnalytics: false, prioritySampling: false },
  pro:  { label: "Pro",  priceCentsMonthly: 999, historyDays: null, maxVehicles: null, parkedAnalytics: true,  prioritySampling: true },
};

export function isPaidTier(x: unknown): x is PaidTier {
  return x === "plus" || x === "pro";
}

// Anything unknown/absent degrades to "free" — never throws on bad data.
export function normalizeTier(x: unknown): Tier {
  return x === "plus" || x === "pro" ? x : "free";
}

export function entitlementsFor(tier: Tier): Entitlements {
  return TIERS[tier];
}

export function tierAtLeast(tier: Tier, required: Tier): boolean {
  return TIER_ORDER.indexOf(tier) >= TIER_ORDER.indexOf(required);
}

// Clamp a requested history window to the tier's allowance (hours / days forms
// for the two endpoint shapes). Unlimited tiers pass the request through.
export function clampHistoryHours(tier: Tier, requestedHours: number): number {
  const days = TIERS[tier].historyDays;
  return days == null ? requestedHours : Math.min(requestedHours, days * 24);
}
export function clampHistoryDays(tier: Tier, requestedDays: number): number {
  const days = TIERS[tier].historyDays;
  return days == null ? requestedDays : Math.min(requestedDays, days);
}

// How many of the account's vehicles this tier may see (Infinity = all).
export function vehicleLimit(tier: Tier): number {
  return TIERS[tier].maxVehicles ?? Infinity;
}

// Map a Stripe subscription-shaped object to the tier it grants. Pure (works on
// plain webhook JSON, no SDK) so the webhook mapping is unit-testable. Reads
// the ce_tier metadata we stamp on the subscription at checkout, falling back
// to the price's metadata; anything not active/trialing grants "free".
export interface StripeSubscriptionLike {
  status?: string;
  metadata?: Record<string, string>;
  items?: { data?: Array<{ price?: { metadata?: Record<string, string> } }> };
}
export function tierFromStripeObject(obj: StripeSubscriptionLike | null | undefined): Tier {
  if (!obj) return "free";
  const active = obj.status === undefined || obj.status === "active" || obj.status === "trialing";
  if (!active) return "free";
  return normalizeTier(obj.metadata?.ce_tier ?? obj.items?.data?.[0]?.price?.metadata?.ce_tier);
}