# Admin Status Management

Documentation for dealer status changes in the admin panel, including Stripe subscription management, cancellation options, and site visibility.

> **Related:** first publish (go-live) and re-publish are gated by the ZO
> compliance guard; system revalidation is intentionally exempt. See
> [ZO_NUMBER_COMPLIANCE.md](./ZO_NUMBER_COMPLIANCE.md).

## Overview

The admin panel allows changing dealer status between:

| Status                 | Description                                           | Site Visible | Billing                 |
| ---------------------- | ----------------------------------------------------- | ------------ | ----------------------- |
| `active`               | Dealer site is live and accessible                    | Yes          | Active                  |
| `registration_pending` | Awaiting setup completion                             | No           | Active                  |
| `suspended`            | Temporarily disabled (e.g., payment issues)           | No           | Paused                  |
| `cancelled_pending`    | Cancellation scheduled, still active until period end | Yes          | Active (ends at period) |
| `cancelled`            | Permanently terminated                                | No           | Cancelled               |
| `payment_failed`       | System-set when payment fails (not admin-settable)    | Varies       | Failed                  |

## Status Transitions

### Allowed Transitions

```
active → suspended, cancelled
suspended → active, cancelled
registration_pending → active, suspended, cancelled
payment_failed → active, suspended, cancelled
cancelled_pending → active, cancelled  (admin can un-cancel or force immediate cancel)
cancelled → active  (system-set via customer.subscription.created when a cancelled dealer re-subscribes)
```

> **Note:** `cancelled_pending` is system-set only. When an admin cancels with "End of billing period" mode, the system stores `cancelled_pending` rather than `cancelled`. The dealer transitions to `cancelled` when `customer.subscription.deleted` fires from Stripe at the end of the billing period.

> **Note:** `cancelled` is no longer a permanent dead end. When a cancelled dealer re-subscribes, Stripe fires `customer.subscription.created`; the webhook gates on `dealer.status === 'cancelled'`, restores the dealer to `active` (applying the new tier/feature flags), and revalidates their ISR pages. This is the only webhook-driven path out of `cancelled`. There is still no direct admin `cancelled → active` control - reactivation always goes through a new Stripe subscription.

### What Happens on Each Transition

#### Active → Suspended

- **Stripe**: Subscription paused via `pause_collection: { behavior: 'mark_uncollectible' }`
- **Site**: Returns 404 (ISR cache revalidated immediately)
- **SSL cert**: If the dealer has a certbot-managed custom domain (`certExpiresAt` set), the cert is revoked and the Apache VirtualHost removed so certbot's renewal timer stops failing for a dark domain. `certExpiresAt` is cleared.
- **Use case**: Policy violations, voluntary pause, payment disputes

#### Suspended → Active

- **Stripe**: Subscription resumed (removes `pause_collection`)
- **Site**: Becomes accessible again (ISR cache revalidated)
- **SSL cert**: Not automatically reprovisioned. If the dealer had a custom domain, the cert was revoked on suspension - they must re-provision via the custom-domain flow after reactivation.
- **Use case**: Issue resolved, payment fixed

#### Active → Cancelled (End of Period)

- **Stripe**: `cancel_at_period_end` set to true (subscription stays active)
- **DB Status**: Set to `cancelled_pending` (NOT `cancelled`)
- **Site**: Remains accessible until period ends
- **ISR**: Not triggered (site stays live)
- **SSL cert**: Not revoked yet - cert stays valid until the Stripe webhook fires the final `cancelled` transition at period end.
- **Use case**: Friendly cancellation, customer-requested

#### Cancelled Pending → Active (Un-cancel)

- **Stripe**: `cancel_at_period_end` set to false, `pause_collection` cleared
- **Site**: Remains accessible (was already up)
- **Use case**: Customer changes their mind, admin reverses cancellation

#### Cancelled Pending → Cancelled (Force Immediate)

- **Stripe**: `subscriptions.cancel()` called
- **Site**: Returns 404 immediately (ISR cache revalidated)
- **SSL cert**: Revoked if `certExpiresAt` is set; `certExpiresAt` is cleared.
- **Use case**: Escalation, dealer requests immediate termination

#### Any Status → Cancelled (Immediate)

- **Stripe**: Subscription terminated with configurable mode (see below)
- **Site**: Returns 404 permanently (ISR cache revalidated)
- **SSL cert**: Revoked if `certExpiresAt` is set; `certExpiresAt` is cleared. Triggered on first transition only - idempotent across webhook redeliveries.
- **Use case**: Account closure, fraud, policy violations

#### Registration Pending → Active (Admin Activation)

- **DNS**: Cloudflare record created automatically if not already present
- **Site**: Published immediately (`lastPublishedAt` set, `migrationStatus: completed`)
- **Stripe**: No change (subscription already active)
- **Use case**: Admin needs to activate a dealer who hasn't self-published

## Cancellation Modes

When cancelling a subscription, admins can choose from three modes:

### 1. End of Billing Period (Recommended Default)

```
mode: 'end_of_period'
```

- Subscription continues until current period ends
- Dealer keeps access to what they've paid for
- No refund needed (they use remaining time)
- Site remains accessible until period end, then returns 404

### 2. Immediate with Prorated Refund

```
mode: 'immediate_refund'
```

- Subscription cancelled immediately
- Calculates unused time remaining in period
- Issues prorated refund via Stripe (minimum $0.50)
- Site returns 404 immediately
- Use case: Customer-requested cancellation, goodwill gesture

### 3. Immediate, No Refund

```
mode: 'immediate'
```

- Subscription cancelled immediately
- No refund issued
- Site returns 404 immediately
- Use case: Policy violations, fraud, ToS breaches

## API Usage

### Status Change Endpoint

```http
PATCH /api/admin/dealers/{dealerId}/status
Content-Type: application/json
Authorization: (admin session required)

{
  "status": "cancelled",
  "reason": "policy_violation",     // optional
  "notes": "Multiple ToS violations reported",  // optional
  "cancellationMode": "immediate"   // only for cancellation
}
```

### Response

```json
{
  "success": true,
  "dealer": {
    "id": "dealer_123",
    "status": "cancelled",
    "previousStatus": "active"
  },
  "stripeAction": "cancel",
  "dnsCreated": false,
  "refund": {
    "id": "re_123abc",
    "amount": 4500
  }
}
```

### Available Reasons

| Value              | Label              |
| ------------------ | ------------------ |
| `customer_request` | Customer Requested |
| `non_payment`      | Non-Payment        |
| `policy_violation` | Policy Violation   |
| `fraud`            | Fraud              |
| `inactive`         | Inactive Account   |
| `test_account`     | Test Account       |
| `other`            | Other              |

## Stripe Integration

### Subscription Actions

| Transition             | Stripe API Call                                                                  |
| ---------------------- | -------------------------------------------------------------------------------- |
| Suspend                | `subscriptions.update({ pause_collection: { behavior: 'mark_uncollectible' } })` |
| Resume                 | `subscriptions.update({ pause_collection: null })`                               |
| Cancel (end of period) | `subscriptions.update({ cancel_at_period_end: true })`                           |
| Cancel (immediate)     | `subscriptions.cancel()`                                                         |
| Cancel (with refund)   | `subscriptions.cancel()` + `refunds.create()`                                    |
| Un-cancel              | `subscriptions.update({ cancel_at_period_end: false, pause_collection: null })`  |

### Refund Calculation

For `immediate_refund` mode:

```javascript
const remainingPeriod = current_period_end - now;
const totalPeriod = current_period_end - current_period_start;
const proratedAmount = Math.round((amount_paid * remainingPeriod) / totalPeriod);
```

Refund is only issued if:

- `proratedAmount > 50` (minimum $0.50 to avoid Stripe errors)
- `latest_invoice.payment_intent` or `charge` exists (looked up via `invoicePayments.list()` API with legacy field fallback)
- `amount_paid > 0`

**Grace period:** Currently set to 0 days (always prorate, per business decision by Joe on 2026-02-10). To add a grace period where early cancellations get a full refund, change `FULL_REFUND_GRACE_PERIOD_DAYS` in `lib/stripe-subscription-admin.ts`.

## ISR Cache Revalidation

When status changes, the system triggers ISR (Incremental Static Regeneration) revalidation for the dealer's site:

```typescript
await triggerISRRevalidation(dealer.subdomain);
```

This ensures:

- Suspended/cancelled sites return 404 immediately
- Reactivated sites become accessible immediately
- No stale cached pages served

If revalidation fails, the status change still succeeds (graceful degradation). The cache will update on next request.

### Webhook-driven purge (automatic)

The same ISR purge fires from the Stripe webhook handler (`app/api/webhooks/stripe/route.ts`), not just admin-initiated changes, so dunning-driven status changes also drop the dealer's pages from cache:

- `customer.subscription.deleted` → `cancelled`
- `customer.subscription.updated` when Stripe transitions to `unpaid` / `past_due` → `suspended` / `payment_failed`
- `invoice.payment_failed`

The webhook path logs partial purge failures (it no longer silently discards them); a failed purge does not block the status change.

> **Edge-cache caveat:** Revalidation clears only the **Next.js origin** ISR cache. Pages already cached at the **Cloudflare edge** linger until their TTL expires, so a cancelled/suspended dealer's page can still be served from the edge for a short window. The origin proxy 404s unserved dealers, so staleness is bounded by edge TTL - but an explicit Cloudflare cache purge remains a known follow-up.

## DNS Record Creation

When activating a dealer from `registration_pending` who hasn't published yet:

1. System checks if `cloudflareRecordId` is null
2. If null, creates DNS CNAME record via Cloudflare API
3. Sets `lastPublishedAt` and `migrationStatus: completed`
4. Site becomes accessible at `{subdomain}.{domainPrefix}.{tld}`

In development mode, DNS failures are soft (logged but don't block activation).
In production, DNS failure blocks the status change to prevent inaccessible sites.

## Audit Trail

All status changes are logged in the `AdminAction` table:

```prisma
model AdminAction {
  id            String   @id @default(cuid())
  adminUserId   String
  action        String   // "change_status"
  targetType    String   // "dealer"
  targetId      String
  details       Json     // { previousStatus, newStatus, stripeAction, stripeResult, cancellationMode }
  reason        String?
  ipAddress     String?
  userAgent     String?
  createdAt     DateTime @default(now())
}
```

## UI Behavior

### Quick Status Change

- Non-dangerous transitions (e.g., suspended → active) apply immediately
- Single click on status option

### Dangerous Status Change

- Dangerous transitions (suspend, cancel) require confirmation
- Shows warning message
- For cancellations, displays mode selection (radio buttons)
- Optional reason/notes fields

## Testing

Run tests for status management:

```bash
npm test -- lib/__tests__/stripe-subscription-admin.test.ts
npm test -- lib/__tests__/status-transitions.test.ts
```

## Files

| File                                         | Purpose                              |
| -------------------------------------------- | ------------------------------------ |
| `lib/stripe-subscription-admin.ts`           | Stripe pause/resume/cancel functions |
| `lib/status-transitions.ts`                  | Transition rules and UI options      |
| `app/api/admin/dealers/[id]/status/route.ts` | Status change API endpoint           |
| `components/admin/StatusChangeModal.tsx`     | Status change UI                     |
| `lib/isr-revalidation.ts`                    | Cache invalidation                   |
