# Plan Override (Gratuitous Subscriptions)

**Version:** 1.0
**Last Updated:** 2026-05-12
**Status:** Production Ready

---

## Overview

Plan Override allows super-admins to grant users a different effective subscription plan without touching Stripe billing. This is used for:

- **Support compensation** — Free upgrade after billing disputes
- **Sales trials** — Temporary Enterprise access for prospects
- **Partner accounts** — Extended access for strategic partners
- **Testing** — Validate features on specific user accounts

The override is displayed as "Gratuitous [Plan] — Expires in X days" in the admin UI.

---

## Architecture

```
┌─────────────────────────────────────────────────────────────────────┐
│                     PLAN RESOLUTION ORDER                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. Check billing.user_plan_overrides                               │
│     ├── Active override exists? → Use override plan                 │
│     │   (viaOverride: true, overrideExpiresAt: <date>)             │
│     └── No active override? → Continue to step 2                    │
│                                                                     │
│  2. Check billing.subscriptions (Stripe)                            │
│     ├── Active subscription? → Use Stripe plan                      │
│     └── No subscription? → Free tier                                │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
```

When an override expires (`expires_at` passes), the user automatically reverts to their real Stripe subscription or free tier — no cron job required.

---

## Database Schema

### `billing.user_plan_overrides` (Migration 145)

```sql
CREATE TABLE billing.user_plan_overrides (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id),

  -- Audit fields
  granted_by UUID NOT NULL REFERENCES auth.users(id),
  reason TEXT NOT NULL CHECK (char_length(reason) >= 10),

  -- Time window
  starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMPTZ,  -- NULL = open-ended (manual revoke only)

  -- Revocation (soft delete for audit trail)
  revoked_at TIMESTAMPTZ,
  revoked_by UUID REFERENCES auth.users(id),

  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  -- Only one active override per user
  CONSTRAINT one_active_override_per_user
    EXCLUDE USING gist (user_id WITH =)
    WHERE (revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()))
);
```

### Key Behaviors

| Scenario | Behavior |
|----------|----------|
| New grant while active exists | Previous override auto-revoked |
| `expires_at` passes | WHERE clause stops matching, auto-revert |
| User deleted | Override cascade-deleted |
| Plan deleted | Override orphaned (plan_id invalid) |

---

## API Endpoints

### Grant Override

```
POST /api/predict/v1/admin/users/:id/plan-override
Authorization: Bearer <super-admin-token>
Content-Type: application/json

{
  "planId": "<uuid>",
  "reason": "Support comp — 30 days Pro after billing dispute",
  "durationHours": 720,        // Option A: duration in hours
  // OR
  "expiresAt": "2026-06-12T00:00:00Z"  // Option B: specific date
  // OR omit both for open-ended (manual revoke only)
}

Response 201:
{
  "override": {
    "id": "<uuid>",
    "userId": "<uuid>",
    "planId": "<uuid>",
    "planName": "enterprise",
    "planDisplayName": "Enterprise",
    "reason": "Support comp — 30 days Pro after billing dispute",
    "startsAt": "2026-05-12T10:00:00Z",
    "expiresAt": "2026-06-11T10:00:00Z",
    "grantedBy": "<admin-uuid>",
    "grantedByEmail": "admin@agencio.cloud"
  }
}
```

### List Override History

```
GET /api/predict/v1/admin/users/:id/plan-override
Authorization: Bearer <super-admin-token>

Response 200:
{
  "overrides": [
    {
      "id": "<uuid>",
      "userId": "<uuid>",
      "planId": "<uuid>",
      "planName": "enterprise",
      "planDisplayName": "Enterprise",
      "grantedBy": "<uuid>",
      "grantedByEmail": "admin@agencio.cloud",
      "reason": "Support comp — 30 days Pro",
      "startsAt": "2026-05-12T10:00:00Z",
      "expiresAt": "2026-06-11T10:00:00Z",
      "revokedAt": null,
      "revokedBy": null,
      "revokedByEmail": null,
      "createdAt": "2026-05-12T10:00:00Z",
      "isActive": true
    }
  ]
}
```

### Revoke Override

```
DELETE /api/predict/v1/admin/users/:id/plan-override/:overrideId
Authorization: Bearer <super-admin-token>
Content-Type: application/json

{
  "reason": "Trial period concluded early"  // Optional
}

Response 200:
{
  "revoked": true
}
```

### Get User with Subscription

```
GET /api/predict/v1/admin/users/:id
Authorization: Bearer <admin-token>

Response 200:
{
  "user": { ... },
  "subscription": {
    "planId": "<uuid>",
    "planName": "enterprise",
    "planDisplayName": "Enterprise",
    "status": "active",
    "viaOverride": true,
    "overrideExpiresAt": "2026-06-11T10:00:00Z"
  }
}
```

---

## Admin UI Usage

### Viewing User Subscription Status

1. Navigate to **Admin → Users**
2. Click on a user row to open the edit modal
3. The **Current Subscription** banner at the top shows:

| Display | Meaning |
|---------|---------|
| `Free` (gray) | No subscription, no override |
| `Pro` (green) + "via Stripe (active)" | Paid Stripe subscription |
| `Enterprise` (purple) + gift icon + "Gratuitous Override — Expires in 14 days" | Active plan override |

### Granting an Override

1. Open user edit modal (must be super-admin)
2. Scroll to **Plan override (super-admin)** section
3. Select target plan from dropdown (Free, Pro, Enterprise)
4. Select duration:
   - 24 hours
   - 7 days
   - 30 days
   - 90 days
   - Custom date
   - No expiry (manual revoke only)
5. Click **Grant override**
6. Enter reason (minimum 10 characters, logged for audit)
7. Confirm

### Revoking an Override

1. Open user edit modal
2. Find the active override in the **Plan override** section
3. Click **Revoke now**
4. Optionally enter a reason
5. Confirm

---

## Feature Gating Integration

The `getActiveSubscriptionForUser()` function is used by the feature gate system:

```typescript
// packages/be/src/billing/services/feature-gate.ts

export async function checkFeatureAccess(
  userId: string,
  feature: keyof ExtendedFeatureFlags
): Promise<FeatureCheckResult> {
  // This checks overrides FIRST, then Stripe subscription
  const subscription = await getActiveSubscriptionForUser(userId);

  if (!subscription) {
    // Free tier defaults
    return { allowed: false, planName: 'free', ... };
  }

  // subscription.viaOverride = true if using gratuitous override
  const effectiveFeatures = getEffectiveFeatures(subscription.plan);
  return { allowed: effectiveFeatures[feature], ... };
}
```

All feature-gated endpoints automatically respect plan overrides.

---

## Audit Trail

All override actions are logged:

### Database Records

Every grant and revoke is preserved in `billing.user_plan_overrides`:
- `granted_by` — Admin who granted
- `reason` — Required justification (min 10 chars)
- `revoked_at`, `revoked_by` — Revocation details

### Server Logs

```
[admin/users] admin@agencio.cloud granted plan override (enterprise) to user@example.com until 2026-06-11T10:00:00Z
[admin/users] admin@agencio.cloud revoked plan override abc123 for user xyz789 — trial concluded
```

### Override History

The edit modal shows full history with status badges:
- **Active** (green) — Currently in effect
- **Expired** (gray) — `expires_at` passed
- **Revoked** (amber) — Manually revoked

---

## Security Considerations

1. **Super-admin only** — Only `super_admin` role can grant/revoke
2. **Self-targeting blocked** — Cannot grant override to yourself
3. **Reason required** — Minimum 10 characters for audit
4. **No billing impact** — Override doesn't create Stripe charges
5. **Automatic expiry** — No stale elevated access

---

## Troubleshooting

### Override Not Taking Effect

1. Check `expires_at` hasn't passed
2. Check `revoked_at` is NULL
3. Verify `starts_at` is in the past
4. Check for database connection issues

```sql
SELECT * FROM billing.user_plan_overrides
WHERE user_id = '<uuid>'
  AND revoked_at IS NULL
  AND starts_at <= NOW()
  AND (expires_at IS NULL OR expires_at > NOW());
```

### User Stuck on Free After Revoke

The user should revert to their Stripe subscription. If they're showing Free:
1. Check `billing.subscriptions` for active subscription
2. Verify `user_id` matches (not `organization_id` only)

### Override History Not Loading

- Ensure caller has `super_admin` role
- Check network connectivity to API

---

## File Reference

| File | Purpose |
|------|---------|
| `db/migrations/145_user_plan_overrides.sql` | Schema |
| `packages/be/src/api/predict/v1/admin/users/handlers.ts` | API handlers |
| `packages/be/src/billing/services/subscription-service.ts` | `getActiveSubscriptionForUser()` |
| `packages/be/src/billing/services/feature-gate.ts` | Feature gating |
| `packages/be/src/validation/admin-users.ts` | Request validation |
| `apps/web/src/app/admin/users/page.tsx` | Admin UI |

---

## Related Documentation

- `docs/07-monetisation-strategy.md` — Pricing and plan structure
- `docs/42-ai-billing-credits-plan.md` — AI credits billing
- `docs/69-license-server-integration.md` — License Server integration
