A workspace subscribes to **one plan** of your application at a time. That
subscription is what every entitlement check resolves through, what every
invoice line points at, and what a cancellation ends.

## A plan is a tier *and* a price point

This is the first thing that surprises people arriving from Stripe, Paddle or
Chargebee. All three split a product in two — a Product, and one or more Prices
attached to it. Connect keeps them in **one row**:

```
Growth (monthly)  ₹2,000 / month   planGroup: "growth"
Growth (annual)   ₹20,000 / year   planGroup: "growth"
```

Two `Plan` rows. `Subscription.planId`, every invoice line and every
entitlement check point at exactly one of them, which is what makes those
lookups a single read with no join.

`planGroup` is what recovers the missing half. Two plans declaring the same
group are the same tier at two cadences, and
`(applicationId, planGroup, billingInterval)` is unique among active plans — so
a tier can have at most one monthly and one annual row.

<Callout type="warn">
Do not pair plans by `name`. It is not unique, it does not survive a rename,
and cloning a plan produces `"Growth (Copy)"`. `planGroup` is the only correct
pairing key. Rows written before the field existed fall back to a slug of
`name`, so nothing needed a backfill — but new code should never rely on that
fallback.
</Callout>

## Reading plans

```ts
import { ConnectClient } from "@cosmoops/connect-sdk/server";

const connect = new ConnectClient({ apiKey: process.env.CONNECT_API_KEY! });

const plans = await connect.plans.list();
```

The client components do this for you — `<Plans />` renders the grouped tiers
with a monthly/annual toggle, and the toggle is driven by `planGroup` rather
than by a naming convention.

## Free plans

A free plan is a plan whose price is zero. There is no separate concept and no
flag, and that is deliberate: a free tier subscribes, appears in
`subscriptions`, carries `features`, and shows up in entitlement checks exactly
like a paid one. Code that gates on a feature key needs no special case, and a
customer moving from free to paid is an ordinary upgrade rather than a first
subscription.

Two consequences worth designing around:

- **A free plan still needs its features declared.** An empty `features` array
  unlocks nothing. "Free" is a price, not a permission set.
- **A free plan changes what onboarding should ask for.** With no free tier,
  every customer must reach a payment method before they can use anything, so
  the payment step is worth making `required`. With one, demanding a card up
  front loses sign-ups for nothing. The Console's application wizard makes
  exactly this recommendation, for exactly this reason.

### Free plans versus trials

They are different mechanisms and they solve different problems.

| | Free plan | Trial |
| --- | --- | --- |
| Price | ₹0, indefinitely | The plan's real price, deferred |
| Ends | Never | After `trialDays` |
| Needs a payment method | No | Yes — the first charge lands when it ends |
| Modelled as | A plan | A field on a plan |

Use a trial to let somebody evaluate a paid tier. Use a free plan when the free
thing is a product you intend to keep running.

## Changing plan

There are **three** kinds of change, not two, and the third is the one
integrations forget:

- **Upgrade** — a higher-priced plan in the same cadence. Prorated and charged
  immediately.
- **Downgrade** — a lower-priced plan in the same cadence. The prorated
  difference is credited, refunded, or dropped, according to the application's
  downgrade policy.
- **Cadence change** — the same tier, the other cadence. Neither of the above.

`classifyPlanChange` returns `switch-to-annual` / `switch-to-monthly` for the
third, and it routes to its own path: the running cycle is closed, what is left
of it is valued, a fresh cycle is billed, and the billing anchor moves.

<Callout type="warn">
Sending a cadence change through the upgrade or downgrade path prorates a delta
across two different cadences, which is negative either way, floors to zero, and
settles the whole switch for nothing while leaving the old cycle boundary in
place. If you are computing "is this an upgrade" yourself by comparing prices,
you have this bug.
</Callout>

Surplus from cutting a long annual cycle short is always **credited**, never
refunded, whatever the downgrade policy says. The customer keeps the same
product, so there is nothing to compensate — only a balance to carry.

## Limits on a plan

A plan gives each of your application's declared limits a number: none, a
ceiling, or unlimited. This is how one tier allows a single site and the next
allows five.

```ts
if (!(await connect.entitlements.canAdd(workspaceId, "sites", siteCount))) {
  return refuse("Your plan's site limit is reached.");
}
```

The catalogue read carries the labels too, so a pricing table can render
"1 site" beside "5 sites" without hardcoding either — including the tiers that
allow none, which is what makes the comparison legible. See
[Capabilities](/docs/concepts/capabilities) for the three states and the one
way to get the comparison wrong.

## Add-ons on a plan

A plan decides **whether** to offer an add-on and **at what price and included
quantity**. It does not decide how the add-on behaves — see
[Capabilities](/docs/concepts/capabilities).

## What to build against

- Gate features on the **feature key**, resolved through the subscription, never
  on the plan's name or id. Names change; a customer on a legacy tier still has
  the entitlement.
- Read quantities from the **limit key**, the same way, and never infer one from
  the tier's name. Encoding a number as three mutually-exclusive feature keys
  (`sites_1`, `sites_5`, `sites_unlimited`) is the shape limits replace: no card
  can compare them and no backend can read them as a quantity.
- Treat the plan id as opaque. It differs per environment.
- Re-read readiness rather than caching a subscription indefinitely — the pull
  channel is authoritative, [webhooks](/docs/webhooks) are the fast channel.