← 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);
}