# Documentation Technique — Module Licence

> Module : `Core\v1\License`
> Chemin : `app/v1/License/`
> Architecture : Multi-tenant (Stancl Tenancy), API REST, JWT custom, Laravel

---

## Table des matières

1. [Installation & Configuration](#1-installation--configuration)
2. [Vue d'ensemble](#2-vue-densemble)
3. [Structure du module](#3-structure-du-module)
4. [Schéma de base de données](#4-schéma-de-base-de-données)
5. [Modèles et relations](#5-modèles-et-relations)
6. [Services](#6-services)
7. [API REST — Endpoints](#7-api-rest--endpoints)
8. [Système d'authentification par clé API](#8-système-dauthentification-par-clé-api)
9. [Système de tokens JWT custom](#9-système-de-tokens-jwt-custom)
10. [Enums](#10-enums)
11. [Événements et Jobs](#11-événements-et-jobs)
12. [Tests](#12-tests)
13. [Conventions et patterns](#13-conventions-et-patterns)

---

## 1. Installation & Configuration

### 1.1 Migrations

Les migrations sont dans `database/migrations/tenant/` et s'exécutent **uniquement en contexte tenant**.

Pour migrer tous les tenants :
```bash
php artisan tenants:migrate
```

Pour migrer un tenant spécifique :
```bash
php artisan tenants:migrate --tenants=<tenant_id>
```

> ⚠️ Ne pas utiliser `php artisan migrate` seul : ces tables n'existent que dans les bases tenant.

**Ordre de migration (chronologique) :**

| Date | Fichier | Description |
|---|---|---|
| 2025-01-17 | `create_license_durations_table` | Durées de licences |
| 2025-01-17 | `create_license_types_table` | Types de licences |
| 2025-01-17 | `create_contact_applications_table` | Souscriptions contact ↔ application |
| 2025-01-17 | `create_licenses_table` | Table principale `licenses` |
| 2025-01-17 | `create_contact_application_api_keys_table` | Clés API (pk/sk) |
| 2025-01-17 | `create_license_activations_table` | Activations par entité externe |
| 2026-02-17 | `create_license_packs_table` | Packs de licences (v1) |
| 2026-02-17 | `create_license_modules_table` | Modules par licence (v1) |
| 2026-02-17 | `create_license_app_permissions_table` | Permissions par licence (v1) |
| 2026-04-16 | `create_offer_types_table` | Types d'offres |
| 2026-04-16 | `create_offers_table` | Table `offers` (ancienne) |
| 2026-04-16 | `create_packs_table` | Table `packs` |
| 2026-04-16 | `create_pricings_table` | Tarifs des offres |
| 2026-04-16 | `refactor_licenses_table` | Refonte schéma licences |
| 2026-04-16 | `create_license_usages_table` | Usages de licences |
| 2026-04-16 | `create_license_modules_table` | Refonte modules par licence |
| 2026-04-16 | `create_license_permissions_table` | Permissions par licence (v2) |
| 2026-04-16 | `create_license_offers_table` | Table snapshot `license_offer` |
| 2026-04-16 | `fix_contact_morphs_to_uuid` | Correction types polymorphiques |
| 2026-04-16 | `add_pricings_to_license_offers_table` | Colonne `pricings` sur snapshot |
| 2026-04-17 | `remove_price_from_licenses_table` | Suppression colonne prix directe |
| 2026-04-17 | `modify_license_permissions_table` | Modification permissions |
| 2026-04-17 | `drop_license_permissions_table` | Suppression table permissions directes |
| 2026-04-18 | `remove_currency_id_in_pricings_table` | Déplacement devise vers offers |
| 2026-04-18 | `add_currency_id_in_offers_table` | Colonne `currency_id` sur offers |
| 2026-04-21 | `change_licenses_status_to_string` | Statut licence en string |
| 2026-04-21 | `create_service_offers_table` | Table `service_offers` (nouvelle) |
| 2026-04-21 | `create_service_offer_prices_table` | Tarifs des service offers |
| 2026-04-21 | `update_offer_id_in_license_offer_table` | Mise à jour FK offer |
| 2026-04-24 | `remove_licence_type_id_from_licences_table` | Suppression `license_type_id` direct |
| 2026-05-06 | `add_deployment_type_to_contact_applications_table` | Colonne `deployment_type` |
| 2026-05-06 | `add_contact_application_id_to_service_offers_table` | Offres personnalisées par souscription |
| 2026-05-20 | `add_webhook_secret_to_contact_applications_table` | Colonne `webhook_secret` |

---

### 1.2 Seeders

Les seeders sont dans `database/seeders/`. **L'ordre est important** : chaque seeder dépend du précédent.

#### Seeder tout-en-un (recommandé pour le développement)

```bash
php artisan db:seed --class="Core\v1\License\Database\Seeders\LicenseTestSeeder"
```

Ce seeder orchestre automatiquement les 6 étapes dans le bon ordre.

#### Seeders individuels (ordre obligatoire)

```bash
# Étape 1 — Modules génériques (AppModule)
php artisan db:seed --class="Core\v1\Application\Database\Seeders\AppModuleSeeder"

# Étape 2 — Types d'offres : Starter, Professional, Enterprise
php artisan db:seed --class="Core\v1\License\Database\Seeders\OfferTypeSeeder"

# Étape 3 — Modules d'application liés à "Test Application" (requiert AppModuleSeeder)
php artisan db:seed --class="Core\v1\Application\Database\Seeders\ApplicationModuleSeeder"

# Étape 4 — Offres de service (requiert OfferTypeSeeder + ApplicationModuleSeeder + Currency)
php artisan db:seed --class="Core\v1\License\Database\Seeders\OfferSeeder"

# Étape 5 — Tarifs des offres (requiert OfferSeeder)
php artisan db:seed --class="Core\v1\License\Database\Seeders\PricingSeeder"

# Étape 6 — Liaison offres ↔ modules applicatifs avec prix (requiert OfferSeeder + ApplicationModuleSeeder)
php artisan db:seed --class="Core\v1\License\Database\Seeders\OfferApplicationModuleSeeder"
```

#### Seeder optionnel — Packs

```bash
php artisan db:seed --class="Core\v1\License\Database\Seeders\PackSeeder"
```

#### Ce que chaque seeder crée

| Seeder | Données créées |
|---|---|
| `OfferTypeSeeder` | 3 types d'offres : **Starter**, **Professional**, **Enterprise** |
| `OfferSeeder` | 3 offres `ServiceOffer` sur l'application "Test Application", statut `active`, avec options (users, storage, support) |
| `PricingSeeder` | 4 tarifs par offre : mensuel/annuel × partagé/dédié. Exemples : Starter mensuel partagé = 29,99 €, Enterprise annuel dédié = 1 999,99 € |
| `OfferApplicationModuleSeeder` | Lie chaque offre à ses modules applicatifs avec un prix unitaire (ex. module CRM : 50 € sur Starter, 150 € sur Professional, 400 € sur Enterprise) |
| `PackSeeder` | 5 packs génériques : **CRM**, **HR**, **Accounting**, **Projects**, **Document** |
| `LicenseTestSeeder` | Orchestrateur des étapes 1 à 6 ci-dessus |

> ⚠️ `OfferSeeder` requiert qu'une `Currency` existe déjà en base (`Currency::first()`). Exécutez le seeder de devises du module Setting en premier.
>
> ⚠️ `PricingSeeder` utilise `$offer->name` pour associer les tarifs. Si les noms d'offres diffèrent, aucun tarif ne sera créé.

---

### 1.3 Middleware

Le middleware `AuthenticateApiKey` doit être enregistré dans le kernel de l'application pour être utilisé sur les routes M2M.

```php
// app/Http/Kernel.php ou équivalent
protected $routeMiddleware = [
    'auth.apikey' => \Core\v1\License\Http\Middleware\AuthenticateApiKey::class,
];
```

---

### 1.4 Service Provider

```php
// config/app.php ou équivalent
Core\v1\License\LicenseServiceProvider::class,
```

---

### 1.5 Variables d'environnement

| Variable | Utilisation | Valeur recommandée |
|---|---|---|
| `APP_URL` | Utilisée comme `iss` (issuer) dans les tokens JWT | URL complète du tenant |
| `QUEUE_CONNECTION` | Les jobs `SendLicenseByEmailJob` et `DispatchLicenseWebhookJob` sont asynchrones | `redis` ou `database` en production |
| `MAIL_*` | Configuration SMTP pour l'envoi de licences par e-mail | Selon l'environnement |

> ⚠️ Si `QUEUE_CONNECTION=sync`, les jobs s'exécutent de manière synchrone (pratique en développement, déconseillé en production).

Ce projet utilise **Laravel Horizon** pour gérer les queues. Pour démarrer Horizon :
```bash
php artisan horizon
```

Pour surveiller l'état des queues :
```bash
php artisan horizon:status
```

---

### 1.6 Prérequis inter-modules

| Module | Raison |
|---|---|
| `Core\v1\Application` | `Application`, `ApplicationModule`, `OfferApplicationModule`, `ModuleDependencyResolver` |
| `Core\v1\Contact` | Relation polymorphique `contact` sur `License` et `ContactApplication` |
| `Core\v1\Setting` | `Currency` référencée par `ServiceOffer` et `ServiceOfferPrice` |
| `Core\v1\Log` | `UserLogService` pour journaliser l'envoi de licences par mail |
| `Core\v1\ExternalApplications` | Type polymorphique `contact_type` dans les souscriptions de stockage |

---

## 2. Vue d'ensemble

Le module **License** gère l'intégralité du cycle de vie des licences logicielles :

- Catalogue d'offres commerciales avec modules, permissions et tarifs multi-fréquences
- Création de licences avec instantané immuable de l'offre (`LicenseOffer`)
- Activation, suspension, révocation et expiration des licences
- Suivi des activations par entité externe (machine, installation)
- Abonnements clients (`ContactApplication`) avec mode de déploiement (SaaS/Dédié)
- Authentification M2M via clés publique/secrète (`pk_` / `sk_`)
- Génération et vérification de tokens JWT custom signés par clé de souscription
- API publique (sans auth admin) pour les applications tierces

**Dépendances inter-modules :**
- `Core\v1\Application` → `Application`, `ApplicationModule`, `OfferApplicationModule`, `ModuleDependencyResolver`
- `Core\v1\Contact` → `Contact`
- `Core\v1\Setting` → `Currency`
- `Core\v1\Log` → `UserLogService`
- `Core\v1\ExternalApplications` → `ExternalApplication`

---

## 2. Structure du module

```
app/v1/License/
├── LicenseServiceProvider.php
├── database/
│   ├── factories/
│   ├── migrations/tenant/
│   └── seeders/
├── Enums/
│   ├── ContactApplicationDeploymentType.php   (saas, dedicated)
│   ├── ContactApplicationStatus.php           (active, suspended, pending)
│   ├── LicensePermissionEnum.php
│   ├── LicenseStatus.php                      (pending, active, expired, revoked, suspended)
│   ├── OfferFrequencyEnum.php                 (monthly, yearly)
│   ├── OfferTypePermissionEnum.php
│   ├── PackPermissionEnum.php
│   ├── ServerEnum.php                         (shared, dedicated)
│   ├── ServiceOfferPermissionEnum.php
│   ├── ServiceOfferStatusEnum.php             (DRAFT, ACTIVE, SUSPENDED)
│   └── SubscriptionPermissionEnum.php
├── Events/
│   ├── ApiKeyGenerated.php
│   ├── ApiKeyRevoked.php
│   ├── LicenseActivated.php
│   ├── LicenseCreated.php
│   ├── LicenseDeactivated.php
│   ├── LicenseRevoked.php
│   ├── LicenseSuspended.php
│   └── LicenseUnsuspended.php
├── Http/
│   ├── Controllers/
│   ├── Middleware/
│   │   └── AuthenticateApiKey.php
│   ├── Requests/
│   └── Resources/
├── Jobs/
│   ├── DispatchLicenseWebhookJob.php
│   └── SendLicenseByEmailJob.php
├── Mail/
│   └── SendLicenseMail.php
├── Models/
├── resources/views/
│   └── sendLicenseMail.blade.php
├── routes/
│   └── tenant.php
├── Services/
│   ├── ApiKeyService.php
│   ├── ContactApplicationService.php
│   ├── LicenseDurationService.php
│   ├── LicensePermissionResolver.php
│   ├── LicenseService.php
│   ├── LicenseTokenService.php
│   ├── LicenseTypeService.php
│   └── LicenseUsageService.php
└── tests/Feature/
```

---

## 3. Schéma de base de données

Toutes les tables sont créées dans le contexte **tenant**.

### `licenses`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | Auto-généré |
| `license_key` | string (unique) | Format `XXXX-XXXX-XXXX-XXXX` (uppercase, collision-checked) |
| `name` | string (nullable) | Libellé personnalisé |
| `contact_id` | UUID | ID du contact bénéficiaire (polymorphique) |
| `contact_type` | string | Classe du contact (ex. `Contact::class`) |
| `license_type_id` | UUID (nullable, FK) | Type de licence |
| `status` | string | `pending`, `active`, `expired`, `revoked`, `suspended` |
| `option` | JSON (nullable) | Options libres de l'offre |
| `duration` | integer (nullable) | Nombre de périodes (selon fréquence de l'offre) |
| `starts_at` | datetime (nullable) | Date de début de validité |

### `license_offer` *(snapshot immuable)*
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `license_id` | UUID (FK → licenses) | — |
| `offer_id` | UUID (FK → service_offers) | Référence vers l'offre source |
| `offer_type_id` | UUID (nullable) | — |
| `name` | string (nullable) | Nom du type d'offre au moment de la création |
| `status` | string | Statut de l'offre au moment de la création |
| `options` | JSON | Options libres |
| `modules` | JSON | **Snapshot** complet des modules (id, permissions, prix, `is_dependency`) |
| `pricings` | JSON | `{ frequency, server, selected_pricing: { id, price, currency_id, frequency, server } }` |

### `license_activations`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `license_id` | UUID (FK → licenses) | — |
| `external_entity_id` | string | Identifiant de l'entité externe (machine, user) |
| `external_entity_type` | string (nullable) | Type de l'entité externe |
| `hostname` | string (nullable) | Nom d'hôte de l'installation |
| `ip_address` | string (nullable) | IP au moment de l'activation |
| `metadata` | JSON | Informations libres |
| `activated_at` | datetime | Date d'activation |
| `last_validated_at` | datetime | Dernière vérification |
| `revoked_at` | datetime (nullable) | Date de révocation |
| `expires_at` | datetime (nullable) | Date d'expiration de l'activation |

### `license_usages`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `license_id` | UUID (FK → licenses) | — |
| `started_at` | datetime | Début d'utilisation effective |
| `ended_at` | datetime (nullable) | Fin calculée selon durée + fréquence |
| `metadata` | JSON | `{ user_id, user_email, ip_address, user_agent }` |

### `license_modules`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `license_id` | UUID (FK → licenses) | — |
| `app_module_id` | UUID | Référence vers le module applicatif |

### `contact_applications`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `contact_id` | UUID (FK → contacts) | Client |
| `application_id` | UUID (FK → applications) | Application souscrite |
| `status` | string | `active`, `suspended`, `pending` |
| `deployment_type` | string | `saas`, `dedicated` |
| `subscribed_at` | datetime (nullable) | Date de souscription |
| `expires_at` | datetime (nullable) | Date d'expiration |
| `metadata` | JSON | Données libres |
| `allowed_domains` | JSON | Liste de domaines autorisés pour les clés API |
| `signing_key` | string (nullable, **hidden**) | Clé HMAC pour les tokens JWT (générée à la demande) |
| `webhook_secret` | string (nullable) | Secret pour sécuriser les webhooks |

### `contact_application_api_keys`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `contact_application_id` | UUID (FK) | — |
| `name` | string (nullable) | Libellé de la clé |
| `public_key` | string (unique) | `pk_` + 32 chars aléatoires |
| `secret_key` | string (**hidden**) | `sk_` + 48 chars aléatoires (comparaison en clair) |
| `last_used_at` | datetime (nullable) | Dernière utilisation |
| `expires_at` | datetime (nullable) | Expiration optionnelle |
| `revoked_at` | datetime (nullable) | Date de révocation |

### `service_offers`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `offer_type_id` | UUID (FK → offer_types) | — |
| `application_id` | UUID (FK → applications) | — |
| `contact_application_id` | UUID (nullable, FK) | Offre personnalisée pour une souscription spécifique |
| `is_custom` | boolean | Offre personnalisée ou standard |
| `options` | JSON | Options libres |
| `status` | string | `DRAFT`, `ACTIVE`, `SUSPENDED` |
| `currency_id` | UUID (nullable, FK → currencies) | — |

### `service_offer_prices`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `service_offer_id` | UUID (FK) | — |
| `price` | decimal | Tarif |
| `frequency` | string | `monthly`, `yearly` |
| `server` | string | `shared`, `dedicated` |
| `currency_id` | UUID (nullable, FK) | Devise spécifique à ce tarif |

### `offer_types`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `name` | string | Nom du type d'offre |

### `packs`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `name` | string (déduit) | Nom du pack |

### `pack_modules` *(pivot)*
| Colonne | Type |
|---|---|
| `pack_id` | UUID (FK → packs) |
| `app_module_id` | UUID (FK → app_modules) |

### `license_types`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `code` | string | Code du type (ex. `trial`, `perpetual`) |
| `name` | string | Libellé |

### `license_durations`
| Colonne | Type | Description |
|---|---|---|
| `id` | UUID (PK) | — |
| `value` | integer | Durée en nombre de périodes |
| `unit` | string | Unité (days, months, years) |

---

## 4. Modèles et relations

### `License`
```
License
 ├── belongsTo  → LicenseType
 ├── morphTo    → contact (Contact)
 ├── hasOne     → LicenseOffer
 ├── hasMany    → LicenseActivation    [activations()]
 ├── hasMany    → LicenseActivation    [activeActivations()] (whereNull revoked_at)
 ├── hasMany    → LicenseUsage
 └── hasMany    → LicenseModule
```

**Scopes :** `scopeActive`, `scopeExpired`, `scopeRevoked`, `scopeSuspended`, `scopeValid`, `scopeSearch`

**Méthodes helper :** `isValid()`, `isRevoked()`, `isSuspended()`, `isExpired()`, `canActivate()`, `revoke()`, `suspend()`, `activate()`, `markAsExpired()`

**Génération de clé :** boucle `do/while` garantissant l'unicité : `XXXX-XXXX-XXXX-XXXX` (uppercase, `Str::random(4)` × 4)

---

### `LicenseOffer`
Snapshot immuable de l'offre au moment de la création de la licence.

```
LicenseOffer
 ├── belongsTo → License
 └── belongsTo → ServiceOffer (offer_id)
```

Colonnes `modules` et `pricings` castées en JSON. Le champ `modules` contient un tableau structuré :
```json
[
  {
    "id": "uuid",
    "application_module_id": "uuid",
    "app_module_id": "uuid",
    "name": "...",
    "permissions": ["shop.sale.view", "..."],
    "price": 0,
    "is_dependency": false
  }
]
```

---

### `ContactApplication`
```
ContactApplication
 ├── hasMany  → ContactApplicationApiKey    [apiKeys()]
 ├── hasMany  → ContactApplicationApiKey    [activeApiKeys()] (whereNull revoked_at)
 ├── belongsTo → Contact
 ├── belongsTo → Application
 └── hasMany  → ServiceOffer               (offres personnalisées)
```

**Scopes :** `scopeActive`, `scopeSuspended`, `scopePending`, `scopeNotExpired`, `scopeExpired`, `scopeForContact`, `scopeForApplication`, `scopeSaas`, `scopeDedicated`

**`signing_key`** : caché dans les sérialisations (`$hidden`). Généré à la demande par `getSigningKey()` (lazy, 32 bytes hex). Utilisé pour signer les tokens JWT.

**`isDomainAllowed(domain)`** : vérifie si un domaine est dans `allowed_domains`. Retourne `true` si la liste est vide.

---

### `ContactApplicationApiKey`
```
ContactApplicationApiKey
 └── belongsTo → ContactApplication
```

**`secret_key`** caché dans les sérialisations.

**Méthodes :** `verifySecretKey(string)` (hash_equals), `isValid()`, `revoke()`, `markAsUsed()`, `getMaskedSecretKey()`

---

### `ServiceOffer`
```
ServiceOffer
 ├── belongsTo → Application
 ├── belongsTo → OfferType
 ├── hasMany   → ServiceOfferPrice     [prices()]
 ├── belongsTo → Currency
 ├── belongsTo → ContactApplication
 └── hasMany   → OfferApplicationModule [offerModules()]
```

**`scopeForContactApplication`** : filtre les offres globales (contact_application_id IS NULL) + celles de la souscription spécifique.

**`createFromRequest($data)`** et **`updateFromRequest($data)`** : encapsulent la logique transactionnelle (création/mise à jour des prix et modules en bulk via `insert()`). Les permissions sont croisées avec celles disponibles sur l'`ApplicationModule` (`array_intersect`).

---

### `LicenseActivation`
```
LicenseActivation
 └── belongsTo → License
```

**Méthodes :** `isActive()`, `revoke()`, `isRevoked()`, `touchValidation()`, `isExpired()`

---

### `Pack`
```
Pack
 ├── belongsToMany → AppModule (via pack_modules)
 └── hasMany       → PackModule
```

---

## 5. Services

### `LicenseService`

Service central du module.

| Méthode | Description |
|---|---|
| `list(filters, perPage)` | Listing paginé. Filtres : `status`, `license_type_id`, `contact_id`, `application_id`, `search` |
| `create(data)` | Crée licence + snapshot `LicenseOffer`. Résout les dépendances transitives via `ModuleDependencyResolver`. Fire `LicenseCreated` |
| `createForDistributor(data)` | Variante pour distributeurs externes : accepte un `ServiceOffer.id` au lieu d'un `ServiceOfferPrice.id`. Statut initial : `ACTIVE` |
| `update(license, data)` | Mise à jour partielle. Si `offer_id` fourni : recalcule le snapshot `LicenseOffer` |
| `delete(license)` | Suppression en cascade : modules → usages → activations → offer → license |
| `validate(licenseKey)` | Vérifie clé, statut, expiration, date de début. Retourne `{ valid, code, message, license? }` |
| `activate(license, data)` | Crée une `LicenseActivation`. Idempotent (touche `last_validated_at` si déjà activée). Fire `LicenseActivated` |
| `deactivate(license, externalEntityId)` | Révoque l'activation d'une entité spécifique. Fire `LicenseDeactivated` |
| `revoke(license)` | Révoque la licence + toutes les activations actives. Fire `LicenseRevoked` |
| `suspend(license)` | Suspend la licence. Fire `LicenseSuspended` |
| `unsuspend(license)` | Réactive une licence suspendue. Fire `LicenseUnsuspended` |
| `startUsing(license, data)` | Passe PENDING → ACTIVE + crée un `LicenseUsage` avec `ended_at` calculé |
| `sendLicenseByMail(license, data)` | Dispatch `SendLicenseByEmailJob`. Supporte `{LICENSE_KEY}` et `{APP_NAME}` dans le body. Journalise via `UserLogService` |
| `updateLicenseOffer(license, data)` | Met à jour le snapshot `LicenseOffer` (modules sélectionnés + tarif) |
| `findByKey(licenseKey)` | Retourne `null` si non trouvé |
| `generateLicenseKey()` | *(protected)* Loop `do/while` pour unicité |

**Calcul de `ended_at` :**
```php
match($frequency) {
    'monthly' => $startedAt->clone()->addMonths($license->duration),
    'yearly'  => $startedAt->clone()->addYears($license->duration),
    default   => $startedAt->clone()->addDays($license->duration),
}
```

---

### `ApiKeyService`

| Méthode | Description |
|---|---|
| `generate(contactApplication, options)` | Génère une paire `pk_` / `sk_` unique. Fire `ApiKeyGenerated`. Retourne les clés en clair (secret visible une seule fois) |
| `validate(publicKey, secretKey)` | Vérifie la paire (existence + validité + `hash_equals`) |
| `validateWithContactApplication(publicKey, secretKey)` | Valide + vérifie que la `ContactApplication` et l'`Application` sont actives. Marque la clé comme utilisée (`markAsUsed`) |
| `revoke(apiKey)` | Révoque la clé. Fire `ApiKeyRevoked` |
| `listByContactApplication(contactApplication)` | Toutes les clés, triées par date |
| `listActiveByContactApplication(contactApplication)` | Clés non révoquées |

**Génération des clés :**
- `pk_` + `Str::random(32)` (loop unicité)
- `sk_` + `Str::random(48)` (loop unicité)

---

### `LicenseTokenService`

Génération et vérification de tokens JWT custom (algorithme HS256, type `LIC`).

| Méthode | Description |
|---|---|
| `generateToken(license, ttlMinutes, activation?)` | Génère un token signé avec le `signing_key` de la `ContactApplication` associée. Crée la `ContactApplication` si elle n'existe pas |
| `verifyToken(token, signingKey)` | Vérifie signature HMAC + expiration. Retourne le payload ou `null` |
| `decodeWithoutVerification(token)` | Décode le payload sans vérifier la signature (debug) |

**Structure du token :**
```json
{
  "header": { "alg": "HS256", "typ": "LIC", "ver": 1 },
  "payload": {
    "iss": "https://...",
    "iat": 1234567890,
    "exp": 1234567890,
    "lic": {
      "id": "uuid",
      "key": "XXXX-XXXX-XXXX-XXXX",
      "app_id": "uuid",
      "type": "license_type_code",
      "starts_at": "ISO8601",
      "expires_at": "ISO8601",
      "status": "active"
    },
    "activation": { "id": "...", "external_entity_id": "...", ... }
  }
}
```

**Calcul de l'expiration :**
Priorité : `activation.expires_at` > `license.expires_at` > `ttlMinutes` > `null` (pas d'expiration)

---

### `LicensePermissionResolver`

| Méthode | Description |
|---|---|
| `resolve(license)` | Extrait les permissions du snapshot `LicenseOffer.modules`. Filtre par modules actifs (`LicenseModule.app_module_id`) si déclarés. Retourne une liste de codes uniques |

---

### `ContactApplicationService`

Gestion CRUD des souscriptions (`ContactApplication`) et de leur cycle de vie.

---

### `LicenseDurationService` / `LicenseTypeService` / `LicenseUsageService`

Services CRUD standards pour les entités de référence.

---

## 6. API REST — Endpoints

Toutes les routes sont en contexte tenant (middleware `InitializeTenancyByDomain`, `PreventAccessFromCentralDomains`).

### Types de licences

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/license-types` | Liste des types |
| `POST` | `/license-types` | Créer un type |
| `GET` | `/license-types/{type}/show` | Détail |
| `PUT` | `/license-types/{type}/update` | Modifier |
| `DELETE` | `/license-types/{type}/delete` | Supprimer |

---

### Durées de licences

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/license-durations` | Liste |
| `POST` | `/license-durations` | Créer |
| `GET` | `/license-durations/{duration}/show` | Détail |
| `PUT` | `/license-durations/{duration}/update` | Modifier |
| `DELETE` | `/license-durations/{duration}/delete` | Supprimer |

---

### Abonnements (Souscriptions)

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/subscriptions` | Liste paginée |
| `GET` | `/subscriptions/collection` | Liste complète non paginée |
| `GET` | `/subscriptions/datatables` | Format DataTables |
| `POST` | `/subscriptions` | Créer un abonnement |
| `GET` | `/subscriptions/{subscription}/show` | Détail |
| `PUT` | `/subscriptions/{subscription}/update` | Modifier |
| `DELETE` | `/subscriptions/{subscription}/delete` | Supprimer |
| `POST` | `/subscriptions/{subscription}/generate-webhook-key` | Générer le secret webhook |
| `GET` | `/contacts/{contact}/subscriptions` | Abonnements d'un contact |
| `GET` | `/applications/{application}/subscriptions` | Abonnements d'une application |

---

### Clés API des abonnements

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/subscriptions/{subscription}/api-keys` | Liste des clés |
| `POST` | `/subscriptions/{subscription}/api-keys` | Générer une nouvelle clé |
| `GET` | `/subscriptions/{subscription}/api-keys/{key}` | Détail d'une clé |
| `POST` | `/subscriptions/{subscription}/api-keys/{key}/revoke` | Révoquer une clé |

**Réponse de création de clé :** la `secret_key` est retournée en clair **une seule fois**.

---

### Licences

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/licenses` | Liste paginée (filtres : `status`, `license_type_id`, `contact_id`, `application_id`, `search`) |
| `POST` | `/licenses` | Créer une licence |
| `GET` | `/licenses/datatables` | Format DataTables |
| `GET` | `/licenses/{license}/show` | Détail (avec offer, activations) |
| `PUT` | `/licenses/{license}/update` | Modifier |
| `DELETE` | `/licenses/{license}/delete` | Supprimer (cascade complète) |
| `POST` | `/licenses/{license}/revoke` | Révoquer (+ toutes activations) |
| `POST` | `/licenses/{license}/activate` | Activer (admin) |
| `GET` | `/licenses/{license}/activations` | Historique des activations |
| `POST` | `/licenses/{license}/suspend` | Suspendre |
| `POST` | `/licenses/{license}/unsuspend` | Lever la suspension |
| `GET` | `/licenses/{license}/show/jwtKey` | Clé de signature JWT |
| `GET` | `/licenses/{license}/resolved-permissions` | Permissions résolues |
| `POST` | `/licenses/send/{licenseId}/by/mail` | Envoyer par e-mail (async) |
| `PUT` | `/licenses/update/{licenseId}/offer/detail` | Mettre à jour le snapshot d'offre |

**Corps de création (`POST /licenses`) :**
```json
{
  "name": "Licence client XYZ",
  "contact_id": "uuid",
  "offer_id": "uuid (ServiceOfferPrice.id)",
  "duration": 12,
  "starts_at": "2026-01-01T00:00:00Z",
  "status": "pending"
}
```

---

### Usages des licences

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/license-usages` | Collection de tous les usages |
| `GET` | `/license-usages/{license}` | Usages d'une licence |
| `GET` | `/license-usages/datatables` | Format DataTables |
| `GET` | `/license-usages/{id}/show` | Détail d'un usage |

---

### Types d'offres

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/offer-types/collection` | Collection |
| `GET` | `/offer-types/datatables` | Format DataTables |
| `POST` | `/offer-types/store` | Créer |
| `PATCH` | `/offer-types/update/{offerType}` | Modifier |
| `DELETE` | `/offer-types/delete/{offerType}` | Supprimer |

---

### Packs

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/packs` | Liste |
| `GET` | `/packs/datatables` | Format DataTables |
| `GET` | `/packs/{pack}/show` | Détail |
| `POST` | `/packs/store` | Créer |
| `PUT` | `/packs/{pack}/update` | Modifier |
| `PUT` | `/packs/add/{pack}/modules` | Ajouter des modules au pack |
| `DELETE` | `/packs/{pack}/delete` | Supprimer |

---

### Offres de service

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/get/offers/license/collection` | Liste complète |
| `GET` | `/get/offers/license/datatable` | Format DataTables |
| `GET` | `/get/{offer}/offer/license/detail` | Détail avec modules + prix |
| `POST` | `/store/offer/license` | Créer (statut initial : `DRAFT`) |
| `PATCH` | `/update/{offer}/offer/license` | Modifier |
| `PATCH` | `/active/{offer}/offer/license` | Activer/Suspendre |
| `DELETE` | `/delete/{offer}/offer/license` | Supprimer |
| `GET` | `/get/{offer}/pricings/offers/license` | Liste des tarifs d'une offre |
| `GET` | `/get/{pricing}/pricing/offers/license` | Détail d'un tarif |
| `POST` | `/add/{offer}/pricing/offers/license` | Ajouter un ou plusieurs tarifs |
| `PATCH` | `/update/{pricing}/pricing/offers/license` | Modifier un tarif |
| `DELETE` | `/delete/{pricing}/pricing/offers/license` | Supprimer un tarif |
| `GET` | `/get/pricing/offers/license/filter` | Filtrer par application + fréquence + serveur |

**Corps de création (`POST /store/offer/license`) :**
```json
{
  "offer_type_id": "uuid",
  "application_id": "uuid",
  "contact_application_id": "uuid (optionnel, offre personnalisée)",
  "is_custom": false,
  "options": {},
  "currency_id": "uuid",
  "prices": [
    { "price": 29.99, "frequency": "monthly", "server": "shared" },
    { "price": 299.99, "frequency": "yearly", "server": "shared" }
  ],
  "modules": [
    { "application_module_id": "uuid", "price": 0, "permissions": ["shop.sale.view"] }
  ]
}
```

---

### API Publique (sans auth admin)

Ces routes sont accessibles sans `auth:api`. Elles sont destinées aux applications tierces.

| Méthode | URL | Description |
|---|---|---|
| `GET` | `/public-licences/licenses` | Liste des licences publiques |
| `POST` | `/public-licences/licenses/validate` | Valider une clé de licence |
| `POST` | `/public-licences/licenses/activate` | Activer une licence sur une entité |
| `POST` | `/public-licences/licenses/deactivate` | Désactiver une licence pour une entité |
| `POST` | `/public-licences/licenses/verify-token` | Vérifier un token JWT |

**`POST /public-licences/licenses/validate` :**
```json
{
  "license_key": "XXXX-XXXX-XXXX-XXXX",
  "include_token": true,
  "include_permissions": true
}
```

**`POST /public-licences/licenses/activate` :**
```json
{
  "license_key": "XXXX-XXXX-XXXX-XXXX",
  "external_entity_id": "installation-uuid",
  "external_entity_type": "server",
  "hostname": "server.example.com",
  "metadata": {},
  "include_token": true,
  "include_permissions": true
}
```

**Codes de retour validation :**
| Code | Signification |
|---|---|
| `VALID` | Licence valide |
| `LICENSE_NOT_FOUND` | Clé inconnue |
| `LICENSE_REVOKED` | Révoquée |
| `LICENSE_SUSPENDED` | Suspendue |
| `LICENSE_EXPIRED` | Expirée |
| `LICENSE_NOT_STARTED` | Date de début future |

**Codes de retour activation :**
| Code | Signification |
|---|---|
| `ACTIVATED` | Nouvelle activation créée |
| `ALREADY_ACTIVATED` | Entité déjà activée (idempotent) |
| `ACTIVATION_LIMIT_REACHED` | Limite atteinte |
| `LICENSE_INVALID` | Licence non activable |

---

### Distributeur externe

| Méthode | URL | Description |
|---|---|---|
| `POST` | `/distributor/licenses` | Créer une licence via distributeur (statut initial : `ACTIVE`) |

**Corps (`POST /distributor/licenses`) :**
```json
{
  "name": "...",
  "contact_id": "uuid",
  "offer_id": "uuid (ServiceOffer.id, pas ServiceOfferPrice.id)",
  "duration": 12,
  "duration_type": "monthly",
  "starts_at": "2026-01-01T00:00:00Z"
}
```

---

### Routes utilitaires (Enums)

| Méthode | URL | Retourne |
|---|---|---|
| `GET` | `/server/type/enum` | `ServerEnum::options()` |
| `GET` | `/offer/frequency/enum` | `OfferFrequencyEnum::options()` |
| `GET` | `/license/status/enum` | `LicenseStatus::options()` |
| `GET` | `/offer/status/enum` | `ServiceOfferStatusEnum::options()` |
| `GET` | `/subscription/deployment-type/enum` | `ContactApplicationDeploymentType::options()` |

---

## 7. Système d'authentification par clé API

### Middleware `AuthenticateApiKey`

Lit les en-têtes `X-Api-Key` (clé publique) et `X-Api-Secret` (clé secrète), les valide via `ApiKeyService::validateWithContactApplication()`, et attache au `Request` :

```php
$request->merge([
    'license_api_key'             => $apiKey,
    'license_contact_application' => $result['contact_application'],
    'license_contact'             => $result['contact'],
]);
```

**Chaîne de validation :**
1. Clés présentes dans les headers
2. `ContactApplicationApiKey` existe et est valide (non révoquée, non expirée)
3. `ContactApplication` est active
4. `Application` est active
5. `Contact` existe
6. Marque la clé comme utilisée (`last_used_at`)

**Note :** la restriction de domaine (`isDomainAllowed`) est implémentée dans le code mais **commentée** (à activer selon les besoins).

---

## 8. Système de tokens JWT custom

### Architecture

Le module implémente son propre format de token JWT (non standard `:jwt`) avec `typ: "LIC"`.

**Algorithme :** HMAC-SHA256 (HS256)
**Clé de signature :** `ContactApplication.signing_key` (32 bytes hex, générée lazy)
**Format :** `base64url(header).base64url(payload).base64url(signature)`

### Cycle de vie du token

```
generate(License) → find ContactApplication by (contact_id + application_id)
                 → get/create signing_key
                 → build header + payload
                 → sign(HS256) → JWT string
```

### Utilisation

Le token est optionnellement retourné par les endpoints publics (`include_token: true`).

La vérification se fait via `POST /public-licences/licenses/verify-token` avec le token et la `ContactApplication` injectée depuis le middleware `AuthenticateApiKey`.

---

## 9. Enums

| Enum | Valeurs |
|---|---|
| `LicenseStatus` | `pending`, `active`, `expired`, `revoked`, `suspended` |
| `ContactApplicationStatus` | `active`, `suspended`, `pending` |
| `ContactApplicationDeploymentType` | `saas`, `dedicated` |
| `ServiceOfferStatusEnum` | `DRAFT`, `ACTIVE`, `SUSPENDED` |
| `OfferFrequencyEnum` | `monthly`, `yearly` |
| `ServerEnum` | `shared`, `dedicated` |

Tous les enums exposent `options()`, `optionsInArray()`, `getOption()`, `toLocaleFr()`, `toLocaleEn()`, `color()`.

---

## 10. Événements et Jobs

### Événements

| Événement | Déclenché par | Payload |
|---|---|---|
| `LicenseCreated` | `LicenseService::create()`, `createForDistributor()` | `License` |
| `LicenseActivated` | `LicenseService::activate()` | `License`, `LicenseActivation` |
| `LicenseDeactivated` | `LicenseService::deactivate()` | `License`, `LicenseActivation` |
| `LicenseRevoked` | `LicenseService::revoke()` | `License` |
| `LicenseSuspended` | `LicenseService::suspend()` | `License` |
| `LicenseUnsuspended` | `LicenseService::unsuspend()` | `License` |
| `ApiKeyGenerated` | `ApiKeyService::generate()` | `ContactApplicationApiKey`, `ContactApplication` |
| `ApiKeyRevoked` | `ApiKeyService::revoke()` | `ContactApplicationApiKey` |

---

### Jobs

#### `SendLicenseByEmailJob`

Envoi asynchrone de la licence par e-mail. Dispatché par `LicenseService::sendLicenseByMail()`.

Variables supportées dans le template :
- `{LICENSE_KEY}` → `$license->license_key`
- `{APP_NAME}` → `$license->offer->offer->application->name`

Utilise `SendLicenseMail` (Mailable) + vue Blade `sendLicenseMail.blade.php`.

---

#### `DispatchLicenseWebhookJob`

Dispatch de webhook lors d'événements de licence. Utilise le `webhook_secret` de la `ContactApplication` pour signer les payloads.

---

## 11. Tests

| Fichier | Couverture |
|---|---|
| `LicenseTest.php` | CRUD licences, activation, révocation, suspension, envoi mail |
| `LicenseUsageTest.php` | Suivi des usages, calcul `ended_at` |
| `PublicLicenseTest.php` | Validation, activation/désactivation publiques, vérification token |
| `ServiceOfferTest.php` | CRUD offres, gestion des tarifs et modules |

**Factories disponibles :**
`LicenseFactory`, `ContactApplicationFactory`, `LicenseDurationFactory`, `LicenseTypeFactory`, `OfferFactory`, `OfferTypeFactory`, `PackFactory`, `PricingFactory`, `ServiceOfferFactory`, `ServiceOfferPriceFactory`

---

## 12. Conventions et patterns

### Snapshot d'offre (LicenseOffer)

À la création d'une licence, l'offre est **gelée** dans `LicenseOffer.modules` (JSON). Cela garantit l'immuabilité des conditions contractuelles même si l'offre source est modifiée. Les dépendances transitives de modules sont résolues à ce moment via `ModuleDependencyResolver::enrichModules()` et marquées `is_dependency: true`.

### Idempotence de l'activation

`LicenseService::activate()` est idempotente : si l'entité externe est déjà activée, la méthode touche `last_validated_at` et retourne `ALREADY_ACTIVATED` sans créer de doublon.

### Suppression en cascade (License)

```
license.modules()     → delete()
license.usages()      → delete()
license.activations() → delete()
license.offer()       → delete()
license               → delete()
```

### Calcul de `ended_at`

Dépend de la fréquence stockée dans `LicenseOffer.pricings.frequency` :
- `monthly` → `addMonths($duration)`
- `yearly` → `addYears($duration)`
- autre → `addDays($duration)`

### Route distributeur vs route admin

| Aspect | Admin (`POST /licenses`) | Distributeur (`POST /distributor/licenses`) |
|---|---|---|
| Auth | `auth:api` | Aucune (route publique) |
| Input `offer_id` | `ServiceOfferPrice.id` | `ServiceOffer.id` |
| Statut initial | `pending` | `active` |
| Pricing sélectionné | Depuis le `ServiceOfferPrice` | Premier pricing de l'offre |

### Journalisation

L'envoi de licence par mail est journalisé via `UserLogService` avec l'action `ActionNameEnum::SEND_LICENSE`.
