# 🏗️ Spécification Complète — Module Abonnement & Paiement FedaPay (Sails.js)

> **Objectif** : Ce document est une spécification technique exhaustive, prête à être utilisée comme prompt pour un agent IA. Elle décrit l'intégralité du système d'abonnement avec paiement via FedaPay, tel qu'implémenté et éprouvé en production dans le projet Corossol. L'agent de destination doit pouvoir reproduire ce système dans n'importe quel projet Sails.js.

---

## Table des matières

1. [Architecture Générale](#1-architecture-générale)
2. [Dépendances](#2-dépendances)
3. [Variables d'Environnement](#3-variables-denvironnement)
4. [Configuration](#4-configuration)
5. [Modèles de Données (5 modèles)](#5-modèles-de-données)
6. [Helper FedaPay — Initialisation Transaction](#6-helper-fedapay)
7. [Controllers Abonnement (7 endpoints)](#7-controllers-abonnement)
8. [Controllers Offre Abonnement (2 endpoints)](#8-controllers-offre-abonnement)
9. [Controller Promotion — Validation](#9-controller-promotion)
10. [Controllers FedaPay — Webhook & Return](#10-controllers-fedapay)
11. [Helper Envoi Notifications Abonnement](#11-helper-envoi-notifications)
12. [Routes à Déclarer](#12-routes)
13. [Policies (Sécurité)](#13-policies)
14. [Flux Complet — Diagramme](#14-flux-complet)

---

## 1. Architecture Générale

```mermaid
flowchart TD
    A["Client Mobile/Web"] -->|"POST /api/abonnement/create"| B["Backend Sails.js"]
    B -->|"Crée Abonnement statut 'en_attente_paiement'"| C["Base de données"]
    A -->|"POST /api/abonnement/init-payment"| D["Controller init-payment"]
    D -->|"Appel Helper"| E["fedapay-init-transaction"]
    E -->|"SDK ou REST API"| F["API FedaPay"]
    F -->|"Retourne paymentUrl"| D
    D -->|"paymentUrl"| A
    A -->|"Redirige vers paymentUrl"| G["Page Paiement FedaPay"]
    G -->|"Paiement effectué"| H["Webhook FedaPay"]
    H -->|"POST /api/fedapay/webhook"| I["Controller webhook"]
    I -->|"Met à jour Abonnement → actif"| C
    I -->|"Crée TransactionAbonnement"| C
    I -->|"Crée NotificationAbonnement"| C
    I -->|"Envoie Email/WhatsApp/SMS"| J["Canaux de notification"]
    G -->|"Redirect return_url"| K["Controller return"]
    K -->|"Page HTML + deeplink"| A
```

**Principes clés** :
- L'abonnement est créé avec le statut `en_attente_paiement`
- Le paiement est initié via FedaPay (SDK ou REST fallback)
- La confirmation arrive via **webhook** (source de vérité)
- Une page de retour (`return`) avec deeplink redirige l'utilisateur vers l'app mobile
- Toutes les transitions de statut sont contrôlées (machine à états)

---

## 2. Dépendances

```bash
npm install axios google-auth-library sanitize-html
# Optionnel — Le SDK FedaPay est chargé dynamiquement si disponible
npm install fedapay  # Optionnel, fallback REST via axios
```

> **Important** : Le helper `fedapay-init-transaction` tente d'abord d'utiliser le SDK `fedapay` (si installé). En cas d'échec ou d'absence, il bascule automatiquement sur l'API REST via `axios`. Les deux chemins sont entièrement fonctionnels.

---

## 3. Variables d'Environnement

```env
# === FedaPay ===
FEDAPAY_ENV=sandbox                            # 'sandbox' ou 'live'
FEDAPAY_API_KEY=sk_sandbox_XXXXXXXXXXXXXXX     # Clé secrète FedaPay
FEDAPAY_RETURN_URL=https://mondomaine.com/api/fedapay/return   # Page de retour web
FEDAPAY_CALLBACK_URL=https://mondomaine.com/api/fedapay/webhook # Webhook callback

# === Optionnel (si tu as du Firebase pour notif push) ===
FIREBASE_PROJECT_ID=mon-projet-id
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@xxx.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
```

---

## 4. Configuration

### `config/external-apis.js`

```javascript
require('dotenv').config();

module.exports.externalApis = {
  // ... autres configs (mailjet, twilio, etc.)

  fedapay: {
    env: process.env.FEDAPAY_ENV || 'sandbox',
    apiKey: process.env.FEDAPAY_API_KEY,
    returnUrl: process.env.FEDAPAY_RETURN_URL,
    callbackUrl: process.env.FEDAPAY_CALLBACK_URL
  }
};
```

---

## 5. Modèles de Données

### 5.1 `api/models/OffreAbonnement.js` — Catalogue des offres

```javascript
const crypto = require('crypto');
const shortId = (prefix) => `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;

module.exports = {
  tableName: 'offre_abonnement',

  attributes: {
    codeOffre: {
      type: 'string', columnName: 'code_offre', unique: true
    },
    nom: {
      type: 'string', columnName: 'nom', required: true
    },
    description: {
      type: 'string', columnName: 'description', allowNull: true
    },
    typeAbonnement: {
      type: 'string', columnName: 'type_abonnement', required: true,
      isIn: ['gratuit', 'mensuel', 'trimestriel', 'semestriel', 'annuel', 'essai']
    },
    dureeJours: {
      type: 'number', columnName: 'duree_jours', required: true
    },
    montant: {
      type: 'number', columnName: 'montant', required: true
    },
    devise: {
      type: 'string', columnName: 'devise', defaultsTo: 'XOF'
    },
    fonctionnalites: {
      type: 'json', columnName: 'fonctionnalites', defaultsTo: []
    },
    actif: {
      type: 'boolean', columnName: 'actif', defaultsTo: true
    },
    dateDebutValidite: {
      type: 'ref', columnName: 'date_debut_validite'
    },
    dateFinValidite: {
      type: 'ref', columnName: 'date_fin_validite'
    },
    ordreAffichage: {
      type: 'number', columnName: 'ordre_affichage', defaultsTo: 0
    },
    couleur: {
      type: 'string', columnName: 'couleur', allowNull: true
    },
    icone: {
      type: 'string', columnName: 'icone', allowNull: true
    },
    // Relations
    abonnements: { collection: 'abonnement', via: 'offre' }
  },

  beforeCreate: function (values, cb) {
    if (!values.codeOffre) {
      values.codeOffre = shortId('OFFRE_');
    }
    cb();
  }
};
```

---

### 5.2 `api/models/Abonnement.js` — Abonnement d'un utilisateur

```javascript
const crypto = require('crypto');
const shortId = (prefix) => `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;

module.exports = {
  tableName: 'abonnement',

  attributes: {
    codeAbonnement: {
      type: 'string', columnName: 'code_abonnement', unique: true, required: true
    },
    // ══════ ADAPTE CES CHAMPS À TON CONTEXTE ══════
    // Dans Corossol c'est codeParent/codeEcole.
    // Dans ton projet, remplace par codeUser/codeTenant ou userId etc.
    codeParent: {
      type: 'string', columnName: 'code_parent', required: true
    },
    codeEcole: {
      type: 'string', columnName: 'code_ecole', required: true
    },
    // ═══════════════════════════════════════════════
    typeAbonnement: {
      type: 'string', columnName: 'type_abonnement', required: true,
      isIn: ['gratuit', 'mensuel', 'trimestriel', 'semestriel', 'annuel', 'essai']
    },
    statut: {
      type: 'string', columnName: 'statut',
      isIn: ['actif', 'expire', 'suspendu', 'annule', 'en_attente_paiement', 'essai'],
      defaultsTo: 'en_attente_paiement'
    },
    dateDebut: { type: 'ref', columnName: 'date_debut', required: true },
    dateFin: { type: 'ref', columnName: 'date_fin', required: true },
    montant: { type: 'number', columnName: 'montant', required: true },
    devise: { type: 'string', columnName: 'devise', defaultsTo: 'XOF' },

    // Association vers l'offre
    offre: { model: 'offreabonnement', columnName: 'offre_id' },
    codeOffre: { type: 'string', columnName: 'code_offre', allowNull: true },

    // Association vers Promotion (optionnelle)
    promotion: { model: 'promotionabonnement', columnName: 'promotion_id' },
    codePromotion: { type: 'string', columnName: 'code_promotion', allowNull: true },

    montantReduction: { type: 'number', columnName: 'montant_reduction', defaultsTo: 0 },
    montantFinal: { type: 'number', columnName: 'montant_final', required: true },

    methodePaiement: {
      type: 'string', columnName: 'methode_paiement',
      isIn: ['mobile_money', 'carte_bancaire', 'virement', 'especes', 'gratuit', 'fedapay'],
      allowNull: true
    },
    referencePaiement: { type: 'string', columnName: 'reference_paiement', allowNull: true },
    datePaiement: { type: 'ref', columnName: 'date_paiement' },

    renouvellementAuto: { type: 'boolean', columnName: 'renouvellement_auto', defaultsTo: false },
    dateRenouvellement: { type: 'ref', columnName: 'date_renouvellement' },
    abonnementPrecedent: { type: 'string', columnName: 'abonnement_precedent', allowNull: true },
    commentaires: { type: 'string', columnName: 'commentaires', allowNull: true },

    // Relations
    transactions: { collection: 'transactionabonnement', via: 'abonnement' },
    notifications: { collection: 'notificationabonnement', via: 'abonnement' }
  },

  beforeCreate: function (values, cb) {
    if (!values.codeAbonnement) values.codeAbonnement = shortId('ABO_');
    if (values.montant && values.montantReduction) {
      values.montantFinal = values.montant - values.montantReduction;
    } else if (values.montant) {
      values.montantFinal = values.montant;
    }
    cb();
  }
};
```

---

### 5.3 `api/models/TransactionAbonnement.js` — Historique des paiements

```javascript
const crypto = require('crypto');
const shortId = (prefix) => `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;

module.exports = {
  tableName: 'transaction_abonnement',

  attributes: {
    codeTransaction: { type: 'string', columnName: 'code_transaction', unique: true },
    abonnement: { model: 'abonnement', columnName: 'abonnement_id' },
    promotion: { model: 'promotionabonnement', columnName: 'promotion_id' },
    codeAbonnement: { type: 'string', columnName: 'code_abonnement' },
    codeParent: { type: 'string', columnName: 'code_parent', required: true },
    typeTransaction: {
      type: 'string', columnName: 'type_transaction', required: true,
      isIn: ['paiement', 'remboursement', 'annulation', 'renouvellement', 'reduction']
    },
    statut: {
      type: 'string', columnName: 'statut',
      isIn: [
        // Statuts internes
        'en_cours', 'reussi', 'echoue', 'annule', 'en_attente_confirmation',
        // Statuts FedaPay
        'approved', 'completed', 'paid', 'succeeded', 'success',
        'canceled', 'failed', 'expired', 'refused', 'declined', 'error',
        'pending', 'processing', 'refunded', 'partially_refunded'
      ],
      defaultsTo: 'en_cours'
    },
    montant: { type: 'number', columnName: 'montant', required: true },
    devise: { type: 'string', columnName: 'devise', defaultsTo: 'XOF' },
    methodePaiement: {
      type: 'string', columnName: 'methode_paiement', required: true,
      isIn: ['mobile_money', 'carte_bancaire', 'virement', 'especes', 'gratuit', 'fedapay']
    },
    referencePaiement: { type: 'string', columnName: 'reference_paiement' },
    referenceExterne: { type: 'string', columnName: 'reference_externe' },
    dateTransaction: { type: 'ref', columnName: 'date_transaction', required: true },
    dateConfirmation: { type: 'ref', columnName: 'date_confirmation' },
    operateur: {
      type: 'string', columnName: 'operateur', required: true,
      isIn: ['fedapay', 'stripe', 'paypal', 'mobile_money_local', 'autre']
    },
    operateurId: { type: 'string', columnName: 'operateur_id' },
    modePaiement: { type: 'string', columnName: 'mode_paiement' },
    numeroTelephone: { type: 'string', columnName: 'numero_telephone' },
    description: { type: 'string', columnName: 'description' },
    fraisTransaction: { type: 'number', columnName: 'frais_transaction', defaultsTo: 0 },
    receiptUrl: { type: 'string', columnName: 'receipt_url' },
    montantNet: { type: 'number', columnName: 'montant_net', required: true },
    codePromotion: { type: 'string', columnName: 'code_promotion' },
    montantReduction: { type: 'number', columnName: 'montant_reduction', defaultsTo: 0 },
    commentaires: { type: 'string', columnName: 'commentaires' },
    donneesTechniques: { type: 'json', columnName: 'donnees_techniques', defaultsTo: {} },
    codeEcole: { type: 'string', columnName: 'code_ecole', required: true }
  },

  beforeCreate: function (values, cb) {
    if (!values.codeTransaction) values.codeTransaction = shortId('TXN_');
    if (values.montant && values.fraisTransaction) {
      values.montantNet = values.montant - values.fraisTransaction;
    } else if (values.montant) {
      values.montantNet = values.montant;
    }
    if (!values.dateTransaction) values.dateTransaction = new Date();
    cb();
  }
};
```

---

### 5.4 `api/models/PromotionAbonnement.js` — Codes promo

```javascript
const crypto = require('crypto');
const shortId = (prefix) => `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;

module.exports = {
  tableName: 'promotion_abonnement',

  attributes: {
    codePromotion: { type: 'string', columnName: 'code_promotion', unique: true },
    nom: { type: 'string', columnName: 'nom', required: true },
    description: { type: 'string', columnName: 'description', allowNull: true },
    typeReduction: {
      type: 'string', columnName: 'type_reduction', required: true,
      isIn: ['pourcentage', 'montant_fixe', 'gratuit']
    },
    valeurReduction: { type: 'number', columnName: 'valeur_reduction', required: true },
    devise: { type: 'string', columnName: 'devise', defaultsTo: 'XOF' },
    codeEcole: { type: 'string', columnName: 'code_ecole', allowNull: true },
    typesAbonnementApplicables: { type: 'json', columnName: 'types_abonnement_applicables', defaultsTo: [] },
    dateDebut: { type: 'ref', columnName: 'date_debut', required: true },
    dateFin: { type: 'ref', columnName: 'date_fin', required: true },
    nombreUtilisationsMax: { type: 'number', columnName: 'nombre_utilisations_max', allowNull: true },
    nombreUtilisationsActuelles: { type: 'number', columnName: 'nombre_utilisations_actuelles', defaultsTo: 0 },
    nombreUtilisationsParParent: { type: 'number', columnName: 'nombre_utilisations_par_parent', defaultsTo: 1 },
    parentsUtilises: { type: 'json', columnName: 'parents_utilises', defaultsTo: [] },
    actif: { type: 'boolean', columnName: 'actif', defaultsTo: true },
    montantMinimum: { type: 'number', columnName: 'montant_minimum', defaultsTo: 0 },
    montantMaximum: { type: 'number', columnName: 'montant_maximum', allowNull: true },
    codeOffreApplicable: { type: 'string', columnName: 'code_offre_applicable', allowNull: true },
    couleur: { type: 'string', columnName: 'couleur', allowNull: true },

    // Relations
    transactions: { collection: 'transactionabonnement', via: 'promotion' },
    abonnements: { collection: 'abonnement', via: 'promotion' }
  },

  beforeCreate: function (values, cb) {
    if (!values.codePromotion) values.codePromotion = shortId('PROMO_');
    cb();
  }
};
```

---

### 5.5 `api/models/NotificationAbonnement.js` — Historique de notifications

```javascript
const crypto = require('crypto');
const shortId = (prefix) => `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;

module.exports = {
  tableName: 'notification_abonnement',

  attributes: {
    codeNotification: { type: 'string', columnName: 'code_notification', unique: true },
    abonnement: { model: 'abonnement', columnName: 'abonnement_id' },
    codeAbonnement: { type: 'string', columnName: 'code_abonnement', required: true },
    codeParent: { type: 'string', columnName: 'code_parent', required: true },
    typeNotification: {
      type: 'string', columnName: 'type_notification', required: true,
      isIn: [
        'expiration_proche', 'expiration', 'renouvellement_auto',
        'paiement_reussi', 'paiement_echoue', 'offre_speciale',
        'promotion_disponible', 'essai_commence', 'essai_fin',
        'suspension', 'reactivation', 'abonnement_active', 'annulation'
      ]
    },
    titre: { type: 'string', columnName: 'titre', required: true },
    message: { type: 'string', columnName: 'message', required: true },
    statut: {
      type: 'string', columnName: 'statut',
      isIn: ['envoyee', 'lue', 'non_lue', 'echoue'], defaultsTo: 'non_lue'
    },
    dateEnvoi: { type: 'ref', columnName: 'date_envoi', required: true },
    dateLecture: { type: 'ref', columnName: 'date_lecture' },
    canauxEnvoi: {
      type: 'json', columnName: 'canaux_envoi', defaultsTo: ['push', 'email', 'sms']
    },
    statutEnvoi: {
      type: 'json', columnName: 'statut_envoi',
      defaultsTo: { push: 'non_envoye', email: 'non_envoye', sms: 'non_envoye' }
    },
    donneesSupplementaires: { type: 'json', columnName: 'donnees_supplementaires', defaultsTo: {} },
    priorite: {
      type: 'string', columnName: 'priorite',
      isIn: ['basse', 'normale', 'haute', 'urgente'], defaultsTo: 'normale'
    },
    actionRequise: { type: 'boolean', columnName: 'action_requise', defaultsTo: false },
    actionType: { type: 'string', columnName: 'action_type' },
    actionUrl: { type: 'string', columnName: 'action_url' },
    nombreTentatives: { type: 'number', columnName: 'nombre_tentatives', defaultsTo: 0 },
    dateDerniereTentative: { type: 'ref', columnName: 'date_derniere_tentative' },
    erreurEnvoi: { type: 'string', columnName: 'erreur_envoi' },
    codeEcole: { type: 'string', columnName: 'code_ecole', required: true }
  },

  beforeCreate: function (values, cb) {
    if (!values.codeNotification) values.codeNotification = shortId('NOTIF_');
    if (!values.dateEnvoi) values.dateEnvoi = new Date();
    cb();
  }
};
```

---

## 6. Helper FedaPay — Initialisation Transaction

### `api/helpers/fedapay-init-transaction.js`

**Rôle** : Créer une transaction FedaPay et retourner l'URL de paiement.

**Stratégie dual-mode** :
1. Tente le SDK `fedapay` (si installé)
2. Fallback automatique sur l'API REST via `axios`

```javascript
module.exports = {
  friendlyName: 'Init FedaPay transaction',

  inputs: {
    abonnement: { type: 'ref', required: true },
    utilisateur: { type: 'ref', required: true },
    offre: { type: 'ref', required: true },
    returnUrl: { type: 'string' },
    callbackUrl: { type: 'string' }
  },

  exits: {
    success: { description: 'Transaction initialisée' },
    error: { description: 'Erreur FedaPay' }
  },

  fn: async function ({ abonnement, utilisateur, offre, returnUrl, callbackUrl }, exits) {
    try {
      const cfg = sails.config.externalApis.fedapay || {};
      const env = (cfg.env || 'sandbox').toLowerCase() === 'live' ? 'live' : 'sandbox';
      const apiKey = cfg.apiKey;
      if (!apiKey) throw new Error('FEDAPAY_API_KEY manquant');

      // ── Normalisation URLs ──
      const isHttp = (u) => typeof u === 'string' && /^https?:\/\//i.test(u);
      const cbUrl = isHttp(callbackUrl) ? callbackUrl : (isHttp(cfg.callbackUrl) ? cfg.callbackUrl : undefined);
      const baseReturn = isHttp(returnUrl) ? returnUrl : (isHttp(cfg.returnUrl) ? cfg.returnUrl : undefined);
      // IMPORTANT : injecter codeAbonnement dans l'URL de retour
      const retUrl = baseReturn 
        ? `${baseReturn}${baseReturn.includes('?') ? '&' : '?'}codeAbonnement=${encodeURIComponent(abonnement.codeAbonnement)}` 
        : undefined;

      // ── Normalisation téléphone (E.164) ──
      const mapIndicatifToCountry = (indicatif) => {
        const map = { '229': 'BJ', '225': 'CI', '228': 'TG', '221': 'SN', '223': 'ML', '226': 'BF', '224': 'GN', '227': 'NE', '237': 'CM' };
        return map[indicatif || ''] || undefined;
      };
      const numberRaw = (utilisateur.telephone1 || '').trim();
      const indicatifDigits = (utilisateur.indicatif1 || '').toString().replace(/\D/g, '');
      let cleaned = numberRaw.replace(/\s+/g, '');
      // ... normalisation E.164 identique au projet source
      const e164Regex = /^\+[1-9]\d{6,14}$/;
      const country = mapIndicatifToCountry(indicatifDigits);
      const phoneObject = country && e164Regex.test(cleaned)
        ? { number: cleaned, country: country.toLowerCase() }
        : undefined;

      // ── Montant & devise ──
      const currency = ((abonnement.devise || offre.devise || 'XOF').toUpperCase());
      const amount = Math.round(+abonnement.montantFinal);
      if (!amount || amount < 1) throw new Error('Montant invalide pour FedaPay');

      const firstName = (utilisateur.prenom || utilisateur.nom || 'Client').toString();
      const lastName = (utilisateur.nom || '').toString();
      const email = utilisateur.email || undefined;

      const metadata = {
        codeAbonnement: abonnement.codeAbonnement,
        codeParent: abonnement.codeParent,
        codeEcole: abonnement.codeEcole,
        codeOffre: abonnement.codeOffre,
        reference: `FDY_${abonnement.codeAbonnement}`
      };

      // ══════════════════════════════════════════
      // TENTATIVE 1 : SDK FedaPay
      // ══════════════════════════════════════════
      let FedaPay = null;
      try { FedaPay = require('fedapay'); } catch (e) { FedaPay = null; }
      if (FedaPay && FedaPay.Transaction) {
        try {
          FedaPay.setApiKey(apiKey);
          FedaPay.setEnvironment(env);
          
          let customer = null;
          try {
            customer = await FedaPay.Customer.create({
              firstname: firstName, lastname: lastName || 'Client',
              email, phone_number: phoneObject
            });
          } catch (e) {
            customer = await FedaPay.Customer.create({
              firstname: firstName, lastname: lastName || 'Client', email
            });
          }

          const trx = await FedaPay.Transaction.create({
            amount,
            currency: { iso: currency },
            description: `Abonnement ${offre.nom} (${abonnement.codeAbonnement})`,
            callback_url: cbUrl,
            return_url: retUrl,
            merchant_reference: metadata.reference,
            custom_metadata: metadata,
            customer: customer?.id
          });

          const paymentUrl = trx?.url || trx?.payment_url || trx?.redirect_url;
          if (!paymentUrl) throw new Error('URL de paiement introuvable (SDK)');
          return exits.success({ paymentUrl, fedapayTransactionId: trx?.id, reference: metadata.reference });
        } catch (sdkErr) {
          sails.log.warn('SDK FedaPay indisponible, fallback REST:', sdkErr.message);
        }
      }

      // ══════════════════════════════════════════
      // TENTATIVE 2 : API REST (Fallback)
      // ══════════════════════════════════════════
      const axios = require('axios');
      const base = env === 'live' ? 'https://api.fedapay.com/v1' : 'https://sandbox-api.fedapay.com/v1';
      const headers = { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' };

      const payload = {
        description: `Abonnement ${offre.nom} (${abonnement.codeAbonnement})`,
        amount, currency: { iso: currency },
        callback_url: cbUrl, return_url: retUrl,
        merchant_reference: metadata.reference,
        custom_metadata: metadata,
        customer: { firstname: firstName, lastname: lastName || 'Client', email, phone_number: phoneObject }
      };

      const resp = await axios.post(`${base}/transactions`, payload, { headers });
      const data = resp.data || {};
      const trxObj = data?.transaction || data?.['v1/transaction'] || data;
      let paymentUrl = trxObj?.payment_url || trxObj?.url;
      let fedapayTransactionId = trxObj?.id || data?.id;

      // Si pas d'URL directe, générer un token
      if (!paymentUrl && fedapayTransactionId) {
        const tokenResp = await axios.post(`${base}/transactions/${fedapayTransactionId}/token`, {}, { headers });
        const tokenTrx = tokenResp.data?.transaction || tokenResp.data?.['v1/transaction'] || tokenResp.data;
        paymentUrl = tokenTrx?.payment_url || tokenTrx?.url;
      }
      if (!paymentUrl) throw new Error('URL de paiement introuvable (REST)');

      return exits.success({ paymentUrl, fedapayTransactionId, reference: metadata.reference });
    } catch (e) {
      sails.log.error('FedaPay init error:', e.response?.data || e.message);
      return exits.error(new Error(e.response?.data ? JSON.stringify(e.response.data) : e.message));
    }
  }
};
```

---

## 7. Controllers Abonnement

### 7.1 `POST /api/abonnement/create` — Créer un abonnement

**Logique** :
1. Vérifier que l'offre existe et est active
2. Vérifier qu'aucun abonnement actif n'existe déjà pour cet utilisateur
3. Si offre d'essai : vérifier que l'utilisateur n'a jamais eu d'essai
4. Si un abonnement `en_attente_paiement` existe pour la même offre, le réutiliser
5. Valider le code promo si fourni (validité temporelle, nb utilisations, compatibilité offre)
6. Calculer `montantFinal = montant - montantReduction`
7. Déterminer le statut initial : `en_attente_paiement` / `essai` / `actif` (si gratuit)
8. Optionnel : paiement inline si les infos de paiement sont fournies directement

**Inputs** : `codeOffre` (requis), `codePromotion`, `methodePaiement`, `montant`, `devise`, `referencePaiement`, `renouvellementAuto`

---

### 7.2 `POST /api/abonnement/init-payment` — Initier le paiement FedaPay

**Logique** :
1. Charger l'abonnement par `codeAbonnement`
2. Charger l'utilisateur et l'offre associés
3. Appeler `sails.helpers.fedapayInitTransaction` avec les données
4. Retourner `{ paymentUrl, fedapayTransactionId, reference }`

**Inputs** : `codeAbonnement` (requis), `returnUrl` (optionnel)

---

### 7.3 `POST /api/abonnement/process-payment` — Traiter un paiement manuel

Pour les paiements hors FedaPay (espèces, virement, mobile_money direct).

**Logique** :
1. Vérifier l'abonnement existe et peut être payé (`en_attente_paiement` ou `essai`)
2. Vérifier le montant (tolérance de 100 unités)
3. Créer la transaction
4. Activer l'abonnement + recaler les dates (dateDebut = maintenant, dateFin = maintenant + duréeJours)
5. Créer et envoyer une notification

---

### 7.4 `GET /api/abonnement/find` — Lister les abonnements

Filtres : `statut`, `typeAbonnement`, `dateDebut`, `dateFin`, pagination (`limit`, `skip`).
Enrichit les résultats avec l'offre, le parent et l'école.

---

### 7.5 `GET /api/abonnement/find-one` — Détails d'un abonnement

Retourne l'abonnement complet avec : offre, promotion, transactions (historique), notifications (10 dernières), parent, résumé (total payé, dernière transaction).

---

### 7.6 `GET /api/abonnement/check-status` — Vérifier et MAJ statut

**Logique automatique** :
- Si `actif` et `dateFin < maintenant` → passe à `expire`
- Si `actif` et `dateFin - 7 jours < maintenant` → notification `expiration_proche`
- Si `essai` et `dateFin < maintenant` → passe à `expire`

---

### 7.7 `PUT /api/abonnement/update` — Mise à jour administrative

Machine à états pour les transitions autorisées :
```
en_attente_paiement → actif, annule
essai → actif, expire, annule
actif → suspendu, expire, annule
suspendu → actif, expire, annule
expire → actif, annule
annule → (aucune transition)
```

---

## 8. Controllers Offre Abonnement

### 8.1 `POST /api/offre-abonnement/create` — Créer une offre

**Inputs** : `nom`, `description`, `typeAbonnement`, `dureeJours`, `montant`, `devise`, `fonctionnalites`, `ordreAffichage`, `couleur`, `icone`

### 8.2 `GET /api/offre-abonnement/find` — Lister les offres

Filtre par `typeAbonnement`, `actif`, `montantMin/Max`, `devise`.
Vérifie automatiquement la validité temporelle (`dateDebutValidite`/`dateFinValidite`).
**Pour les parents** : masque les offres d'essai si l'utilisateur en a déjà bénéficié.

---

## 9. Controller Promotion

### `POST /api/promotion-abonnement/validate` — Valider un code promo

Validations en cascade :
1. Le code existe
2. Il est actif
3. Il est dans sa période de validité
4. Le nombre max d'utilisations n'est pas atteint
5. Le parent ne l'a pas déjà utilisé (par parent)
6. Compatible avec l'offre ciblée
7. Le montant est dans la fourchette autorisée

Retourne : `montantOriginal`, `montantReduction`, `montantFinal`, `pourcentageReduction`

---

## 10. Controllers FedaPay

### 10.1 `POST /api/fedapay/webhook` — Webhook de confirmation

> [!CAUTION]
> **C'est le fichier le plus critique**. C'est la source de vérité pour confirmer les paiements.

**Logique complète** :
1. **Rate limiting** (optionnel avec Redis) : max 10 webhooks/minute/IP
2. **Vérification signature** (si `webhookSecret` configuré)
3. **Récupérer la transaction FedaPay** via l'API GET pour fiabiliser les données
4. **Normaliser les métadonnées** : FedaPay utilise `custom_metadata` et `metadata` avec des noms en `snake_case` → normaliser en `camelCase`
5. **Récupérer l'abonnement** par `codeAbonnement + codeEcole`
6. **Mapper la méthode de paiement** FedaPay vers les statuts internes :
   - `moov`, `mtn`, `orange` → `mobile_money`
   - `visa`, `mastercard` → `carte_bancaire`
   - Défaut → `fedapay`
7. **Si succès** (`approved`, `completed`, `paid`, `succeeded`, `success`) :
   - Créer la `TransactionAbonnement`
   - Activer l'abonnement (recaler dateDebut/dateFin)
   - Créer une `NotificationAbonnement` (`paiement_reussi`)
   - Envoyer immédiatement la notification (email + WhatsApp + SMS)
8. **Si échec** (`canceled`, `failed`, `expired`, `refused`, `declined`) :
   - Créer une transaction avec le statut d'échec
9. **Si pending** : retourne OK sans action

---

### 10.2 `GET /api/fedapay/return` — Page de retour après paiement

Génère une **page HTML responsive** avec :
- Affichage du statut (succès/échec/en cours)
- **Redirection automatique** vers l'app mobile via deeplink après 2 secondes
- Fallback si l'app n'est pas installée (instructions manuelles après 6s)

Le deeplink suit le format : `monapplication://payment/return?status=approved&codeAbonnement=ABO_XXXX`

---

## 11. Helper Envoi Notifications Abonnement

### `api/helpers/send-abonnement-notification.js`

Envoi multicanal (email, WhatsApp, SMS) selon le type de notification :
- `paiement_reussi` → Confirmation de paiement
- `expiration_proche` → Rappel d'expiration
- `expiration` → Abonnement expiré
- `essai_commence` → Début période d'essai
- `abonnement_active` → Activation

**Stratégie** :
1. Email via Mailjet (si email disponible)
2. WhatsApp via WaSender (si téléphone disponible)
3. SMS via Twilio (si WhatsApp échoue ET téléphone disponible)
4. Met à jour le statut d'envoi de la notification après chaque tentative

---

## 12. Routes à Déclarer

```javascript
// config/routes.js
module.exports.routes = {
  // ── Abonnement ──
  'POST   /api/abonnement/create':         'abonnement/create',
  'GET    /api/abonnement/find':           'abonnement/find',
  'GET    /api/abonnement/find-one':       'abonnement/find-one',
  'PUT    /api/abonnement/update':         'abonnement/update',
  'POST   /api/abonnement/init-payment':   'abonnement/init-payment',
  'POST   /api/abonnement/process-payment':'abonnement/process-payment',
  'GET    /api/abonnement/check-status':   'abonnement/check-status',

  // ── Offres d'abonnement ──
  'POST   /api/offre-abonnement/create':   'offre-abonnement/create',
  'GET    /api/offre-abonnement/find':     'offre-abonnement/find',

  // ── Promotion ──
  'POST   /api/promotion-abonnement/validate': 'promotion-abonnement/validate',

  // ── FedaPay (webhook = pas d'auth, return = pas d'auth) ──
  'POST   /api/fedapay/webhook':           'fedapay/webhook',
  'GET    /api/fedapay/return':            'fedapay/return',
};
```

---

## 13. Policies (Sécurité)

```javascript
// config/policies.js
module.exports.policies = {
  // Tous les endpoints abonnement nécessitent une authentification JWT
  'abonnement/*': ['isAuthenticated'],
  'offre-abonnement/*': ['isAuthenticated'],
  'promotion-abonnement/*': ['isAuthenticated'],

  // ⚠️ IMPORTANT : Le webhook FedaPay et la page de retour sont PUBLICS
  'fedapay/webhook': true,   // Pas d'auth (FedaPay appelle directement)
  'fedapay/return': true,    // Pas d'auth (page de retour web)
};
```

---

## 14. Flux Complet — Résumé Séquentiel

```
1. ADMIN crée les OffreAbonnement (catalogue)
2. ADMIN crée les PromotionAbonnement (optionnel)
3. USER appelle POST /api/abonnement/create { codeOffre, codePromotion? }
   → Abonnement créé en statut "en_attente_paiement"
4. USER appelle POST /api/abonnement/init-payment { codeAbonnement }
   → Backend crée la transaction FedaPay
   → Retourne { paymentUrl }
5. USER est redirigé vers paymentUrl (page de paiement FedaPay)
6. USER paie (mobile money, carte, etc.)
7. FedaPay appelle POST /api/fedapay/webhook { status, id }
   → Backend vérifie la transaction via API FedaPay
   → Crée TransactionAbonnement
   → Active l'abonnement
   → Envoie les notifications (email, WhatsApp, SMS)
8. USER est redirigé vers GET /api/fedapay/return
   → Page HTML avec deeplink vers l'app mobile
9. USER revient dans l'app → check-status confirme l'activation
```

---

> [!IMPORTANT]
> **Adaptations nécessaires pour ton projet** :
> - Remplace `codeParent` / `codeEcole` par tes propres identifiants utilisateur/tenant
> - Remplace `Parents` / `Utilisateurs` / `Ecoles` par tes modèles utilisateurs
> - Adapte le deeplink (`corossol://payment/return`) vers ton propre scheme
> - Configure les templates d'emails dans `config/abonnement-templates.js`
> - Assure-toi que les URLs `returnUrl` et `callbackUrl` sont HTTPS et accessibles publiquement
