{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "subscription-state",
  "title": "Subscription state",
  "author": "PumpGTM (https://pumpgtm.com)",
  "description": "One function that turns a Stripe subscription into the product decision: needs card, trialing, cancelling but still running, paused, past due, or ended. Scheduled cancellations keep access until Stripe ends them.",
  "files": [
    {
      "path": "registry/pumpgtm/subscription-state/subscription-state.ts",
      "content": "// The one place a Stripe subscription becomes a product decision. Every gate\n// (feature access, the billing wall, the cancel button) should read this\n// instead of comparing `status` strings on its own.\n\nexport type SubscriptionState =\n  | \"needs_card\" // no subscription, checkout unfinished, or a trial ended without a card\n  | \"trialing\"\n  | \"trial_canceling\" // cancelled during the trial: the trial still runs to its end\n  | \"active\"\n  | \"canceling\" // cancelled while paid: the paid period still runs to its end\n  | \"paused\" // collection paused as a save offer; resumes on its own\n  | \"past_due\"\n  | \"ended\";\n\n// The fields this needs, so a stored copy of the subscription works as well as\n// a live Stripe object. Stripe timestamps are seconds.\nexport interface SubscriptionSnapshot {\n  status: string;\n  cancel_at_period_end: boolean;\n  cancel_at: number | null;\n  trial_end: number | null;\n  pause_collection?: { resumes_at: number | null } | null;\n}\n\nexport function subscriptionState(\n  subscription: SubscriptionSnapshot | null | undefined,\n  now: Date = new Date(),\n): SubscriptionState {\n  if (!subscription) return \"needs_card\";\n  const nowSeconds = now.getTime() / 1000;\n  const scheduledToCancel =\n    subscription.cancel_at_period_end || subscription.cancel_at != null;\n  switch (subscription.status) {\n    case \"trialing\":\n      // A stored copy can outlive the trial it describes.\n      if (subscription.trial_end != null && subscription.trial_end <= nowSeconds) return \"ended\";\n      return scheduledToCancel ? \"trial_canceling\" : \"trialing\";\n    case \"active\":\n      if (subscription.pause_collection) return \"paused\";\n      return scheduledToCancel ? \"canceling\" : \"active\";\n    // `unpaid` is a past-due subscription Stripe stopped retrying. It still owes\n    // money and still has to be cancellable.\n    case \"past_due\":\n    case \"unpaid\":\n      return \"past_due\";\n    // `paused` is Stripe's status for a trial that ended with no payment method.\n    case \"incomplete\":\n    case \"paused\":\n      return \"needs_card\";\n    default:\n      // canceled, incomplete_expired\n      return \"ended\";\n  }\n}\n\n// A scheduled cancellation keeps access until Stripe ends the subscription:\n// people cancel minutes after starting a trial so they are not charged, and\n// the trial they were promised must still work.\nexport function hasAccess(state: SubscriptionState): boolean {\n  return (\n    state === \"trialing\" ||\n    state === \"trial_canceling\" ||\n    state === \"active\" ||\n    state === \"canceling\"\n  );\n}\n\n// Who may open the cancel flow. Someone already cancelling resumes instead.\n// A failed payment must never trap anyone: past_due can always cancel.\nexport function canCancel(state: SubscriptionState): boolean {\n  return state === \"trialing\" || state === \"active\" || state === \"paused\" || state === \"past_due\";\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:lib"
}