Skip to contentVibraUI

Plan changes

Where the quarter's subscriptions moved: the plan each opened the quarter on against the plan it is on today, with New coming in and Churned going out, as a sankey, and how many moved and churned; reads planChanges().

Preview

Install

npx shadcn@latest add @vibra/widget-saas-subscriptions-plan-changes

Needs the @vibra registry in your components.json — set it up once.

Source

app/saas/subscriptions/components/plan-changes.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { CHART_TOKENS } from "@/components/ui/percentage-bar"
import { SankeyChart } from "@/components/ui/sankey-chart"

import { type PlanChangeTotals, type PlanFlows } from "../data"

const count = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

// The two stages that are not plans carry meaning, so they wear the tones:
// an arrival is new business, a cancellation is churn.
const TONES = new Map([
  ["New", "var(--chart-positive)"],
  ["Churned", "var(--chart-negative)"],
])

export type PlanChangesProps = {
  flows: PlanFlows
  totals: PlanChangeTotals
  /**
   * The plans as the plan mix ranks them, largest first. A plan wears the
   * chart slot it wears in the plan mix's donut, so the two read as one key,
   * while the columns keep the tiers' own order.
   */
  ranked: string[]
}

/** Where the quarter's subscriptions moved between plans, and how many arrived and left. */
export function PlanChanges({ flows, totals, ranked }: PlanChangesProps) {
  const plans = [
    ...ranked,
    ...flows.nodes.map((node) => node.label).filter((label) => !TONES.has(label) && !ranked.includes(label)),
  ]
  const nodes = flows.nodes.map((node) => ({
    ...node,
    color: TONES.get(node.label) ?? CHART_TOKENS[plans.indexOf(node.label) % CHART_TOKENS.length],
  }))

  return (
    <ChartCard
      data-widget="widget-saas-subscriptions-plan-changes"
      title="Plan changes"
      // No sides named: a right-to-left page reads the flow from the right.
      description="From a quarter ago to today, plan by plan"
      height={300}
      className="h-full"
      footer={`${count(totals.moved)} moved plans, ${count(totals.churned)} churned this quarter.`}
    >
      <SankeyChart nodes={nodes} links={flows.links} label="Plan changes" height={300} valueFormatter={count} />
    </ChartCard>
  )
}
app/saas/subscriptions/data.ts
/**
 * What /subscriptions reads. The book is `db.subscriptions`, one per account,
 * joined to `db.customers` for the workspace behind it and `db.plans` for the
 * tier it sits on. Movements are read off the rows' own dates — a subscription
 * that started inside the window is new, one that cancelled inside it is churn
 * — so opening plus new minus churn closes on the book exactly. Nothing here
 * reads a clock: every window ends at REFERENCE_DATE.
 */
import { getInitials } from "@/lib/format"
import {
  db,
  REFERENCE_DATE,
  seeded,
  type Member,
  type Subscription,
} from "@/lib/sample-data"

// The plans are reference rows nothing writes. The subscriptions and the
// accounts are read per call, never held at module scope: a cancellation or a
// new account since the server started is in the next book.
const PLANS = new Map(db.plans.all().map((plan) => [plan.id, plan]))
const subscriptions = (): Subscription[] => db.subscriptions.all()
const accountsById = () => new Map(db.customers.all().map((customer) => [customer.id, customer]))

// What a tier charges a seat a month, read off the accounts paying it today.
// A churned account's own `mrrCents` is zeroed the moment it churns, so the
// rate has to come from an account still on that tier — otherwise every
// cancellation would be worth nothing and churn would read as zero.
function seatPrices(): Map<string, number> {
  const prices = new Map<string, number>()
  for (const customer of db.customers.all()) {
    if (customer.mrrCents > 0 && !prices.has(customer.plan)) {
      prices.set(customer.plan, customer.mrrCents / customer.seats)
    }
  }
  return prices
}

/** What a subscription bills a month, in cents. A trial bills nothing. */
function billedCents(subscription: Subscription, prices: Map<string, number>): number {
  if (subscription.status === "trialing") return 0
  const tier = (PLANS.get(subscription.planId)?.name ?? "").toLowerCase()
  return subscription.seats * (prices.get(tier) ?? 0)
}

const DAY_MS = 86_400_000

const dollars = (cents: number): number => cents / 100

/** Midnight UTC on the first of the month `k` months before the current one. */
function monthStart(k: number): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}

/** Whether a subscription was on the book at an instant. */
function live(subscription: Subscription, at: Date): boolean {
  if (subscription.startedAt > at) return false
  return !subscription.cancelledAt || subscription.cancelledAt > at
}

/** The book at an instant, in dollars. */
function bookAt(at: Date): number {
  const prices = seatPrices()
  return dollars(
    subscriptions()
      .filter((subscription) => live(subscription, at))
      .reduce((total, subscription) => total + billedCents(subscription, prices), 0)
  )
}

/** How many months of movement the waterfall covers. */
export const WINDOW_MONTHS = 12

const OPENED_AT = monthStart(WINDOW_MONTHS)

/**
 * How the book got from one close to the next. Two movements, not the usual
 * four: an account moving up or down a tier leaves no trace in the rows, and
 * an invented expansion would need an invented contraction to cancel it — so
 * opening plus new minus churn closes on the book to the cent.
 */
export function mrrMovements(): { label: string; value: number; isTotal?: boolean }[] {
  let won = 0
  let lost = 0
  const prices = seatPrices()
  for (const subscription of subscriptions()) {
    if (subscription.startedAt > OPENED_AT) won += billedCents(subscription, prices)
    const { cancelledAt } = subscription
    if (cancelledAt && cancelledAt > OPENED_AT) lost += billedCents(subscription, prices)
  }

  return [
    { label: "Opening", value: Math.round(bookAt(OPENED_AT)) },
    { label: "New", value: Math.round(dollars(won)) },
    { label: "Churn", value: -Math.round(dollars(lost)) },
    { label: "Closing", value: 0, isTotal: true },
  ]
}

const liveNow = () => subscriptions().filter((subscription) => live(subscription, REFERENCE_DATE))
const trialing = () => liveNow().filter((subscription) => subscription.status === "trialing")

export type SubscriptionStat = {
  key: string
  label: string
  value: number
  description: string
  format: "currency" | "number"
}

/** The four headline numbers: what the book is worth, and who is on it. */
export function subscriptionStats(): SubscriptionStat[] {
  const prices = seatPrices()
  const book = subscriptions()
  const onBook = book.filter((subscription) => live(subscription, REFERENCE_DATE))
  const billing = onBook.filter((subscription) => billedCents(subscription, prices) > 0)
  const closing = bookAt(REFERENCE_DATE)

  return [
    {
      key: "mrr",
      label: "MRR",
      value: Math.round(closing),
      description: `across ${billing.length} paying subscriptions`,
      format: "currency",
    },
    {
      key: "live",
      label: "Live subscriptions",
      value: onBook.length,
      description: `of ${book.length} ever opened`,
      format: "number",
    },
    {
      key: "trials",
      label: "In trial",
      value: onBook.filter((subscription) => subscription.status === "trialing").length,
      description: "not billing yet",
      format: "number",
    },
    {
      key: "arpa",
      label: "Average account",
      value: billing.length > 0 ? Math.round(closing / billing.length) : 0,
      description: "a month, across the accounts that pay",
      format: "currency",
    },
  ]
}

/** How the live book splits across the four tiers, largest first. */
export function planMix(): { name: string; value: number }[] {
  const counts = new Map<string, number>()
  for (const subscription of liveNow()) {
    const name = PLANS.get(subscription.planId)?.name ?? "Unknown"
    counts.set(name, (counts.get(name) ?? 0) + 1)
  }
  return [...counts]
    .map(([name, value]) => ({ name, value }))
    .sort((a, b) => b.value - a.value)
}

/** How many subscriptions are on the book right now, for the middle of the donut. */
export function liveTotal(): number {
  return liveNow().length
}

/** The tiers from the cheapest up: a move "one tier away" is one step along this. */
const TIERS = [...PLANS.values()].sort((a, b) => a.priceMonthlyCents - b.priceMonthlyCents)

/** The three months the plan changes cover, ending at REFERENCE_DATE. */
const QUARTER_OPENED_AT = monthsAfter(REFERENCE_DATE, -3)

/** The share of the book that opened the quarter on the plan it is on now. */
const KEPT_PLAN = 0.8

/** The plan a roll of the stream opens a subscription on, given the plan it is on now. */
function openedOn(planId: string, roll: number): string {
  const at = TIERS.findIndex((plan) => plan.id === planId)
  if (at === -1 || roll < KEPT_PLAN) return planId
  const up = TIERS[at + 1]
  const down = TIERS[at - 1]
  // The rest split evenly between the tier above and the one below; a plan at
  // either end has only the one neighbour to have come from.
  const step = roll < KEPT_PLAN + (1 - KEPT_PLAN) / 2 ? (up ?? down) : (down ?? up)
  return step?.id ?? planId
}

let openingPlans: Map<string, string> | undefined

/**
 * The plan a subscription opened the quarter on. A row records only the plan
 * it is on now, so the one it was on three months ago is drawn from a stream
 * of its own — four in five the plan it is on now, the rest one tier away —
 * once, when a page first asks, never when the module loads, a draw for each
 * subscription on the book when the quarter opened, in the book's own order.
 * Only the draws are kept: a plan changed since then reads as a move, and a
 * subscription that started inside the quarter never draws one, because it
 * arrived as New.
 */
function openingPlan(subscription: Subscription): string {
  if (!openingPlans) {
    const rand = seeded("saas-subscriptions:plan-changes")
    openingPlans = new Map(
      subscriptions()
        .filter((row) => live(row, QUARTER_OPENED_AT))
        .map((row) => [row.id, openedOn(row.planId, rand())])
    )
  }
  return openingPlans.get(subscription.id) ?? subscription.planId
}

/**
 * Each subscription the quarter touched, by the plan it opened the quarter on
 * — null when it started inside the quarter — and the plan it is on today,
 * null when it was cancelled inside the quarter.
 */
function quarterMoves(): { from: string | null; to: string | null }[] {
  const moves: { from: string | null; to: string | null }[] = []
  for (const subscription of subscriptions()) {
    const opened = live(subscription, QUARTER_OPENED_AT)
    const arrived = !opened && subscription.startedAt > QUARTER_OPENED_AT && subscription.startedAt <= REFERENCE_DATE
    // Gone before the quarter opened, or not on the book yet.
    if (!opened && !arrived) continue
    moves.push({
      from: opened ? openingPlan(subscription) : null,
      to: live(subscription, REFERENCE_DATE) ? subscription.planId : null,
    })
  }
  return moves
}

// A plan stands once in each column, so its two stages need ids of their own;
// New and Churned stand in one column each.
const NEW = "new"
const CHURNED = "churned"
const opening = (planId: string) => `opened:${planId}`
const closing = (planId: string) => `now:${planId}`

/**
 * The stages and flows `SankeyChart` draws, in its own shape. Written out
 * rather than imported from the chart: every card of the page installs this
 * file, and an import of the chart's types would install the chart with each.
 */
export type PlanFlows = {
  nodes: { id: string; label: string }[]
  links: { source: string; target: string; value: number }[]
}

type Stage = PlanFlows["nodes"][number]
type Flow = PlanFlows["links"][number]

/**
 * Where the quarter's subscriptions went: on the left the plan each was on
 * when the quarter opened, and New for the ones that started inside it; on
 * the right the plan each is on today, and Churned for the ones cancelled
 * inside it. A subscription is one unit, so what leaves the left arrives on
 * the right. One flow per pair, in the order the stages stand — the tiers
 * from the cheapest up — and a stage only where a flow touches it.
 */
export function planChanges(): PlanFlows {
  const nodes: Stage[] = [
    ...TIERS.map((plan) => ({ id: opening(plan.id), label: plan.name })),
    { id: NEW, label: "New" },
    ...TIERS.map((plan) => ({ id: closing(plan.id), label: plan.name })),
    { id: CHURNED, label: "Churned" },
  ]
  const counts = new Map<string, Flow>()
  for (const { from, to } of quarterMoves()) {
    const source = from === null ? NEW : opening(from)
    const target = to === null ? CHURNED : closing(to)
    const key = `${source} ${target}`
    const link = counts.get(key) ?? { source, target, value: 0 }
    counts.set(key, { ...link, value: link.value + 1 })
  }
  const place = new Map(nodes.map((node, i) => [node.id, i]))
  const links = [...counts.values()].sort(
    (a, b) => place.get(a.source)! - place.get(b.source)! || place.get(a.target)! - place.get(b.target)!
  )
  const touched = new Set(links.flatMap((link) => [link.source, link.target]))
  return { nodes: nodes.filter((node) => touched.has(node.id)), links }
}

export type PlanChangeTotals = { moved: number; churned: number }

/** The line under the flows: how many moved between two plans, and how many churned. */
export function planChangeTotals(): PlanChangeTotals {
  let moved = 0
  let churned = 0
  for (const { from, to } of quarterMoves()) {
    if (to === null) churned += 1
    else if (from !== null && from !== to) moved += 1
  }
  return { moved, churned }
}

/** How many monthly cohorts the heatmap shows, and how far each is followed. */
export const COHORTS = 12

const COHORT_LABEL = new Intl.DateTimeFormat("en-US", {
  month: "short",
  year: "2-digit",
  timeZone: "UTC",
})

/** `months` months after `from`, on the same day of the month. */
function monthsAfter(from: Date, months: number): Date {
  return new Date(
    Date.UTC(
      from.getUTCFullYear(),
      from.getUTCMonth() + months,
      from.getUTCDate(),
      from.getUTCHours(),
      from.getUTCMinutes()
    )
  )
}

export type Cohorts = { rows: string[]; columns: string[]; values: number[][] }

/**
 * Retention by signup cohort. A cohort is the accounts that opened in one
 * month; a cell is the share of them still on the book that many months later.
 * A month a cohort has not lived through yet is left empty rather than counted
 * as a loss — the grid draws those as "no data".
 */
export function churnCohorts(): Cohorts {
  const starts = Array.from({ length: COHORTS }, (_, index) => monthStart(COHORTS - index))
  const book = subscriptions()

  return {
    rows: starts.map((start) => COHORT_LABEL.format(start)),
    columns: Array.from({ length: COHORTS }, (_, month) => `M${month}`),
    values: starts.map((start, index) => {
      const end = monthStart(COHORTS - index - 1)
      const cohort = book.filter(
        (subscription) => subscription.startedAt >= start && subscription.startedAt < end
      )

      return Array.from({ length: COHORTS }, (_, month) => {
        // Read at the close of the month, not its start: a cohort's own month
        // opens before its accounts exist, and "M0" is how many of them made it
        // to the end of the month they arrived in.
        const at = monthsAfter(start, month + 1)
        // The future has no retention to report.
        if (at > REFERENCE_DATE || cohort.length === 0) return Number.NaN
        const kept = cohort.filter((subscription) => live(subscription, at)).length
        return (kept / cohort.length) * 100
      })
    }),
  }
}

// The trial the data itself describes: every account in trial was opened
// somewhere in the last three weeks, so three weeks is the window it is on.
const TRIAL_DAYS = 21

export type Trial = {
  id: string
  company: string
  contact: string
  contactAvatar?: string
  plan: string
  seats: number
  endsAt: Date
  daysLeft: number
}

/** The trials closest to running out, soonest first. */
export function trialsExpiring(): Trial[] {
  const accounts = accountsById()
  return trialing()
    .map((subscription) => {
      const account = accounts.get(subscription.customerId)
      const endsAt = new Date(subscription.startedAt.getTime() + TRIAL_DAYS * DAY_MS)

      return {
        id: subscription.id,
        company: account?.company ?? subscription.customerId,
        contact: account?.name ?? "Unknown",
        contactAvatar: account?.avatarUrl,
        plan: PLANS.get(subscription.planId)?.name ?? "Unknown",
        seats: subscription.seats,
        endsAt,
        daysLeft: Math.max(0, Math.ceil((endsAt.getTime() - REFERENCE_DATE.getTime()) / DAY_MS)),
      }
    })
    .sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())
    .slice(0, 6)
}

export type SubscriptionRow = {
  id: string
  company: string
  plan: string
  status: Subscription["status"]
  seats: number
  mrr: number
  startedAt: Date
  renewsAt: Date
}

/** Every subscription ever opened, newest first. */
export function subscriptionRows(): SubscriptionRow[] {
  const accounts = accountsById()
  return subscriptions()
    .map((subscription) => ({
      id: subscription.id,
      company: accounts.get(subscription.customerId)?.company ?? subscription.customerId,
      plan: PLANS.get(subscription.planId)?.name ?? "Unknown",
      status: subscription.status,
      seats: subscription.seats,
      mrr: dollars(subscription.mrrCents),
      startedAt: subscription.startedAt,
      renewsAt: subscription.renewsAt,
    }))
    .sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

const MONTH_NAME = new Intl.DateTimeFormat("en-US", {
  month: "long",
  year: "numeric",
  timeZone: "UTC",
})

/** The window the movements cover. */
export function lastUpdated(): string {
  return `${WINDOW_MONTHS} months to ${MONTH_NAME.format(REFERENCE_DATE)}`
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Subscriptions dashboard page