# ADR-017 : Paiements en ligne via GeniusPay, abonnements prépayés

**Statut :** Accepté
**Date :** 2026-10-17
**Remplace :** ADR-017 du WES « Facturation des abonnements SaaS via Stripe Checkout » (`welely-engineering-system/decisions/017-stripe-subscription-billing.md`), dont les principes d'idempotence et de registre unique sont conservés.

---

## Contexte

Stripe n'est pas disponible en Côte d'Ivoire. L'intégration de l'ADR-017 du WES n'a jamais servi : en production, au 17/10/2026, aucune clé Stripe n'est configurée, aucune formule n'a de `stripe_price_id`, aucun paiement n'est passé par Stripe. Les trois clients payants sont facturés à la main, hors application.

GeniusPay (https://geniuspay.ci/docs/api) est un agrégateur ivoirien : Wave, Orange Money, MTN MoMo, Moov et carte, sur une page de paiement hébergée. Son API ne propose **que des paiements ponctuels**, sans prélèvement récurrent : le Mobile Money ne permet pas de débiter un client sans son accord à chaque fois. Sa doc ne décrit ni clé d'idempotence ni remboursement par API ; un webhook signé (HMAC-SHA256) confirme chaque paiement.

## Décision

### 1. Abonnement prépayé, renouvelé par le client

Un abonnement payant est une **période prépayée** d'un mois ou d'un an (`subscriptions.billing_cycle`). Payer ouvre ou prolonge la période :

- début = fin de la période en cours si elle n'est pas échue, sinon maintenant ;
- fin = début + 1 mois (`addMonthNoOverflow`) ou + 1 an.

Le client peut renouveler à tout moment ; une avance s'ajoute à la période en cours, rien n'est perdu. Le prix annuel est `plans.yearly_price` (« 2 mois offerts » sur la page d'accueil).

**Échéance** : rappels par e-mail à J-7, J-3 et le jour J, puis **7 jours de grâce** avec un bandeau dans l'application, puis statut `past_due`. Le middleware `CheckSubscription` existant bloque alors l'accès aux données, mais pas aux écrans de paiement. Aucune donnée n'est supprimée. Une inscription sur une formule payante reste en `trial` 3 jours en attendant le premier paiement, puis passe aussi en `past_due` : aujourd'hui, un essai n'expire jamais.

### 2. Changement de formule

- **Montée en gamme** : le client paie la différence de prix au prorata des jours restants, même cycle, même échéance. La nouvelle formule s'active au paiement. Montant arrondi au franc supérieur, au minimum 200 FCFA (minimum GeniusPay).
- **Descente en gamme** : programmée pour l'échéance (`subscriptions.scheduled_plan_id`), sans remboursement. Vers la formule gratuite, elle s'applique d'elle-même à l'échéance ; vers une formule payante moins chère, le renouvellement se fait au nouveau prix.
- Une formule gratuite vers une payante est un premier achat : période complète.
- Le changement de cycle (mensuel ou annuel) se fait au renouvellement.

### 3. Une commande de paiement locale avant tout appel à GeniusPay

Table `payment_orders` : tenant, objet (`subscription`, `plan_upgrade`, `custom`), formule et cycle visés, montant (dont frais d'activation), statut (`pending`, `completed`, `failed`, `cancelled`, `expired`), référence GeniusPay **unique**, lien de paiement, auteur.

La commande est créée **avant** l'appel à GeniusPay : son identifiant voyage dans les `metadata`, et le montant est calculé côté serveur, jamais reçu du navigateur. L'appel `POST /payments` n'est **jamais rejoué automatiquement** (GeniusPay ne documente pas d'idempotence) : en cas d'échec, la commande reste sans référence et le client relance, ce qui crée un nouveau paiement. Un paiement non réglé expire de lui-même au bout de 24 h.

### 4. Confirmation : webhook vérifié, puis état relu à la source

Le webhook est la voie normale de confirmation. Le contrôleur vérifie la signature et l'horodatage (5 minutes au plus) et répond tout de suite ; le traitement part en file `webhooks`. Le job **relit le paiement** (`GET /payments/{reference}`) avant d'agir : seul l'état renvoyé par l'API fait foi. Le montant et la devise doivent correspondre à la commande, sinon elle passe en `failed` avec une alerte critique.

Deux filets, avec les **mêmes règles idempotentes**, pour les webhooks perdus ou en retard (la politique de nouvel essai de GeniusPay n'est pas documentée) :

- au retour du client sur `success_url`, le serveur relit le paiement (appel serveur à serveur, pas la parole du navigateur) ;
- toutes les 10 minutes, une commande planifiée relit les commandes en attente depuis plus de 10 minutes et expire celles de plus de 25 h.

**Idempotence** (même raisonnement que l'ADR-017 du WES) : contrainte unique `(provider, event_id)` sur `payment_webhook_events`, posée dans la transaction métier ; verrou `lockForUpdate` sur la commande ; une commande `completed` ne se réapplique jamais ; contrainte unique `(payment_order_id, type)` sur `company_payments`.

### 5. Un seul registre comptable

Une commande payée écrit dans `company_payments` (`source = geniuspay`, `payment_order_id`), registre que lit `CommissionService` :

- `activation` pour les frais d'activation, facturés une seule fois, au premier paiement d'une inscription en ligne (abonnement en `trial`, aucun frais d'activation déjà enregistré). Les clients déjà actifs ne les paient pas en renouvelant ;
- `subscription` pour la période achetée ;
- `upgrade` pour un prorata, et `other` pour un lien libre. Ces deux types ne sont **pas commissionnés** : `CommissionService` ne compte que l'activation et les trois premiers abonnements, sans modification.

L'ancienne contrainte de l'ADR-017 du WES (pas de frais d'activation dans le paiement) tombe : le montant étant calculé chez nous, la ventilation est triviale.

### 6. Client d'API brut, pas le SDK Laravel

`GeniusPayGateway` implémente un contrat **générique** `PaymentGatewayContract`, avec le client HTTP de Laravel (délais de 5 s à la connexion et 15 s au total, nouvel essai sur les lectures seulement). Le SDK `geniuspay/laravel` est écarté : il publie ses propres migrations et tables, comme Cashier écarté dans l'ADR-017 du WES ; il ne documente que la création de paiement ; et il masquerait la vérification de signature.

### 7. Lien de paiement libre

Le superadmin peut générer un lien GeniusPay pour un montant et un libellé libres (prestation, formation, rattrapage) et l'envoyer au client. Une fois payé, il s'inscrit au registre en type `other`.

### 8. Stripe retiré

Le code, les tests, la route et la dépendance `stripe/stripe-php` sont supprimés. Les colonnes `stripe_*` et la table `stripe_webhook_events` sont supprimées dans une seconde MR, après déploiement de la première : migration rétrocompatible.

## Sécurité

- Clés dans `.env` et dans les variables CI uniquement (`GENIUSPAY_API_KEY`, `GENIUSPAY_API_SECRET`, `GENIUSPAY_WEBHOOK_SECRET`), jamais dans le dépôt. Clés sandbox en local et en staging, clés live en production seulement.
- La signature est vérifiée sur le corps brut de la requête, comparée en temps constant (`hash_equals`). Le webhook ne déclenche rien par lui-même : seule la relecture de l'API fait foi, ce qui neutralise un webhook forgé même si le secret fuitait.
- La route du webhook est exclue du contrôle CSRF. Elle ne l'était pas pour Stripe : chaque webhook aurait reçu une erreur 419 en production.
- Aucun montant ne vient du client : il est calculé par `SubscriptionBillingService` à partir de la formule.

## Exploitation

- Le planificateur Laravel doit tourner (`* * * * * php artisan schedule:run`) en staging et en production. Au 17/10/2026, aucun cron ne le lançait pour GestionStock : la réinitialisation et la purge des démos ne s'exécutaient pas non plus.
- Le webhook s'enregistre auprès de GeniusPay avec `php artisan geniuspay:register-webhook <url>`, qui affiche une seule fois le secret à copier dans `.env`.

## Alternatives écartées

- **Garder Stripe en second PSP** : inutilisable en Côte d'Ivoire, et du code à maintenir pour rien.
- **SDK `geniuspay/laravel`** : voir §6.
- **Polling seul, sans webhook** : latence d'activation et appels inutiles. Le polling sert ici de filet, pas de voie normale.
- **Faire confiance au webhook sans relire l'API** : un secret fuité permettrait d'activer des abonnements sans payer.
- **Prorata en crédit de jours plutôt qu'en paiement** : plus simple à payer, mais la nouvelle formule s'activerait sans paiement ; refusé par le choix produit du 17/10/2026.

## Conséquences

- Plus de facturation automatique : le chiffre d'affaires dépend des rappels et de la facilité de paiement (Mobile Money en deux clics).
- Les clients facturés à la main peuvent renouveler en ligne depuis Paramètres > Abonnement. Leur période démarre au paiement si elle n'avait pas d'échéance.
- Nouvelles commandes planifiées : `subscriptions:process-renewals` (quotidienne) et `payments:reconcile` (toutes les 10 minutes).
- **Frais GeniusPay** (révisé le 20/10/2026, voir ci-dessous) : à la date de l'ADR, ils étaient à la charge de Welely, et le montant facturé au client était le prix affiché.

## Révision du 20/10/2026 : frais de paiement

Le premier paiement réel en production (200 F par Wave) a montré le vrai barème : **frais opérateur + 1 % + 100 XOF, prélevés seulement en cas de succès** (105 F de frais, 95 F reçus). C'est bien plus que le 1 % supposé ici, et prohibitif sur les petits montants.

- **Enregistrement** : les frais et le net réellement crédité (`fees` et `net_amount`, renvoyés par GeniusPay) sont inscrits sur la commande. Ils sont répartis sur ses lignes de registre au prorata de leur montant. Les paiements antérieurs se rattrapent avec `php artisan payments:sync-fees`. Le registre garde le montant payé par le client ; les commissions restent calculées sur ce montant.
- **Qui paie** : décision du produit, **les frais sont à la charge du payeur**, et celui-ci voit séparément le montant dû, les frais de paiement et le total. Welely ne les absorbe pas (voir la révision suivante).

## Révision du 20/10/2026 (2) : frais à la charge du payeur

GeniusPay n'offre pas d'option pour faire payer les frais au client, mais son API permet d'**imposer le canal** (`gateway`, `payment_method`, `mmo_provider`). Si le moyen de paiement est connu avant la création du paiement, les frais sont donc prévisibles. Essais en sandbox du 20/10/2026 : `wave`, `pawapay` (ORANGE_CIV, MTN_MOMO_CIV), `moov_money` et `card` sont acceptés. `pal`, `paystack`, `cinetpay`, `paiementpro` et `stripe` sont refusés par l'API, alors que le tableau de bord les affiche.

1. **Le client choisit son moyen de paiement sur notre page**, et non plus sur la page GeniusPay : Wave, Orange Money, MTN MoMo, Moov Money ou carte. Le paiement est ensuite créé directement sur le canal correspondant.
2. **Frais affichés avant de payer**, ligne par ligne : prix, frais de paiement, total. Le total est calculé pour que Welely reçoive le prix exact : total = ⌈(prix + 100) ÷ (1 − taux du canal − 1 %)⌉.
3. **Taux par canal** (tableau de bord GeniusPay, 20/10/2026), dans `config/services.php` pour être ajustés sans toucher au code :

   | Moyen | Canal | Taux | Statut |
   | --- | --- | --- | --- |
   | Wave | wave | 1,5 % | exact, vérifié par un paiement réel |
   | Orange Money, MTN MoMo | pawapay | 3,5 % | approximatif (« ~3,5 % ») |
   | Moov Money | moov_money | 3,5 % | estimation, non affiché par GeniusPay |
   | Carte | card | 5 % | annoncé par GeniusPay |

4. **Contrôle** : à chaque paiement, l'écart entre les frais réels et les frais facturés au client est inscrit au registre comme frais supportés par Welely. Au-delà de l'arrondi, une alerte `payment_fees_underestimated` signale qu'il faut relever le taux du canal.
5. **Registre** : il inscrit le **prix** (hors frais du payeur) ; les commissions restent calculées sur ce prix. La commande garde le total payé, les frais ajoutés au payeur (`payer_fees`), les frais réels et le net reçu.
6. **Liens de paiement libres** : le lien envoyé au client ouvre une page publique GestionStock (`/admin/paiement/{code}`, sans connexion). Le client y choisit son moyen et voit le détail des frais. Chaque tentative crée un nouveau paiement GeniusPay, rattaché au lien (table `payment_links`). Le lien est payé dès qu'une tentative réussit, et reste valable 7 jours.

## Révision du 28/10/2026 : abonnement payé hors ligne

Un client a payé en espèces ses frais d'inscription et un an d'abonnement. Le superadmin pouvait inscrire les frais, mais un paiement « abonnement » saisi à la main n'était qu'une ligne du registre : il n'ouvrait aucune période.

1. Un paiement d'abonnement saisi par le superadmin (`POST superadmin/companies/{id}/payments`, type `subscription`) précise la **formule** et le **cycle** (mensuel, annuel). Il ouvre ou prolonge la période avec les règles du paiement en ligne (`SubscriptionBillingService::openPeriod`, partagé) : la période part de l'échéance si une période payée de la même formule court encore, sinon du **jour du paiement** déclaré ; la formule est appliquée à l'entreprise.
2. Changer de formule pendant une période payée reste refusé, comme en ligne : il faut d'abord « Changer de formule ».
3. Le paiement est inscrit au registre (`source = manual`) avec la période en note. La date du paiement ne peut pas être dans le futur.
4. Les frais d'inscription restent saisis à part, une seule fois.

## Quand réviser

- Si GeniusPay publie une clé d'idempotence : rejouer `POST /payments` sur les erreurs réseau.
- Si GeniusPay propose le prélèvement récurrent (carte) : renouvellement automatique facultatif.
- Si un remboursement par API devient disponible : gérer les remboursements et le statut `refunded`.
