From 352892485776c471eabb13bdaeff8597795f9722 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:03:33 +0200 Subject: [PATCH] =?UTF-8?q?docs(abonnement):=20spec=20Frisbii=20(souscript?= =?UTF-8?q?ion,=20r=C3=A9siliation,=20webhooks)=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N --- .../2026-06-25-frisbii-abonnements-design.md | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md diff --git a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md new file mode 100644 index 0000000..d92a601 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md @@ -0,0 +1,287 @@ +# Abonnements payants via Frisbii — design + +- **Issue** : [#90](https://github.com/ColinMaudry/decp.info/issues/90) +- **Branche** : `feature/90_subscriptions` +- **Date** : 2026-06-25 + +## Objectif + +Permettre à un utilisateur connecté de **souscrire** un abonnement payant et de +**résilier** son abonnement, via [Frisbii](https://docs.frisbii.com/) (ex-Reepay), +une solution européenne de gestion d'abonnements. L'abonnement débloque les +sections premium de l'espace compte (Mes archives, Mes filtres, Mon SIRET), déjà +protégées par le mécanisme `require_subscription`. + +Le câblage côté app est déjà prêt : `current_user_has_subscription()` dans +`src/pages/_compte_shell.py` est un stub qui renvoie `False`, et +`src/pages/compte_abonnement.py` est une page placeholder. Cette fonctionnalité +les branche sur Frisbii. + +## Décisions clés + +1. **Session de checkout hébergée + webhooks.** On crée une _subscription session_ + Frisbii (`POST /v1/session/subscription`) qui renvoie une URL de page de + paiement hébergée. Frisbii collecte la carte ; on ne manipule jamais de données + de paiement. Les **webhooks** sont la **source de vérité** de l'état d'abonnement. +2. **Deux plans fixes, définis côté Frisbii.** decp.info ne référence que leurs + _handles_ (via `.env`) : + + - `simple` — **20 € HT / mois** + - `soutien` — **50 € HT / mois** + + Les deux plans donnent **le même accès premium** ; le soutien est une + contribution supérieure, pas un palier de fonctionnalités. + +3. **Abonnement à durée indéterminée, mois glissants.** L'abonnement est renouvelé + chaque mois (période ancrée sur la date d'inscription, comportement par défaut + Frisbii — pas de prorata de première période). Il perdure jusqu'à résiliation. +4. **Résiliation en fin de période courante.** `POST` cancel Frisbii avec le + comportement **par défaut** (expiration en fin de période courante). L'accès est + maintenu jusqu'à `current_period_end` renvoyé par Frisbii ; aucun calcul de date + côté app. +5. **Clé privée serveur uniquement.** HTTP Basic Auth (clé privée en username), + jamais exposée au frontend. + +## Architecture + +Nouveau module `src/subscriptions/`, calqué sur `src/auth/`, avec des frontières +nettes : + +| Fichier | Rôle | Dépendances | Ne dépend PAS de | +| ----------- | -------------------------------------------------------------- | ----------------- | ---------------- | +| `client.py` | Client HTTP pur de l'API Frisbii | `requests`, env | DB, Flask | +| `db.py` | Table `subscriptions` (réutilise `auth.db.get_conn`) | sqlite | Flask, client | +| `plans.py` | Catalogue des plans (clé → handle, libellé, prix, description) | env | DB, Flask | +| `routes.py` | Blueprint Flask : subscribe, cancel, webhook | client, db, plans | — | +| `setup.py` | `init_subscriptions(app)` | routes | — | + +Côté présentation : + +- `src/pages/compte_abonnement.py` — UI de la page `/compte/abonnement`. +- `src/pages/_compte_shell.py` — `current_user_has_subscription()` branché sur + `subscriptions.db`. + +### `client.py` — client Frisbii + +Fonctions pures, sans état applicatif (toute config lue depuis l'env) : + +- `_auth()` → tuple HTTP Basic `(FRISBII_API_KEY, "")`. +- `get_or_create_customer(handle: str, email: str) -> dict` + - Handle déterministe `decpinfo-{user_id}`. GET le customer ; s'il n'existe pas + (404), le crée (`POST /v1/customer`). Idempotent. +- `create_subscription_session(plan_handle, customer_handle, accept_url, cancel_url) -> str` + - `POST /v1/session/subscription` avec `prepare_subscription` (plan + customer) et + les URLs de retour. Renvoie l'`url` hébergée. +- `cancel_subscription(subscription_handle) -> dict` + - Cancel par défaut (fin de période courante). Renvoie l'objet subscription. +- `get_subscription(subscription_handle) -> dict` (utilitaire de réconciliation). + +Base URL : `FRISBII_API_BASE_URL` (à confirmer au moment de l'implémentation depuis +la doc Frisbii ; valeur par défaut documentée dans `.template.env`). Timeouts +explicites sur tous les appels. Les erreurs HTTP lèvent une exception +`FrisbiiError` (sous-classe locale) loggée par l'appelant. + +### `db.py` — état d'abonnement + +Table `subscriptions` dans `users.sqlite` (un abonnement courant par utilisateur) : + +```sql +CREATE TABLE IF NOT EXISTS subscriptions ( + user_id INTEGER PRIMARY KEY, + frisbii_customer_handle TEXT, + frisbii_subscription_handle TEXT, + plan TEXT, -- 'simple' | 'soutien' + status TEXT, -- 'pending' | 'active' | 'cancelled' | 'expired' + current_period_end TEXT, -- ISO 8601, nullable + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_subscriptions_customer + ON subscriptions(frisbii_customer_handle); +``` + +Le module possède sa propre `init_schema()` (appelée depuis `setup.py`) utilisant +`auth.db.get_conn()`, pour rester isolé du schéma auth. + +Fonctions : + +- `upsert_subscription(user_id, customer_handle, subscription_handle, plan, status, current_period_end)` +- `get_subscription_by_user(user_id) -> Row | None` +- `get_subscription_by_customer(customer_handle) -> Row | None` (résolution webhook) +- `set_status(user_id, status, current_period_end=None)` +- `has_active_subscription(user_id) -> bool` + → `True` si une ligne existe avec `status='active'` **ou** + (`status='cancelled'` **et** `current_period_end` dans le futur). Couvre le cas + « résilié mais encore valide jusqu'à la fin du mois ». + +### Statuts et cycle de vie + +| Statut | Sens | Accès premium | +| ----------- | -------------------------------------------- | --------------------- | +| `pending` | Session créée, paiement pas encore confirmé | non | +| `active` | Abonnement en cours, renouvelé chaque mois | oui | +| `cancelled` | Résilié, valide jusqu'à `current_period_end` | oui (jusqu'à la date) | +| `expired` | Période échue (annulé ou échec de paiement) | non | + +### `routes.py` — blueprint Flask + +`subscriptions_bp` (préfixe explicite par route) : + +- `POST /subscriptions/subscribe` — `@login_required`, **CSRF protégé**. + - Form `plan=simple|soutien`. Résout le handle via `plans.py` (400 si inconnu). + - `get_or_create_customer("decpinfo-{user_id}", email)`. + - `upsert_subscription(..., status='pending')`. + - `create_subscription_session(...)` avec + `accept_url={APP_BASE_URL}/compte/abonnement?paiement=succes` et + `cancel_url={APP_BASE_URL}/compte/abonnement?paiement=annule`. + - Redirection **303** vers l'URL hébergée. + - En cas d'erreur API : redirection `/compte/abonnement?error=frisbii` + log. +- `POST /subscriptions/cancel` — `@login_required`, **CSRF protégé**. + - Récupère l'abonnement de l'utilisateur ; 400 s'il n'y en a pas d'actif. + - `cancel_subscription(handle)` (défaut = fin de période). + - Met à jour le statut localement (`cancelled` + `current_period_end` si renvoyé) ; + le webhook confirmera. + - Redirection `/compte/abonnement?resiliation=ok`. +- `POST /frisbii/webhook` — **CSRF-exempt**, pas d'auth de session. + - **Vérifie la signature** via `FRISBII_WEBHOOK_SECRET` selon le schéma documenté + par Frisbii (à confirmer depuis la doc webhooks à l'implémentation). Signature + invalide → **403**. + - Dispatch par type d'événement (mapping vers l'utilisateur via le customer + handle stocké) : + - `subscription_created` / `subscription_activated` → `status='active'`, + `current_period_end` maj. + - `invoice_settled` / renouvellement → `current_period_end` maj. + - `subscription_cancelled` → `status='cancelled'`, `current_period_end` maj. + - `subscription_expired` / échec de paiement terminal → `status='expired'`. + - Événement inconnu → **200** (ignoré). Erreur de traitement → **5xx** pour que + Frisbii réessaie. Les noms exacts d'événements seront confirmés depuis la doc + Frisbii ; le dispatch est piloté par une table `EVENT_HANDLERS` facile à étendre. + +### `setup.py` — initialisation + +`init_subscriptions(app)` : + +- `db.init_schema()`. +- Enregistre `subscriptions_bp`. +- Exempte la vue webhook de CSRF (même approche que `_auth_csrf.exempt` dans + `src/app.py`). +- Warnings au démarrage si `FRISBII_API_KEY`, `FRISBII_WEBHOOK_SECRET`, + `FRISBII_PLAN_SIMPLE` ou `FRISBII_PLAN_SOUTIEN` manquent (comme Brevo/LinkedIn). + +Appelé depuis `src/app.py` après `init_auth(...)`. + +### `plans.py` — catalogue + +```python +PLANS = { + "simple": {"handle": env("FRISBII_PLAN_SIMPLE"), "label": "Abonnement simple", + "prix_ht": 20, "description": "..."}, + "soutien": {"handle": env("FRISBII_PLAN_SOUTIEN"), "label": "Abonnement de soutien", + "prix_ht": 50, "description": "..."}, +} +``` + +`resolve_handle(key) -> str | None` pour les routes ; le dict sert aussi à rendre +les cartes de la page. + +## Page `/compte/abonnement` + +`account_guard("/compte/abonnement", require_subscription=False)` reste (la page est +accessible sans abonnement). Le contenu dépend de l'état : + +**Sans abonnement actif** : + +- Deux cartes de plan (Simple 20 € HT/mois, Soutien 50 € HT/mois), chacune avec un + formulaire `POST /subscriptions/subscribe` (input caché `plan` + CSRF) et un bouton + « S'abonner ». +- Contenu pédagogique (issue #90) : + - **À quoi servent les abonnements** : abonnement Frisbii 50 €, serveur Scaleway + 40 €, espace de coworking 250 €, salaire médian 3 840 €. + - **Ce que le soutien permettrait** : rédaction d'études à partir des données (ex. + acheteurs aux données introuvables et raisons de la non-publication) ; + coordination des bonnes volontés militant pour une législation plus exigeante sur + la transparence de la commande publique. + +**Avec abonnement actif** : + +- Plan courant, statut, date de prochain renouvellement / fin de validité + (`current_period_end`). +- Si `cancelled` : bandeau « Abonnement résilié, actif jusqu'au {date} ». +- Si `active` : formulaire `POST /subscriptions/cancel` (CSRF) + bouton « Résilier ». + +**Messages de retour** (query params lus dans le `layout`) : `paiement=succes` +(« Merci, votre abonnement est en cours d'activation »), `paiement=annule`, +`resiliation=ok`, `error=frisbii`. + +## `current_user_has_subscription()` + +Dans `_compte_shell.py`, remplacer le stub par : + +```python +def current_user_has_subscription() -> bool: + if not current_user.is_authenticated: + return False + return subscriptions.db.has_active_subscription(current_user.id) +``` + +C'est le seul point de branchement avec le reste de l'espace compte ; le mécanisme +`visible_sections` / `guard_redirect` existant fonctionne tel quel. + +## Configuration (`.template.env`) + +```bash +# Frisbii — gestion des abonnements (https://docs.frisbii.com) +FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement +FRISBII_API_BASE_URL= # base de l'API Frisbii (cf. doc) +FRISBII_PLAN_SIMPLE= # handle du plan "abonnement simple" (20 € HT/mois) +FRISBII_PLAN_SOUTIEN= # handle du plan "abonnement de soutien" (50 € HT/mois) +FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks +``` + +**Prérequis de configuration côté dashboard Frisbii** (hors code, à documenter) : + +- Créer les deux plans mensuels (mois glissants, ancrés sur la date d'inscription). +- Configurer un webhook vers `{APP_BASE_URL}/frisbii/webhook` avec les événements + d'abonnement et de facturation, et récupérer le secret de signature. + +## Gestion des erreurs + +| Situation | Comportement | +| ---------------------------- | ------------------------------------------------------------------------- | +| Échec API à la souscription | Redirect `/compte/abonnement?error=frisbii` + log | +| Échec API à la résiliation | Redirect `/compte/abonnement?error=frisbii` + log ; statut local inchangé | +| Webhook signature invalide | 403, pas de traitement | +| Webhook événement inconnu | 200, ignoré | +| Webhook erreur de traitement | 5xx → Frisbii réessaie | +| Config Frisbii absente | Warnings au démarrage ; souscription échoue proprement | + +## Tests + +Unitaires (mocks, pas d'appel réseau réel) : + +- `client.py` : auth Basic, get-or-create customer (200 vs 404→create), création de + session (URL renvoyée), cancel ; gestion d'erreur HTTP → `FrisbiiError`. HTTP mocké. +- `db.py` : upsert / get / set_status ; `has_active_subscription` pour chaque statut, + y compris `cancelled` futur (vrai) vs passé (faux). +- `plans.py` : `resolve_handle` (connu / inconnu). +- `routes.py` : webhook — signature valide/invalide, dispatch de chaque événement + vers le bon changement de statut (payloads factices), résolution par customer + handle ; subscribe (redirect 303 vers l'URL de session, statut `pending` créé) ; + cancel (appel client + statut `cancelled`). + +Intégration légère (rendu) : + +- Page `/compte/abonnement` : affiche les deux cartes + boutons « S'abonner » sans + abonnement ; affiche le bouton « Résilier » et la date avec abonnement actif (DB + de test préremplie). + +## Hors périmètre (YAGNI) + +- Changement de plan / upgrade-downgrade en self-service (le client peut résilier et + re-souscrire). +- Montant de soutien libre (décidé : plans fixes). +- Réconciliation périodique automatique (un utilitaire `get_subscription` existe pour + un script manuel si besoin, mais pas de cron). +- Facturation / historique des factures dans l'UI (Frisbii fournit son propre portail + et envoie les factures par email).