# Tier Transitions

Dealer status and subscription tier state machines, and the cascading side effects of changing them. Use this when investigating "why did X feature silently appear/disappear?" or when running an admin-initiated change.

See also:

- [ADMIN_STATUS_MANAGEMENT.md](./ADMIN_STATUS_MANAGEMENT.md) - admin panel status change workflows
- [billing/README.md](./billing/README.md) - Stripe webhook overview
- [CRON_JOBS.md](./CRON_JOBS.md) - scheduled jobs that touch dealer state

## Concepts

Two independent state machines interact on every tier change:

1. **Dealer status** - lifecycle state (`pending`, `active`, `suspended`, `cancelled`, etc.). Defined in `lib/status-transitions.ts:5-14`.
2. **Subscription tier** - entitlement level (`starter`, `growth`, `enhanced`, `professional`). Defined in `lib/plan-features.ts:8` (`TierName`) and `lib/plan-features.ts:190` (`TIER_ORDER`).

A dealer always has both. Most bugs that look like "tier change broke X" are actually "status change during tier change left state inconsistent."

## Dealer Status State Machine

States (`lib/status-transitions.ts:5-14`):

| Status                  | Set by           | Site visible | Billing                  |
| ----------------------- | ---------------- | ------------ | ------------------------ |
| `pending`               | admin            | No           | n/a                      |
| `registration_pending`  | system (webhook) | No           | Active (payment cleared) |
| `registration_complete` | system           | No           | Active                   |
| `active`                | admin/system     | Yes          | Active                   |
| `suspended`             | admin            | No           | Paused                   |
| `cancelled_pending`     | system only      | Yes          | Active until period end  |
| `cancelled`             | system/admin     | No           | Cancelled (self-serve reactivatable) |
| `payment_failed`        | system only      | Varies       | Failed (dunning rescue via portal) |

Transition rules (`lib/status-transitions.ts:41-70`):

- `payment_failed` and `cancelled_pending` are **system-only targets**. Admins can transition _away from_ them but never _to_ them.
- `cancelled` is terminal for **admin** transitions - an admin cannot move a dealer `cancelled → active` because the Stripe subscription is gone (`BLOCKED_TRANSITIONS` at `lib/status-transitions.ts:41-47`). There **is** a `cancelled → active` path, but only via dealer **self-serve reactivation**, which creates a brand-new Stripe subscription rather than reusing the deleted one (see [Reactivation](#reactivation-cancelled--active) below).
- `suspended → active` is admin-only and intentionally support-gated: there is **no** self-serve resubscribe path for suspended dealers (PR #894). See [Suspended vs. cancelled recovery](#suspended-vs-cancelled-recovery).
- Self-transitions are blocked (`lib/status-transitions.ts:57-58`).
- Dangerous targets (`suspended`, `cancelled_pending`, `cancelled`) require extra confirmation (`lib/status-transitions.ts:105-108`).

See `ADMIN_STATUS_MANAGEMENT.md` for the per-transition Stripe side effects (pause/resume/cancel).

### Side effects on transitions to non-serving states (`cancelled`/`suspended`)

Both the webhook handlers (`app/api/webhooks/stripe/route.ts`) and the admin status route (`app/api/admin/dealers/[id]/status/route.ts`) run two extra side effects when a dealer **first** transitions into a non-serving status:

- **ISR purge.** Cancelled/suspended/payment_failed dealers have their Next.js origin ISR pages purged via `revalidateAllDealerPages` (`lib/isr-revalidation.ts`), so the origin stops re-serving a dark site. This only clears the origin cache - an already edge-cached (Cloudflare) copy can linger until its TTL.
- **Certificate revocation.** `revokeCertbotCertIfPresent` (`lib/certbot-api.ts:325-350`) revokes the dealer's Let's Encrypt cert so certbot's renewal timer stops failing on a domain whose ACME challenge will 404 (PR #866). It fires on the **first** transition to `cancelled` or `suspended`, is gated on `certExpiresAt` being set (the canonical signal of a certbot-issued cert; Cloudflare-for-SaaS dealers leave it null), and runs **before** the DB write so `certExpiresAt` is only cleared on actual revoke success - a failed revoke leaves the field populated so ops can spot the orphaned renewal config. `cancelled_pending` is intentionally **excluded** (the site still serves until period end). Revocation is non-throwing - cert cleanup never blocks the status change.

## Tier Hierarchy and Change Triggers

Tier order (lowest to highest), from `lib/plan-features.ts:190`:

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

`HIGHEST_TIER` is the only tier with blog access (`lib/plan-features.ts:195`, used at `lib/tier-handlers.ts:140`).

### Triggers

| Trigger                  | Source                                                                          | Code path                                                                                                |
| ------------------------ | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Initial checkout         | Stripe `checkout.session.completed` webhook                                     | `lib/stripe-webhook-handlers.ts:174` (`createDealerFromCheckout`), tier extracted at line 343-351        |
| Upgrade (user-initiated) | `POST /api/checkout/upgrade` → Stripe → `customer.subscription.updated` webhook | `app/api/checkout/upgrade/route.ts`, reconciled at `app/api/webhooks/stripe/route.ts:313-435`            |
| Downgrade                | `POST /api/subscription/downgrade`                                              | `app/api/subscription/downgrade/route.ts`                                                                |
| Sidegrade (interval)     | `POST /api/subscription/sidegrade`                                              | `app/api/subscription/sidegrade/route.ts`                                                                |
| Custom domain add-on     | Separate Stripe price; does NOT change `subscriptionTier`                       | `app/api/webhooks/stripe/route.ts:191-274` (filter at lines 402-405 guards against writing addon as tier) |
| Admin override           | Admin panel (direct DB write)                                                   | Admin dealer detail endpoints                                                                            |

The webhook at `app/api/webhooks/stripe/route.ts:401-411` compares `getTierRank(oldTier)` vs `getTierRank(newTier)` and calls either `handleTierUpgrade` or `handleTierDowngrade` from `lib/tier-handlers.ts`.

## Cascading Side Effects

When a tier changes, the following fields and related rows change automatically.

### Per-change matrix

| Change                               | `subscriptionTier` | `hasLeadForm`     | Nav items (default)                                                                  | Blog nav item                                                                           | ISR revalidation |
| ------------------------------------ | ------------------ | ----------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | ---------------- |
| `starter → growth`                   | Updated            | `true` if growth+ | `syncNavigationVisibility` toggles tier-gated items (`lib/tier-handlers.ts:236-260`) | Not affected (still hidden)                                                             | Yes (indirect)   |
| `growth → enhanced`                  | Updated            | `true`            | Same                                                                                 | Not affected                                                                            | Yes              |
| `enhanced → professional`            | Updated            | `true`            | Same                                                                                 | Upserted + visibility set by `updateBlogNavVisibility` (`lib/tier-handlers.ts:140-189`) | Yes              |
| `professional → enhanced` (or lower) | Updated            | Re-evaluated      | Same                                                                                 | `isVisible: false` forced (`lib/tier-handlers.ts:196-202`)                              | Yes (line 214)   |
| Custom domain addon purchase         | **Unchanged**      | Unchanged         | Unchanged                                                                            | Unchanged                                                                               | No               |

### Lead form flag

`hasLeadForm` is set from the tier on dealer creation (`lib/stripe-webhook-handlers.ts:404`) and on every webhook tier update (`app/api/webhooks/stripe/route.ts:353`). It uses `hasLeadFormAccess(tier)` from `lib/cms/types.ts`.

### Navigation seeding (first publish)

`seedDefaultNavigation(dealerId, tier)` at `lib/tier-handlers.ts:45-130` runs on first publish:

- All tiers receive default nav items (filtered by tier at `lib/tier-handlers.ts:47` via `getDefaultNavigationItemsForSeeding`). This seeding helper creates defaults for every tier, deliberately differing from `getDefaultNavigationItems` (at `lib/tier-handlers.ts:28`) which returns `[]` for starter/growth.
- Starter and growth cannot edit them; enhanced and professional can.
- Blog nav item is created with `isVisible: false` until a blog post is published (`lib/tier-handlers.ts:108`).
- Runs inside a serializable transaction to survive concurrent requests (`lib/tier-handlers.ts:55`); unique constraint on `(dealerId, defaultKey)` is the backstop (`lib/tier-handlers.ts:120-122`).

### Blog visibility (content-driven, not tier-driven)

`updateBlogNavVisibility` at `lib/blog-nav-visibility.ts:21-65`:

- Called after publish/unpublish/delete of a blog post.
- Counts published blog posts; sets `isVisible` on the blog nav item accordingly (`lib/blog-nav-visibility.ts:27-29`).
- Early-returns if no blog nav item exists - i.e. non-highest-tier dealers (`lib/blog-nav-visibility.ts:40-42`).
- Triggers ISR revalidation only if visibility actually changed (`lib/blog-nav-visibility.ts:45-47`).

This is why `syncNavigationVisibility` in `lib/tier-handlers.ts:239-263` **explicitly skips the blog key** (line 250) - blog visibility is driven by content, not by tier. See commit 671cf8c for the rationale (blog auto-show on first publish).

### Custom domain add-on

Not a tier. Stored separately on `Dealer`: `hasCustomDomainAddon`, `customDomainAddonStatus`, `customDomainAddonSubscriptionId` (`app/api/webhooks/stripe/route.ts:199-206` / `app/api/webhooks/stripe/route.ts:258-265`). The webhook has a guard at `app/api/webhooks/stripe/route.ts:402-405` that prevents the addon price from being written as `subscriptionTier`.

## Admin Manual Status Change Playbook

When an admin changes dealer status through the UI (`PATCH /api/admin/dealers/{id}/status`):

1. **Pre-flight.** Confirm you're making the right change. If it's dangerous (`suspended`, `cancelled_pending`, `cancelled`), the UI will prompt for confirmation and a reason.
2. **Execute.** The status endpoint handles the Stripe side (see `ADMIN_STATUS_MANAGEMENT.md`), writes the DB, logs to `AdminAction`, and triggers ISR revalidation.
3. **Verify - check all three layers:**
   - **DB:** `dealer.status` matches. `dealer.subscriptionTier` is unchanged (status changes don't touch tier).
   - **Stripe:** Subscription state matches the intended action (paused, cancelled, etc.).
   - **Public site:** Hit `https://{subdomain}.amsoil.aimclear.com` - should 404 for suspended/cancelled, render for active.
4. **Audit.** Open the dealer's Admin Actions log. Confirm the `change_status` entry has your admin ID, reason, and `stripeAction`.

### Admin-initiated tier change (override)

There is no dedicated admin "change tier" endpoint - admins go through Stripe (issue refund + new checkout) or adjust the subscription directly in the Stripe dashboard, which flows back through the `customer.subscription.updated` webhook. **Do not edit `subscriptionTier` directly in the DB** - it bypasses `handleTierUpgrade`/`handleTierDowngrade` and leaves nav and `hasLeadForm` inconsistent.

## Reactivation (`cancelled → active`)

A fully `cancelled` dealer can self-serve reactivate from `/dashboard/subscription` - the "Subscription Cancelled" banner shows a **Reactivate `<tier>` plan** button (`app/dashboard/subscription/page.tsx:404-474`). The flow (PR #891):

1. **Endpoint:** `POST /api/subscription/reactivate` (`app/api/subscription/reactivate/route.ts`). Returns `409` for any status other than `cancelled` - the other recoverable states still have a live subscription, so reactivation doesn't apply: `payment_failed` recovers through the billing portal, while `suspended` is **support-gated** (the dashboard shows only a *Contact support* link, no self-serve path - see PR #894).
2. **Tier is server-trusted.** The new subscription uses the dealer's **prior** `subscriptionTier` read from the DB; a hand-crafted body cannot reactivate onto a cheaper/different tier (`route.ts:59-65`). Only the billing **interval** comes from the request (the old subscription was deleted at cancellation, so there's no stored interval to restore - starter is annual-only; non-starter defaults to annual when unspecified).
3. **New subscription, existing customer.** Cancellation deletes the Stripe subscription but preserves the customer + billing history, so reactivation opens a **new** Stripe Checkout session on the existing `stripeCustomerId` (`route.ts:128-154`), keeping invoice history and webhook matching intact.
4. **Double-charge guard.** Before creating Checkout, the endpoint asks Stripe whether the customer already has a non-terminal subscription and refuses to start a second one (`route.ts:107-126`) - the `dealer.status` check can be stale (the CTA suppression is purely client-side, keyed off `?reactivated=1`). An `idempotencyKey` scoped to customer+tier+interval collapses rapid duplicate POSTs.
5. **Status flips via webhook.** On success the dealer lands on `/dashboard/subscription?reactivated=1` (CTA suppressed client-side). The `customer.subscription.created` webhook - which acts **only** for `cancelled` dealers, skipping new signups (`app/api/webhooks/stripe/route.ts:312-334`) - flips status back toward served and rebuilds ISR pages (`app/api/webhooks/stripe/route.ts:451-474`).

This is why the admin-only `BLOCKED_TRANSITIONS` list still blocks `cancelled → active`: the admin path has no way to recreate the deleted subscription, so the only legitimate route back is a fresh self-serve checkout.

### Suspended vs. cancelled recovery

| Status           | Self-serve recovery?                 | Path                                                                 |
| ---------------- | ------------------------------------ | ------------------------------------------------------------------- |
| `payment_failed` | Yes - dunning rescue                 | Banner "Update payment method" CTA → Stripe portal; pay open invoice or let Stripe's retry charge the updated card |
| `cancelled`      | Yes - self-serve reactivation        | "Reactivate `<tier>` plan" CTA → `POST /api/subscription/reactivate` → new Checkout |
| `suspended`      | **No** - support-gated               | Banner shows a "contact support" link only; no billing CTA (PR #894) |

Suspension is an admin action for accounts in violation (and the terminal dunning state: Stripe unpaid/paused → suspended). PR #894 deliberately **removed** the one-click pay-to-restore portal CTA that the dunning work briefly gave suspended dealers - recovery for suspended accounts is gated through support. Note the portal endpoint itself is not status-gated; the suspended banner simply exposes no link to it.

## Downgrade Edge Cases

### Blog content on `professional → lower`

`handleTierDowngrade` at `lib/tier-handlers.ts:190-227` only hides the blog **nav item** (`isVisible: false`). Blog pages in the DB are preserved - they remain as `page.type = 'blog'` rows. If the dealer upgrades back to professional, the nav item visibility is recomputed by `updateBlogNavVisibility` against existing published post count (`lib/tier-handlers.ts:184`).

Blog URLs (`/blog`, `/blog/[slug]`) may still be reachable directly even when hidden from nav - verify routing behavior if a dealer reports post-downgrade visibility concerns.

### Custom domain on downgrade

`subscriptionTier` change does not automatically tear down `hasCustomDomainAddon`. The add-on is a separate Stripe subscription item. If a dealer downgrades to starter but keeps the add-on, they continue paying for it. Teardown happens via `customer.subscription.deleted` on the add-on subscription (`app/api/webhooks/stripe/route.ts:469-479` preserves config but marks status cancelled).

### Draft content on downgrade

Draft blog posts (`status: 'draft'`) are preserved regardless of tier - `updateBlogNavVisibility` only counts published posts (`lib/blog-nav-visibility.ts:30`). A dealer who downgrades, later upgrades back, still has their drafts.

### Sidegrade (monthly ↔ annual, same tier)

`getTierRank` returns the same rank, so neither `handleTierUpgrade` nor `handleTierDowngrade` runs (`app/api/webhooks/stripe/route.ts:401,407`). Only billing interval changes on the Stripe side. No side effects.

## Pitfalls

### Non-transactional tier handlers

`handleTierUpgrade` performs an `upsert` and then calls `updateBlogNavVisibility` non-transactionally (`lib/tier-handlers.ts:156-188`). A blog post published between the upsert and the visibility check can result in stale visibility. Self-corrects on the next publish/unpublish action. Called out in the code comment at line 185-187.

### Tier handlers are non-critical on webhook path

The webhook wraps tier handlers in try/catch and logs `'Failed to process tier change handlers - dealer may have stale features'` on failure (`app/api/webhooks/stripe/route.ts:414-425`). **The DB tier update succeeds even if the handlers fail.** If you see this log line, manually run `syncNavigationVisibility` and `updateBlogNavVisibility` for the affected dealer.

### Invalid tier guard

If either old or new tier isn't in `TIER_ORDER`, the webhook **skips** tier handlers entirely (`app/api/webhooks/stripe/route.ts:392-430`). This guards against `custom_domain_addon` leaking through, but also means a corrupted `subscriptionTier` in the DB silently prevents handler execution on future changes. If a dealer reports "upgrade didn't change anything," check `dealer.subscriptionTier` for a valid value.

### Webhook env var cache

`buildPriceToTierMapping` is cached for the process lifetime (`lib/stripe-webhook-handlers.ts:66-80`). Changing `STRIPE_PRICE_*` env vars requires a server restart, or tier extraction will fall back to `'starter'` (`lib/stripe-webhook-handlers.ts:113`). Comment at lines 52-65 spells this out.

### ISR revalidation failures are soft

Both `handleTierDowngrade` (`lib/tier-handlers.ts:210-218`) and `updateBlogNavVisibility` (`lib/blog-nav-visibility.ts:60-66`) log warnings on revalidation failure but do not roll back. Cached pages can serve stale nav until the next revalidation window or manual cache bust.

### Race on concurrent first-publish

`seedDefaultNavigation` handles this with a serializable transaction plus the unique constraint - concurrent requests result in a swallowed `Unique constraint failed` at `lib/tier-handlers.ts:120-122`. If you see unexplained nav item duplication, the unique constraint is the source of truth; check migration history.
