// Pure billing/tier data and logic, safe to import from client components // (unlike app/services/billing.server.ts, which does DB I/O and must not be // bundled client-side — see the currency.ts precedent for why this split // exists). Plan names/prices here are the single source of truth for both // shopify.server.ts's billing config and services/billing.server.ts's // AppSubscription-name-to-Tier mapping. export type Tier = "free" | "starter" | "growth" | "pro"; export const STARTER_PLAN = "Starter"; export const GROWTH_PLAN = "Growth"; export const PRO_PLAN = "Pro"; export interface PlanPricing { tier: Exclude; name: string; amount: number; currencyCode: "USD"; trialDays: number; } // Prices from PRODUCT_STRATEGY.md §6. Free has no entry: Shopify's Billing // API has no concept of a $0 plan, it's just "no active subscription". export const PLAN_PRICING: PlanPricing[] = [ { tier: "starter", name: STARTER_PLAN, amount: 9.99, currencyCode: "USD", trialDays: 14 }, { tier: "growth", name: GROWTH_PLAN, amount: 19.99, currencyCode: "USD", trialDays: 14 }, { tier: "pro", name: PRO_PLAN, amount: 39.99, currencyCode: "USD", trialDays: 14 }, ]; const TIER_RANK: Record = { free: 0, starter: 1, growth: 2, pro: 3 }; // Location-count ceiling per tier, per PRODUCT_STRATEGY.md §6. null = unlimited. export const LOCATION_LIMITS: Record = { free: 1, starter: 3, growth: null, pro: null, }; export function tierAtLeast(tier: Tier, min: Tier): boolean { return TIER_RANK[tier] >= TIER_RANK[min]; } export function locationLimitFor(tier: Tier): number | null { return LOCATION_LIMITS[tier]; } // Inverse of PLAN_PRICING's names — maps an AppSubscription's `name` (from // billing.check()/the app_subscriptions/update webhook) back to our // internal Tier. Any unrecognized or absent subscription is "free". export function tierFromPlanName(name: string | undefined | null): Tier { const match = PLAN_PRICING.find((p) => p.name === name); return match?.tier ?? "free"; }