# Plan Features Matrix

Single source of truth for which features each tier unlocks, how Stripe price IDs map to tiers, and which files must be updated together when adding a plan or gating a feature. All data here is derived from code; it is **not** the marketing page.

## Tier hierarchy

Tiers are defined as a string literal union in [`lib/plan-features.ts:8`](../lib/plan-features.ts):

```ts
export type TierName = 'starter' | 'growth' | 'enhanced' | 'professional';
```

Order (lowest to highest) is [`TIER_ORDER` at `lib/plan-features.ts:190`](../lib/plan-features.ts):

```
starter → growth → enhanced → professional
```

`HIGHEST_TIER` ([`lib/plan-features.ts:195`](../lib/plan-features.ts)) is `'professional'`, used for blog-visibility gating in [`handleTierUpgrade()` at `lib/tier-handlers.ts:140`](../lib/tier-handlers.ts).

Helpers in `lib/plan-features.ts`:

- `getTierRank(tier)` - line 200, returns 1–4
- `isUpgrade(current, target)` / `isDowngrade(...)` / `isSidegrade(...)` - lines 208, 215, 222
- `canDowngrade(tier)` - line 251
- `getFeaturesGained(current, target)` / `getFeaturesLost(...)` - lines 239, 229
- `isValidTier(s)` - line 265, type guard for runtime validation

Prisma stores tier as a `String` on `Dealer.subscriptionTier` ([`prisma/schema.prisma:105`](../prisma/schema.prisma)). There is **no DB enum** - validation happens in the application layer via `isValidTier()`.

## Feature matrix

Two independent matrices exist in code. They must stay consistent.

### Editor / access gates (from `lib/cms/types.ts`)

These drive runtime authorization - whether a given API call or UI element is allowed for a dealer at a given tier.

| Feature                       | Constant (`lib/cms/types.ts`)   | Starter | Growth | Enhanced | Professional | Accessor                                     |
| ----------------------------- | ------------------------------- | :-----: | :----: | :------: | :----------: | -------------------------------------------- |
| Media library                 | `MEDIA_LIBRARY_TIERS` (line 34) |   yes   |  yes   |   yes    |     yes      | `hasMediaLibraryAccess()` (328)              |
| View CMS pages                | `PAGE_VIEW_TIERS` (line 17)     |   yes   |  yes   |   yes    |     yes      | `canViewCMSPages()` (249)                    |
| Lead form                     | `LEAD_FORM_TIERS` (line 26)     |   no    |  yes   |   yes    |     yes      | `hasLeadFormAccess()` (285)                  |
| Link editor                   | `LINK_EDITOR_TIERS` (line 29)   |   no    |  yes   |   yes    |     yes      | `hasLinkEditorAccess()` (292)                |
| Custom domain (tier-based)    | `CUSTOM_DOMAIN_TIERS` (line 32) |  no\*   |  yes   |   yes    |     yes      | `hasCustomDomainAccess()` (308)              |
| Slider editor                 | `SLIDER_EDITOR_TIERS` (line 23) |   no    |   no   |   yes    |     yes      | `hasSliderEditorAccess()` (278)              |
| Navigation editor             | `NAV_EDITOR_TIERS` (line 14)    |   no    |   no   |   yes    |     yes      | `hasNavEditorAccess()` (242)                 |
| CMS (full Puck access)        | `CMS_TIERS` (line 13)           |   no    |   no   |    no    |     yes      | `hasCMSAccess()` (235)                       |
| Create CMS pages              | `PAGE_CREATE_TIERS` (line 18)   |   no    |   no   |    no    |     yes      | `canCreateCMSPages()` (256)                  |
| Edit CMS pages (non-migrated) | `PAGE_EDIT_TIERS` (line 19)     |   no    |   no   |    no    |     yes      | `canEditCMSPage()` (263)                     |
| Blog (nav visibility)         | `HIGHEST_TIER` check            |   no    |   no   |    no    |     yes      | `handleTierUpgrade()` (tier-handlers.ts:140) |

\* Starter can get custom domain access by purchasing the add-on; see `hasCustomDomainAccess()` at `lib/cms/types.ts:308`.

### Library quotas (from `lib/cms/types.ts`)

| Quota       | Constant                            | Starter | Growth | Enhanced | Professional |
| ----------- | ----------------------------------- | :-----: | :----: | :------: | :----------: |
| Image count | `MEDIA_LIBRARY_LIMITS` (line 37)    |   100   |  100   |   500    |     1000     |
| PDF count   | `DOCUMENT_LIBRARY_LIMITS` (line 44) |   10    |   10   |    25    |      50      |

See [`docs/MEDIA_LIBRARY.md`](./MEDIA_LIBRARY.md) for per-file size limits.

### Marketing comparison table (from `lib/plan-features.ts`)

Separate from the gating constants above, [`PLAN_FEATURES` at `lib/plan-features.ts:18`](../lib/plan-features.ts) drives the upgrade/downgrade comparison UI. It is intentionally decoupled from the runtime gates - feature **names** here are display copy, not authoritative access checks.

Feature rows, in order:

1. Set-up & Hosting (all tiers)
2. AMSOIL Corporate Content Updates & Image Assets (all tiers)
3. Dealer Information Editor (all tiers)
4. Automatic transfer links to AMSOIL store (all tiers)
5. Core SEO & AI features (all tiers)
6. Lead generation form (growth+)
7. Social Profile Linking (growth+)
8. Option to Use a Custom Domain (growth+)
9. Featured content editor (enhanced+)
10. Advanced SEO & AI Enhancements (enhanced+)
11. Integrated Performance Dashboard (enhanced+)
12. Optimized local content (professional only)
13. Content section editor (professional only)
14. Content publishing capabilities (professional only)

## Pricing

From [`PLAN_PRICES` at `lib/plan-features.ts:157`](../lib/plan-features.ts):

| Tier         | Monthly | Annual | Annual ÷ 12 |
| ------------ | ------- | ------ | ----------- |
| starter      | n/a     | $48    | $4          |
| growth       | $25     | $300   | $25         |
| enhanced     | $50     | $600   | $50         |
| professional | $200    | $2400  | $200        |

Starter is annual-only (`getAvailableIntervals()` at `lib/plan-features.ts:183`).

Billing intervals: `BILLING_INTERVALS = ['monthly', 'annual']` ([`lib/plan-features.ts:130`](../lib/plan-features.ts)).

## Stripe price-to-tier mapping

Mapping is built from environment variables, not hardcoded IDs. See [`PRICE_ENV_VARS` at `lib/stripe-webhook-handlers.ts:12`](../lib/stripe-webhook-handlers.ts):

| Environment variable                | Tier                  |
| ----------------------------------- | --------------------- |
| `STRIPE_PRICE_STARTER_ANNUAL`       | starter               |
| `STRIPE_PRICE_GROWTH_MONTHLY`       | growth                |
| `STRIPE_PRICE_GROWTH_ANNUAL`        | growth                |
| `STRIPE_PRICE_ENHANCED_MONTHLY`     | enhanced              |
| `STRIPE_PRICE_ENHANCED_ANNUAL`      | enhanced              |
| `STRIPE_PRICE_PROFESSIONAL_MONTHLY` | professional          |
| `STRIPE_PRICE_PROFESSIONAL_ANNUAL`  | professional          |
| `STRIPE_PRICE_CUSTOM_DOMAIN_ANNUAL` | `custom_domain_addon` |

The mapping is **lazily built on first webhook call and cached for the life of the process** ([`getPriceToTierMapping()` at `lib/stripe-webhook-handlers.ts:75`](../lib/stripe-webhook-handlers.ts)). If you change any `STRIPE_PRICE_*` env var, you must restart the server. `_resetPriceToTierMapping()` exists but is test-only (line 91).

Lookup entry point: [`getTierFromPriceId()` at `lib/stripe-webhook-handlers.ts:108`](../lib/stripe-webhook-handlers.ts). Unknown price IDs default to `'starter'` - treat that fallback as a misconfiguration alarm bell, not a safe default. Missing env vars log a warning in production (line 41).

The `custom_domain_addon` value is special: it does **not** change `Dealer.subscriptionTier`. The webhook handler interprets it separately to set `hasCustomDomainAddon` flags.

## Tier-change side effects

When a dealer's tier changes (via Stripe webhook or admin action), [`lib/tier-handlers.ts`](../lib/tier-handlers.ts) handles navigation consequences:

- [`handleTierUpgrade(dealerId, newTier)`](../lib/tier-handlers.ts) at line 136 - if the new tier is `HIGHEST_TIER`, upserts the blog navigation item. Calls `updateBlogNavVisibility()` to set visibility based on whether the dealer has published blog posts.
- [`handleTierDowngrade(dealerId, newTier)`](../lib/tier-handlers.ts) at line 190 - hides the blog nav item for non-highest tiers and triggers ISR revalidation.
- [`syncNavigationVisibility(dealerId, tier)`](../lib/tier-handlers.ts) at line 236 - reconciles default nav items visible per tier. Skips the blog key (that's content-driven, not tier-driven).

Wired into the Stripe webhook at [`app/api/webhooks/stripe/route.ts:402-408`](../app/api/webhooks/stripe/route.ts) and into upgrade checkout at [`app/api/checkout/upgrade/route.ts:308`](../app/api/checkout/upgrade/route.ts).

## Adding a new plan or feature

### Adding a feature with tier gating

A feature lives in at least three places:

1. **The gating constant in [`lib/cms/types.ts`](../lib/cms/types.ts)** - add a new `FEATURE_TIERS` array and an accessor (e.g. `hasFeatureXAccess()`).
2. **The display row in [`PLAN_FEATURES` in `lib/plan-features.ts:18`](../lib/plan-features.ts)** - if the feature should appear in the upgrade/downgrade comparison UI, add a row with the correct booleans.
3. **The API routes and UI components that gate on it** - use the accessor from step 1. Do not inline tier checks like `tier === 'professional'` - use `hasFeatureXAccess(tier)`.

If the feature has a quota (like image count), also add a `*_LIMITS` record next to `MEDIA_LIBRARY_LIMITS`.

If the feature affects navigation or requires a side effect on tier change, update [`lib/tier-handlers.ts`](../lib/tier-handlers.ts).

### Adding a new tier

1. Extend the `TierName` union in [`lib/plan-features.ts:8`](../lib/plan-features.ts).
2. Add the new tier to `TIER_ORDER` ([line 190](../lib/plan-features.ts)) in the correct position.
3. Add entry to `PLAN_DISPLAY_NAMES` (line 119) and `PLAN_PRICES` (line 157).
4. Add columns to every `*_TIERS` array in [`lib/cms/types.ts`](../lib/cms/types.ts) that should include the new tier.
5. Add entries to `MEDIA_LIBRARY_LIMITS` and `DOCUMENT_LIBRARY_LIMITS`.
6. Update `UPGRADE_TARGET_TIERS` / `DOWNGRADE_TARGET_TIERS` in [`lib/plan-features.ts:137`](../lib/plan-features.ts) if the new tier is a valid upgrade/downgrade target.
7. Add the new Stripe price ID env vars to `PRICE_ENV_VARS` in [`lib/stripe-webhook-handlers.ts:12`](../lib/stripe-webhook-handlers.ts). **Set the env vars** on every environment, then restart each server (the mapping is cached).
8. Add booleans for the new tier to every row of `PLAN_FEATURES` ([`lib/plan-features.ts:18`](../lib/plan-features.ts)).
9. Review `lib/tier-handlers.ts` - does the new tier need its own upgrade/downgrade behavior, or does the existing `HIGHEST_TIER` logic cover it?
10. Run the test suite. Missing rows in `PLAN_FEATURES` will be caught by `getFeaturesForTier()` usage but not all paths have tests.

## Pitfalls

**Two feature tables, not one.** `PLAN_FEATURES` in `lib/plan-features.ts` is marketing copy; the `*_TIERS` arrays in `lib/cms/types.ts` are runtime gates. They must agree in spirit but aren't auto-checked. A feature shown as "Growth+" in the comparison table but gated on `CMS_TIERS` (professional only) in code is a classic support ticket.

**Stripe price env vars cache forever.** Changing `STRIPE_PRICE_*` at runtime has no effect. Restart the server. Seeing unexpected "Unknown price ID" warnings after an env change means the old mapping is still in memory.

**Unknown price ID fallback is `starter`.** A misconfigured webhook will silently downgrade a paying dealer to starter rather than throwing. Monitor for `Missing Stripe price ID env var` warnings in webhook logs.

**Tier is `String`, not an enum.** The database column is `String` - typos in data migrations or admin tools won't be rejected by Prisma. Always validate via `isValidTier()` before writing.

**`hasCustomDomainAccess()` has three paths.** Tier, grandfathered flag, and add-on. Forgetting one when you touch this function produces access regressions - grandfathered dealers silently losing access is hard to catch without test coverage.

**`handleTierUpgrade` isn't transactional with `updateBlogNavVisibility`.** A blog post published between those two calls can produce stale visibility. It self-corrects on the next publish action - don't try to make it atomic unless you're solving a real observed bug.

## Related

- [Developer Overview](./DEVELOPER_OVERVIEW.md) - developer onboarding entry point (links here as the canonical tier/feature-gating reference)
- [Media Library](./MEDIA_LIBRARY.md) - tier-gated upload limits
- [Stripe Customer Portal](./STRIPE_CUSTOMER_PORTAL.md) - subscription management UX
- [Local Stripe setup](./LOCAL_DEVELOPMENT.md) - test price IDs in development
