# Activation Codes Management

**Branch:** `activation-codes-management`
**Base:** `origin/main` (post-PR #8 activation hardening)
**Purpose:** Turn activation codes from a one-shot `trial_unlimited` voucher into a controlled business tool — linked to plans, modules, limits, time windows, and usage tracking.

---

## Purpose

Activation codes let platform staff hand out controlled access to specific subscription plans during pilots, partner deals, or trade-show campaigns. A single code can:

- Link to any subscription plan (not just `trial_unlimited`).
- Grant a custom modules / premium-features subset.
- Impose per-code branch / user / device limits.
- Be redeemed up to N times (`max_uses`).
- Live inside a `starts_at..expires_at` window.
- Be reversibly deactivated (separate from one-way revoke).

Hand-out remains manual (WhatsApp / phone / direct). The system never sends codes.

---

## Lifecycle

```
                       ┌────────────┐
   create() ──────────▶│   active   │◀─── reactivate
                       └─────┬──────┘
                             │
        deactivate() ────────┤
                             │
                       ┌─────▼──────┐
                       │  inactive  │◀──┐
                       └─────┬──────┘   │
                             │          │ reactivate
        revoke()────────────────────────┤
                             │          │
                       ┌─────▼──────┐   │
                       │  revoked   │   │  (terminal — no reactivate)
                       └────────────┘   │
                                        │
   redeemed at signup ──────────────────┘
        used_count++
        on first use → used_at stamped
   max_uses reached → derived status = used (one-shot) or exhausted
   expires_at past  → derived status = expired
```

The derived `status()` accessor returns one of: `revoked | inactive | expired | not_yet_started | used | exhausted | active`. Precedence is top-down — revoked beats everything.

---

## Data model

### `activation_codes` (extended)

| Column | Type | Notes |
|---|---|---|
| `id` | ulid | primary |
| `code` | string(64) | unique, normalised UPPERCASE on save |
| `name` | string(120) | operator-facing label |
| `description` | text | longer context |
| `notes` | text | internal sales/partner notes |
| `plan_id` | string FK → `subscription_plans.id` | nullable; null falls back to `trial_unlimited` at promote time |
| `granted_modules` | json array | overrides plan defaults; null → use plan |
| `granted_premium_features` | json array | overrides plan defaults; null → use plan |
| `branch_limit` / `user_limit` / `device_limit` | smallint nullable | per-code caps |
| `max_uses` | int, default 1 | 1 = legacy one-shot |
| `used_count` | int, default 0 | increments on each promote redemption |
| `starts_at` / `expires_at` | timestamp nullable | time window |
| `is_active` | bool, default true | reversible pause |
| `last_used_at` | timestamp nullable | most-recent redemption |
| `metadata` | json | free-form bag |
| `used_at` / `used_by_signup_id` / `used_by_company_id` | legacy | stamped on FIRST redemption only |
| `revoked_at` / `revoked_by_user_id` | terminal | nulled until revoke |
| `created_by_user_id` | FK → users | |
| `created_at` / `updated_at` | timestamps | |

### `activation_code_usages` (new, append-only)

| Column | Type | Notes |
|---|---|---|
| `id` | ulid | primary |
| `activation_code_id` | FK ulid, nullOnDelete | FK to candidate code (null only when code string didn't exist) |
| `signup_id` | FK string, nullOnDelete | |
| `company_id` | FK ulid, nullOnDelete | filled at promote time |
| `used_by_email` | string(160) | for failed unknown-code attempts |
| `status` | string(24) | `redeemed` / `promoted` / `failed_invalid` / `failed_expired` / `failed_inactive` / `failed_exhausted` / `failed_not_started` |
| `failure_reason` | string(200) | optional human note |
| `used_at` | timestamp | |
| `metadata` | json | |

Rows are NEVER deleted. If a code is hard-deleted, its history rows survive with `activation_code_id = NULL`.

---

## Plan / module / feature grants

`TenantProvisioningService::promoteSignup()` reads the redeemed code's grants:

| Source | Used for |
|---|---|
| `code.plan_id` | The plan assigned to the new tenant. Null → `trial_unlimited` (back-compat). |
| `code.granted_modules` | If a non-empty array, intersected with `['retail', 'dine', 'pay']` and passed as the tenant's enabled systems. Null/empty → all three. |
| `code.granted_premium_features` | (reserved — not currently consumed by `provision()`; recorded for future plan-feature merging.) |
| `code.branch_limit / user_limit / device_limit` | (reserved — not currently enforced by `provision()`; recorded for future per-code overrides on top of plan defaults.) |

The override.plan from the platform staff is **ignored** when an activation code is redeemed — the code is the deal.

---

## Usage tracking

Two places write to `activation_code_usages`:

1. **`SignupController::register()`** — writes a row per attempt:
   - `status='redeemed'` when the code is valid.
   - `status='failed_*'` when any lifecycle gate rejects (invalid / expired / inactive / exhausted / not_started). FK points at the candidate code even on failure so platform staff can answer "this code was attempted N times".
2. **`TenantProvisioningService::promoteSignup()`** — writes `status='promoted'` when a redeemed code is consumed into a real tenant. Increments `used_count` and updates `last_used_at`. Stamps legacy `used_at` + `used_by_company_id` only on FIRST redemption.

---

## Admin workflow

Page: `/app/platform/activation-codes` (permission `platform.activation_codes.manage` — superadmin only).

### Create
- Code: auto-generate (XK7-BPQR style) or custom.
- Name, notes, plan, max_uses, expires_at, modules subset.
- Code is normalised UPPERCASE on save.
- Returns 422 if the code string is not unique or the plan_id doesn't exist.

### Edit (PATCH)
- Labels (name, description, notes), limits, time windows, max_uses, is_active, metadata are always editable.
- `plan_id`, `granted_modules`, `granted_premium_features` are **immutable after the first redemption** — once a tenant has been provisioned against the code, those grants are part of their contract. The controller silently strips these fields from the update payload when `used_count > 0`.
- Revoked codes cannot be edited (422 `CODE_REVOKED`).

### Deactivate / Reactivate
- Reversible pause. Future signup attempts silently drop the code (same as any inactive code).
- Idempotent — calling deactivate on an already-inactive code returns 422 `CODE_ALREADY_INACTIVE`. Same for reactivate.
- Revoked codes cannot be deactivated.

### Revoke
- Terminal. Sets `revoked_at` + `revoked_by_user_id`.
- Only active codes can be revoked. Used / exhausted / already-revoked rows → 422 `CODE_NOT_ACTIVE` (back-compat with the legacy error code).

### Usages
- `GET /platform/activation-codes/{id}/usages` returns last 200 attempts with KPI summary (`redeemed` / `promoted` / `failed`).
- Shown inline in the detail drawer.

---

## Error handling

All endpoints return the canonical `ApiException` envelope:

```json
{
  "error": {
    "code": "CODE_NOT_ACTIVE",
    "message_en": "Only active codes can be revoked. This code is used.",
    "message_ar": "يمكن إلغاء الأكواد النشطة فقط.",
    "http_status": 422,
    "fields": {"status": ["used"]}
  }
}
```

Codes used by the controller:

| Code | Status | When |
|---|---|---|
| `CODE_NOT_ACTIVE` | 422 | revoke on a non-active code (back-compat) |
| `CODE_REVOKED` | 422 | patch/deactivate on a revoked code |
| `CODE_ALREADY_INACTIVE` | 422 | deactivate twice |
| `CODE_ALREADY_ACTIVE` | 422 | reactivate twice |
| `VALIDATION_FAILED` | 422 | bad payload (unknown plan, invalid module, expires-before-starts) |

Frontend surfaces these inline:
- Create dialog → `ErrorBanner` on submit error.
- Detail drawer → mutation errors handled by the `useRevokeActivationCode` / `useDeactivateActivationCode` / `useReactivateActivationCode` hooks (toast / banner via the shared platform error path).

All signup-side failures are **silent drops** (the public signup form must not info-leak about whether a code exists, was revoked, was used elsewhere, etc.). The usage row preserves the actual failure reason for platform staff.

---

## Security assumptions

- Codes are random unambiguous-alphabet strings (XK7-BPQR), 8 characters. Brute-force space ≈ 31^8 ≈ 10^12 — combined with the rate-limited /signup endpoint (10/min bilingual 429), this is operationally hard.
- Codes are normalised UPPERCASE on save; lookups normalise input to UPPERCASE — case-insensitive in practice.
- Codes are **never logged** in audit payloads. `Audit::record('activation_code.*')` writes `id` only, never `code`.
- Codes are **never echoed back** by the public signup endpoint — the merchant just sees the neutral "application received" payload.
- All endpoints gated by `platform.activation_codes.manage`. Only the superadmin role holds this permission in the seeded matrix.
- Codes are tenant-agnostic (they belong to the platform, not any tenant). Once redeemed, the resulting tenant is properly tenant-isolated via the standard `BelongsToTenant` mechanism.
- Owner password / temp credentials for the newly-provisioned tenant are handled by `TenantProvisioningService` exactly as before — the activation code never sees or stores plaintext.

---

## Known limitations

- **`granted_premium_features` is captured but not yet enforced** at provision time. The column lives on the code, but `provision()` doesn't currently merge it into the tenant's plan_snapshot. Adding that is a small follow-up — the data is there, the consumer isn't. Filed as deferred.
- **`branch_limit` / `user_limit` / `device_limit` are captured but not enforced.** Same reasoning as above. The plan already caps these via `SubscriptionPlan.branches/users/devices`; per-code overrides would require a join in the entitlement layer.
- **No per-code analytics dashboard.** The usages endpoint is per-code; aggregating across codes (e.g. "which codes are most redeemed this month") would be a future view.
- **No bulk-create / CSV import.** Operators create one code at a time. Adequate for current volume.
- **`max_uses=0` is not supported.** Validation rejects 0; null is treated as 1 by the migration default. A genuinely unlimited code would need `max_uses=NULL` semantics — deferred.
- The 4 pre-existing accounting step-up fixture failures (`AccountingTest::*`, `ArApVoucherPostingTest::*`) remain unchanged. Not related to activation codes.

---

## Rollback notes

Each commit reverts independently:

- `git revert <feat-commit>` reverts the schema + controller + service changes.
  - Migration `down()` drops the new columns and `activation_code_usages` table.
  - Existing rows lose their normalised defaults; the legacy promote flow (hardcoded `trial_unlimited`) was preserved verbatim before this branch, so reverting restores the previous behaviour exactly.
- `git revert <docs-commit>` drops the doc.

No data loss in any rollback scenario beyond:
- The `activation_code_usages` table is dropped (history lost). If history matters before rollback, export via the usages endpoint first.

---

## Production deployment notes

Standard Laravel migrate + cache-refresh:

```bash
cd /var/www/dashboard/current/backend
php artisan migrate --force      # ← runs both Phase-22 management migrations
php artisan config:clear && php artisan cache:clear && php artisan route:clear && php artisan view:clear
php artisan config:cache && php artisan route:cache
sudo systemctl restart php8.3-fpm
```

The frontend rebuild is the standard process documented in `docs/deployment/FRONTEND_PRODUCTION_BUILD.md`. The new page is lazy-loaded so no manual nav-config registration is required.

**Existing activation codes are auto-migrated:**
- All existing rows get `plan_id='trial_unlimited'`, `max_uses=1`, `is_active=true`.
- Already-used codes get `used_count=1`.
- No data loss. The legacy promote-grants-trial behaviour is preserved exactly.

---

## Tests run

| Suite | Result |
|---|---|
| `ActivationCodeTest` (existing) | 13 passed |
| `ActivationCodeManagementTest` (new) | 15 passed (60 assertions) |
| `TenantProvisioningTest` (existing) | 9 passed |
| `SignupStatusTest` (existing) | 10 passed |
| `PublicWebsiteTest` (existing) | all green |
| `AdversarialQATest` (existing) | all green |
| Broader `Activation\|Signup\|Onboarding\|Platform` | 156+ passed |
| Frontend full suite | 180 passed across 18 files |
| Dine regression | 186 passed (no change) |
| `DineCashSaleAccountingPostingTest` | 1 passed (53 assertions) — accounting integrity preserved |

The 4 pre-existing accounting step-up fixture failures (`AccountingTest::bills_create_and_pay_partial_then_full`, `ArApVoucherPostingTest::*`) remain unchanged and unrelated.

---

## Production-ready

**Yes.** All endpoints gated, all mutations audited, all redemptions tracked, all failures surfaced with bilingual messages, all back-compat tests green.
