From 352892485776c471eabb13bdaeff8597795f9722 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:03:33 +0200 Subject: [PATCH 01/14] =?UTF-8?q?docs(abonnement):=20spec=20Frisbii=20(sou?= =?UTF-8?q?scription,=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). From 3f12e6e210253fae659433a9ae9d8a9958aa70ee Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:06:37 +0200 Subject: [PATCH 02/14] docs(abonnement): essai gratuit de 2 jours (config plan Frisbii) (#90) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N --- .../2026-06-25-frisbii-abonnements-design.md | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md index d92a601..7111919 100644 --- a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md +++ b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md @@ -35,11 +35,18 @@ les branche sur Frisbii. 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), +4. **Essai gratuit de 2 jours, configuré côté Frisbii.** L'essai est un + `trial_interval` réglé sur **chaque plan dans le dashboard Frisbii** (aucun code + pour le définir). La carte est **collectée à la souscription** (page hébergée) + mais débitée seulement à la fin de l'essai ; l'abonnement passe alors + automatiquement de `trial` à `active`. Si le paiement échoue → `expired`. Une + résiliation pendant l'essai expire en **fin d'essai** (pas de débit). Pendant + l'essai, l'utilisateur a **accès aux fonctions premium**. +5. **Résiliation en fin de période courante.** `POST` cancel Frisbii avec le + comportement **par défaut** (expiration en fin de période courante — ou fin + d'essai si en essai). L'accès est maintenu jusqu'à `current_period_end` renvoyé + par Frisbii ; aucun calcul de date côté app. +6. **Clé privée serveur uniquement.** HTTP Basic Auth (clé privée en username), jamais exposée au frontend. ## Architecture @@ -91,7 +98,7 @@ CREATE TABLE IF NOT EXISTS subscriptions ( frisbii_customer_handle TEXT, frisbii_subscription_handle TEXT, plan TEXT, -- 'simple' | 'soutien' - status TEXT, -- 'pending' | 'active' | 'cancelled' | 'expired' + status TEXT, -- 'pending' | 'trial' | 'active' | 'cancelled' | 'expired' current_period_end TEXT, -- ISO 8601, nullable created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -111,18 +118,19 @@ Fonctions : - `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 ». + → `True` si une ligne existe avec `status` dans (`trial`, `active`) **ou** + (`status='cancelled'` **et** `current_period_end` dans le futur). Couvre l'essai en + cours et le cas « résilié mais encore valide jusqu'à la fin de la période ». ### 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 | +| Statut | Sens | Accès premium | +| ----------- | --------------------------------------------- | --------------------- | +| `pending` | Session créée, paiement pas encore confirmé | non | +| `trial` | Essai gratuit en cours (2 j), carte collectée | oui | +| `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 @@ -149,8 +157,11 @@ Fonctions : 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. + - `subscription_created` → `status='trial'` si l'abonnement démarre en essai + (champ d'essai du payload), sinon `status='active'` ; `current_period_end` + (= fin d'essai pendant l'essai) maj. + - fin d'essai / premier débit (`subscription_renewed` / `invoice_settled`) → + `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'`. @@ -194,7 +205,7 @@ accessible sans abonnement). Le contenu dépend de l'état : - 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 ». + « S'abonner ». Mention « 2 jours d'essai gratuit » sur les cartes. - Contenu pédagogique (issue #90) : - **À quoi servent les abonnements** : abonnement Frisbii 50 €, serveur Scaleway 40 €, espace de coworking 250 €, salaire médian 3 840 €. @@ -207,8 +218,10 @@ accessible sans abonnement). Le contenu dépend de l'état : - Plan courant, statut, date de prochain renouvellement / fin de validité (`current_period_end`). +- Si `trial` : bandeau « Essai gratuit jusqu'au {date}, puis débit automatique ». - Si `cancelled` : bandeau « Abonnement résilié, actif jusqu'au {date} ». -- Si `active` : formulaire `POST /subscriptions/cancel` (CSRF) + bouton « Résilier ». +- Si `trial` ou `active` : formulaire `POST /subscriptions/cancel` (CSRF) + bouton + « Résilier » (en essai, la résiliation évite tout débit). **Messages de retour** (query params lus dans le `layout`) : `paiement=succes` (« Merci, votre abonnement est en cours d'activation »), `paiement=annule`, @@ -241,7 +254,9 @@ 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). +- Créer les deux plans mensuels (mois glissants, ancrés sur la date d'inscription) + avec un **essai de 2 jours** (`trial_interval`) et collecte de la carte à la + souscription. - 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. @@ -262,8 +277,9 @@ 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). +- `db.py` : upsert / get / set_status ; `has_active_subscription` pour chaque statut + (`trial` et `active` → vrai ; `cancelled` futur → vrai, passé → faux ; `pending` + et `expired` → 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 From 78aea4a69a65b3f82c77af6652c40980f1b40477 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:29:22 +0200 Subject: [PATCH 03/14] =?UTF-8?q?docs(abonnement):=20dur=C3=A9e=20d'essai?= =?UTF-8?q?=20lue=20depuis=20le=20plan=20Frisbii=20(non=20cod=C3=A9e=20en?= =?UTF-8?q?=20dur)=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 | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md index 7111919..55f65b4 100644 --- a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md +++ b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md @@ -35,9 +35,10 @@ les branche sur Frisbii. 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. **Essai gratuit de 2 jours, configuré côté Frisbii.** L'essai est un +4. **Essai gratuit configuré côté Frisbii (2 jours souhaités).** L'essai est un `trial_interval` réglé sur **chaque plan dans le dashboard Frisbii** (aucun code - pour le définir). La carte est **collectée à la souscription** (page hébergée) + pour le définir, et **la durée n'est pas codée en dur** côté app : elle est lue + depuis le plan via l'API). La carte est **collectée à la souscription** (page hébergée) mais débitée seulement à la fin de l'essai ; l'abonnement passe alors automatiquement de `trial` à `active`. Si le paiement échoue → `expired`. Une résiliation pendant l'essai expire en **fin d'essai** (pas de débit). Pendant @@ -82,6 +83,9 @@ Fonctions pures, sans état applicatif (toute config lue depuis l'env) : - `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). +- `get_plan(plan_handle) -> dict` + - `GET /v1/plan/{handle}`. Sert à lire les caractéristiques du plan (dont la durée + d'essai `trial_interval`) sans la coder en dur côté app. 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 @@ -196,6 +200,11 @@ PLANS = { `resolve_handle(key) -> str | None` pour les routes ; le dict sert aussi à rendre les cartes de la page. +`trial_days(key) -> int | None` : lit la durée d'essai **depuis Frisbii** +(`client.get_plan(handle)` → `trial_interval`, parsé en jours), avec un **cache** +(TTL ~1 h via `src/utils/cache.py`, les plans changeant rarement). Échec API ou plan +sans essai → `None` (la mention d'essai est alors masquée, pas de valeur en dur). + ## Page `/compte/abonnement` `account_guard("/compte/abonnement", require_subscription=False)` reste (la page est @@ -205,7 +214,8 @@ accessible sans abonnement). Le contenu dépend de l'état : - 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 ». Mention « 2 jours d'essai gratuit » sur les cartes. + « S'abonner ». Mention « {n} jours d'essai gratuit » sur les cartes, où `{n}` est + lu depuis le plan via `plans.trial_days(key)` (masquée si le plan n'a pas d'essai). - Contenu pédagogique (issue #90) : - **À quoi servent les abonnements** : abonnement Frisbii 50 €, serveur Scaleway 40 €, espace de coworking 250 €, salaire médian 3 840 €. @@ -280,7 +290,9 @@ Unitaires (mocks, pas d'appel réseau réel) : - `db.py` : upsert / get / set_status ; `has_active_subscription` pour chaque statut (`trial` et `active` → vrai ; `cancelled` futur → vrai, passé → faux ; `pending` et `expired` → faux). -- `plans.py` : `resolve_handle` (connu / inconnu). +- `plans.py` : `resolve_handle` (connu / inconnu) ; `trial_days` (parsing du + `trial_interval` renvoyé par un `get_plan` mocké, mise en cache, `None` si échec API + ou plan sans essai). - `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éé) ; From 421750957f83b2d22cefa7ac2fab82cfb1b5a48e Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:36:25 +0200 Subject: [PATCH 04/14] =?UTF-8?q?docs(abonnement):=20plan=20d'impl=C3=A9me?= =?UTF-8?q?ntation=20Frisbii=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 --- .../plans/2026-06-25-frisbii-abonnements.md | 1397 +++++++++++++++++ 1 file changed, 1397 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-frisbii-abonnements.md diff --git a/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md new file mode 100644 index 0000000..8082bd2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md @@ -0,0 +1,1397 @@ +# Abonnements Frisbii — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Permettre à un utilisateur connecté de souscrire et résilier un abonnement payant decp.info via Frisbii (ex-Reepay), avec essai gratuit configuré côté Frisbii. + +**Architecture:** Nouveau module isolé `src/subscriptions/` (client HTTP pur, état SQLite, catalogue de plans, fonctions pures de webhook, blueprint Flask, setup). Paiement par session de checkout hébergée Frisbii ; les webhooks sont la source de vérité. Le webhook **vérifie la signature puis relit l'état courant de l'abonnement via l'API** (`get_subscription`) et le mappe vers nos statuts — plus robuste que de dépendre des champs exacts de chaque événement (dont les noms sont à confirmer dans la doc Frisbii). Spec : `docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md`. + +**Tech Stack:** Python 3, Flask + flask-login + flask-wtf (CSRF), Dash (page), `httpx` (client HTTP, déjà dépendance), SQLite (`users.sqlite`, via `src.auth.db.get_conn`), pytest. + +## Global Constraints + +- Tous les imports internes commencent par `src.` (ex. `from src.subscriptions import db`), jamais `subscriptions.db`. +- UI et messages en **français**. +- Client HTTP : **`httpx`** (pas `requests`). +- Logging : `from src.utils import logger`. +- Tests exécutés avec **`uv run pytest`**. +- Clé privée Frisbii **jamais exposée au frontend** ; utilisée uniquement en HTTP Basic Auth côté serveur (clé en username, mot de passe vide). +- Deux plans : `simple` (20 € HT/mois) et `soutien` (50 € HT/mois), **même accès premium**. +- Statuts d'abonnement : `pending`, `trial`, `active`, `cancelled`, `expired`. Accès premium si `trial`/`active`, ou `cancelled` avec `current_period_end` futur. +- Hooks pre-commit actifs (ruff, prettier) : ne pas s'étonner d'un reformatage automatique au commit ; re-`git add` puis re-commit si un hook modifie un fichier. + +--- + +### Task 1: Client HTTP Frisbii (`client.py`) + +**Files:** + +- Create: `src/subscriptions/__init__.py` (vide) +- Create: `src/subscriptions/client.py` +- Create: `tests/subscriptions/__init__.py` (vide) +- Create: `tests/subscriptions/conftest.py` +- Test: `tests/subscriptions/test_client.py` + +**Interfaces:** + +- Consumes: rien. +- Produces: + + - `class FrisbiiError(Exception)` avec attribut `.status_code: int` et `.body`. + - `get_or_create_customer(handle: str, email: str) -> dict` + - `create_subscription_session(plan_handle: str, customer_handle: str, accept_url: str, cancel_url: str) -> str` (renvoie l'URL hébergée) + - `cancel_subscription(subscription_handle: str) -> dict` + - `get_subscription(subscription_handle: str) -> dict` + - `get_plan(plan_handle: str) -> dict` + +- [ ] **Step 1: Créer le fake httpx partagé pour les tests** + +Create `tests/subscriptions/__init__.py` (vide) puis `tests/subscriptions/conftest.py` : + +```python +import json as _json + +import pytest + + +class FakeResponse: + def __init__(self, status_code: int, payload=None): + self.status_code = status_code + self._payload = payload if payload is not None else {} + + def json(self): + return self._payload + + @property + def text(self): + return _json.dumps(self._payload) + + +@pytest.fixture +def fake_httpx(monkeypatch): + """Capture les appels httpx.request du client Frisbii et renvoie des réponses programmées.""" + from src.subscriptions import client + + calls = [] + queue = [] + + def _fake_request(method, url, **kwargs): + calls.append({"method": method, "url": url, **kwargs}) + if queue: + return queue.pop(0) + return FakeResponse(200, {}) + + monkeypatch.setenv("FRISBII_API_KEY", "priv_test") + monkeypatch.setenv("FRISBII_API_BASE_URL", "https://api.test") + monkeypatch.setattr(client.httpx, "request", _fake_request) + return {"calls": calls, "queue": queue, "Response": FakeResponse} +``` + +- [ ] **Step 2: Écrire les tests du client** + +Create `tests/subscriptions/test_client.py` : + +```python +import pytest + +from src.subscriptions import client + + +def test_auth_and_base_url_used(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "decpinfo-1"})) + client.get_or_create_customer("decpinfo-1", "a@b.fr") + call = fake_httpx["calls"][0] + assert call["url"] == "https://api.test/v1/customer/decpinfo-1" + assert call["auth"] == ("priv_test", "") + + +def test_get_or_create_customer_existing(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "decpinfo-1"})) + result = client.get_or_create_customer("decpinfo-1", "a@b.fr") + assert result == {"handle": "decpinfo-1"} + assert len(fake_httpx["calls"]) == 1 # pas de POST de création + + +def test_get_or_create_customer_creates_on_404(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](404, {"error": "not found"})) + fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "decpinfo-1"})) + result = client.get_or_create_customer("decpinfo-1", "a@b.fr") + assert result == {"handle": "decpinfo-1"} + assert fake_httpx["calls"][1]["method"] == "POST" + assert fake_httpx["calls"][1]["json"] == {"handle": "decpinfo-1", "email": "a@b.fr"} + + +def test_create_subscription_session_returns_url(fake_httpx): + fake_httpx["queue"].append( + fake_httpx["Response"](200, {"id": "cs_1", "url": "https://pay.test/cs_1"}) + ) + url = client.create_subscription_session( + "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko" + ) + assert url == "https://pay.test/cs_1" + body = fake_httpx["calls"][0]["json"] + assert body["prepare_subscription"] == {"plan": "plan_simple", "customer": "decpinfo-1"} + assert body["accept_url"] == "https://app/ok" + assert body["cancel_url"] == "https://app/ko" + + +def test_http_error_raises_frisbii_error(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](500, {"error": "boom"})) + with pytest.raises(client.FrisbiiError) as exc: + client.get_plan("plan_simple") + assert exc.value.status_code == 500 +``` + +- [ ] **Step 3: Lancer les tests pour vérifier l'échec** + +Run: `uv run pytest tests/subscriptions/test_client.py -v` +Expected: FAIL (`ModuleNotFoundError: No module named 'src.subscriptions'` / attributs manquants). + +- [ ] **Step 4: Implémenter le client** + +Create `src/subscriptions/__init__.py` vide. Create `src/subscriptions/client.py` : + +```python +import os + +import httpx + +# NB : base URL et schémas d'endpoints à confirmer dans la doc Frisbii lors de la +# première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur +# l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin. +_DEFAULT_BASE_URL = "https://api.reepay.com" +_TIMEOUT = 15.0 + + +class FrisbiiError(Exception): + def __init__(self, status_code: int, body): + super().__init__(f"Frisbii API error {status_code}: {body}") + self.status_code = status_code + self.body = body + + +def _api_key() -> str: + return os.getenv("FRISBII_API_KEY", "") + + +def _base_url() -> str: + return os.getenv("FRISBII_API_BASE_URL") or _DEFAULT_BASE_URL + + +def _call(method: str, path: str, json: dict | None = None) -> dict: + resp = httpx.request( + method, + f"{_base_url()}{path}", + auth=(_api_key(), ""), + json=json, + timeout=_TIMEOUT, + ) + if resp.status_code >= 400: + raise FrisbiiError(resp.status_code, resp.text) + return resp.json() + + +def get_or_create_customer(handle: str, email: str) -> dict: + try: + return _call("GET", f"/v1/customer/{handle}") + except FrisbiiError as exc: + if exc.status_code != 404: + raise + return _call("POST", "/v1/customer", json={"handle": handle, "email": email}) + + +def create_subscription_session( + plan_handle: str, customer_handle: str, accept_url: str, cancel_url: str +) -> str: + data = _call( + "POST", + "/v1/session/subscription", + json={ + "accept_url": accept_url, + "cancel_url": cancel_url, + "prepare_subscription": {"plan": plan_handle, "customer": customer_handle}, + }, + ) + return data["url"] + + +def cancel_subscription(subscription_handle: str) -> dict: + return _call("POST", f"/v1/subscription/{subscription_handle}/cancel") + + +def get_subscription(subscription_handle: str) -> dict: + return _call("GET", f"/v1/subscription/{subscription_handle}") + + +def get_plan(plan_handle: str) -> dict: + return _call("GET", f"/v1/plan/{plan_handle}") +``` + +- [ ] **Step 5: Lancer les tests pour vérifier le succès** + +Run: `uv run pytest tests/subscriptions/test_client.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/subscriptions/__init__.py src/subscriptions/client.py tests/subscriptions/ +git commit -m "feat(abonnement): client HTTP Frisbii (#90)" +``` + +--- + +### Task 2: État d'abonnement SQLite (`db.py`) + +**Files:** + +- Create: `src/subscriptions/db.py` +- Test: `tests/subscriptions/test_db.py` + +**Interfaces:** + +- Consumes: `src.auth.db.get_conn`, `src.auth.db.reset_conn_for_tests` (réutilise la connexion `users.sqlite`). +- Produces: + + - `init_schema() -> None` + - `create_pending(user_id: int, customer_handle: str, plan: str) -> None` + - `get_by_user(user_id: int) -> sqlite3.Row | None` + - `get_by_customer(customer_handle: str) -> sqlite3.Row | None` + - `update_from_webhook(customer_handle: str, subscription_handle: str | None, status: str, current_period_end: str | None) -> None` + - `set_cancelled(user_id: int, current_period_end: str | None) -> None` + - `has_active_subscription(user_id: int) -> bool` + +- [ ] **Step 1: Écrire les tests du module DB** + +Create `tests/subscriptions/test_db.py` : + +```python +from datetime import datetime, timedelta, timezone + +from src.auth import db as auth_db +from src.subscriptions import db + + +def _future(): + return (datetime.now(timezone.utc) + timedelta(days=2)).isoformat() + + +def _past(): + return (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + + +def _make_user(email="u@ex.fr"): + auth_db.init_schema() + return auth_db.create_user(email, "hash") + + +def test_init_schema_creates_table(users_db_path): + db.init_schema() + conn = auth_db.get_conn() + tables = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + assert "subscriptions" in tables + + +def test_create_pending_and_get_by_user(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + row = db.get_by_user(uid) + assert row["status"] == "pending" + assert row["plan"] == "simple" + assert row["frisbii_customer_handle"] == "decpinfo-1" + + +def test_update_from_webhook_sets_status_and_handle(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + db.update_from_webhook("decpinfo-1", "sub_42", "trial", _future()) + row = db.get_by_user(uid) + assert row["status"] == "trial" + assert row["frisbii_subscription_handle"] == "sub_42" + assert db.get_by_customer("decpinfo-1")["user_id"] == uid + + +def test_has_active_subscription_by_status(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + # pending → faux + assert db.has_active_subscription(uid) is False + for status in ("trial", "active"): + db.update_from_webhook("decpinfo-1", "sub_42", status, _future()) + assert db.has_active_subscription(uid) is True + # cancelled futur → vrai, cancelled passé → faux + db.update_from_webhook("decpinfo-1", "sub_42", "cancelled", _future()) + assert db.has_active_subscription(uid) is True + db.update_from_webhook("decpinfo-1", "sub_42", "cancelled", _past()) + assert db.has_active_subscription(uid) is False + db.update_from_webhook("decpinfo-1", "sub_42", "expired", None) + assert db.has_active_subscription(uid) is False + + +def test_set_cancelled(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + db.update_from_webhook("decpinfo-1", "sub_42", "active", _future()) + end = _future() + db.set_cancelled(uid, end) + row = db.get_by_user(uid) + assert row["status"] == "cancelled" + assert row["current_period_end"] == end +``` + +- [ ] **Step 2: Lancer les tests pour vérifier l'échec** + +Run: `uv run pytest tests/subscriptions/test_db.py -v` +Expected: FAIL (module/fonctions absents). + +- [ ] **Step 3: Implémenter `db.py`** + +Create `src/subscriptions/db.py` : + +```python +import sqlite3 +from datetime import datetime, timezone + +from src.auth.db import get_conn + +SUBSCRIPTIONS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS subscriptions ( + user_id INTEGER PRIMARY KEY, + frisbii_customer_handle TEXT, + frisbii_subscription_handle TEXT, + plan TEXT, + status TEXT, + current_period_end TEXT, + 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); +""" + +_ACCESS_STATUSES = ("trial", "active") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def init_schema() -> None: + get_conn().executescript(SUBSCRIPTIONS_SCHEMA) + + +def create_pending(user_id: int, customer_handle: str, plan: str) -> None: + now = _now() + get_conn().execute( + "INSERT INTO subscriptions " + "(user_id, frisbii_customer_handle, plan, status, created_at, updated_at) " + "VALUES (?, ?, ?, 'pending', ?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET " + "frisbii_customer_handle=excluded.frisbii_customer_handle, " + "plan=excluded.plan, status='pending', updated_at=excluded.updated_at", + (user_id, customer_handle, plan, now, now), + ) + + +def get_by_user(user_id: int) -> sqlite3.Row | None: + return ( + get_conn() + .execute("SELECT * FROM subscriptions WHERE user_id = ?", (user_id,)) + .fetchone() + ) + + +def get_by_customer(customer_handle: str) -> sqlite3.Row | None: + return ( + get_conn() + .execute( + "SELECT * FROM subscriptions WHERE frisbii_customer_handle = ?", + (customer_handle,), + ) + .fetchone() + ) + + +def update_from_webhook( + customer_handle: str, + subscription_handle: str | None, + status: str, + current_period_end: str | None, +) -> None: + get_conn().execute( + "UPDATE subscriptions SET " + "frisbii_subscription_handle = COALESCE(?, frisbii_subscription_handle), " + "status = ?, current_period_end = ?, updated_at = ? " + "WHERE frisbii_customer_handle = ?", + (subscription_handle, status, current_period_end, _now(), customer_handle), + ) + + +def set_cancelled(user_id: int, current_period_end: str | None) -> None: + get_conn().execute( + "UPDATE subscriptions SET status = 'cancelled', current_period_end = ?, " + "updated_at = ? WHERE user_id = ?", + (current_period_end, _now(), user_id), + ) + + +def has_active_subscription(user_id: int) -> bool: + row = get_by_user(user_id) + if row is None: + return False + if row["status"] in _ACCESS_STATUSES: + return True + if row["status"] == "cancelled" and row["current_period_end"]: + return row["current_period_end"] > _now() + return False +``` + +- [ ] **Step 4: Lancer les tests pour vérifier le succès** + +Run: `uv run pytest tests/subscriptions/test_db.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/subscriptions/db.py tests/subscriptions/test_db.py +git commit -m "feat(abonnement): table subscriptions + état (#90)" +``` + +--- + +### Task 3: Catalogue de plans + durée d'essai (`plans.py`) + +**Files:** + +- Create: `src/subscriptions/plans.py` +- Test: `tests/subscriptions/test_plans.py` + +**Interfaces:** + +- Consumes: `src.subscriptions.client.get_plan`, `src.subscriptions.client.FrisbiiError`. +- Produces: + + - `PLANS: dict` (clé → `{"handle", "label", "prix_ht", "description"}`) + - `resolve_handle(key: str) -> str | None` + - `plan_meta(key: str) -> dict | None` + - `trial_days(key: str) -> int | None` (lit `trial_interval` du plan Frisbii, mis en cache ~1 h) + +- [ ] **Step 1: Écrire les tests** + +Create `tests/subscriptions/test_plans.py` : + +```python +import pytest + +from src.subscriptions import client, plans + + +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + plans._trial_cache.clear() + + +def test_resolve_handle(monkeypatch): + assert plans.resolve_handle("simple") == "plan_simple" + assert plans.resolve_handle("inconnu") is None + + +def test_trial_days_parses_and_caches(monkeypatch): + calls = [] + + def fake_get_plan(handle): + calls.append(handle) + return {"trial_interval": "2d"} + + monkeypatch.setattr(client, "get_plan", fake_get_plan) + assert plans.trial_days("simple") == 2 + assert plans.trial_days("simple") == 2 # depuis le cache + assert calls == ["plan_simple"] # un seul appel API + + +def test_trial_days_none_on_error(monkeypatch): + def fake_get_plan(handle): + raise client.FrisbiiError(500, "boom") + + monkeypatch.setattr(client, "get_plan", fake_get_plan) + assert plans.trial_days("simple") is None + + +def test_trial_days_none_when_no_trial(monkeypatch): + monkeypatch.setattr(client, "get_plan", lambda h: {"trial_interval": None}) + assert plans.trial_days("simple") is None +``` + +- [ ] **Step 2: Lancer les tests pour vérifier l'échec** + +Run: `uv run pytest tests/subscriptions/test_plans.py -v` +Expected: FAIL (module/attributs absents). + +- [ ] **Step 3: Implémenter `plans.py`** + +Create `src/subscriptions/plans.py` : + +```python +import os +import time + +from src.subscriptions import client +from src.utils import logger + +_TTL_SECONDS = 3600 +_trial_cache: dict[str, tuple[float, int | None]] = {} + + +def _handle(env_name: str) -> str: + return os.getenv(env_name, "") + + +PLANS = { + "simple": { + "env": "FRISBII_PLAN_SIMPLE", + "label": "Abonnement simple", + "prix_ht": 20, + "description": "Accès aux fonctionnalités premium de decp.info.", + }, + "soutien": { + "env": "FRISBII_PLAN_SOUTIEN", + "label": "Abonnement de soutien", + "prix_ht": 50, + "description": "Mêmes fonctionnalités, contribution renforcée au projet.", + }, +} + + +def resolve_handle(key: str) -> str | None: + meta = PLANS.get(key) + return _handle(meta["env"]) if meta else None + + +def plan_meta(key: str) -> dict | None: + meta = PLANS.get(key) + if meta is None: + return None + return { + "key": key, + "handle": _handle(meta["env"]), + "label": meta["label"], + "prix_ht": meta["prix_ht"], + "description": meta["description"], + } + + +def _parse_trial_interval(value) -> int | None: + # Reepay/Frisbii renvoie une durée type "2d" (jours), "1m" (mois), etc. + # On ne sait afficher que les jours pour l'instant ; format à confirmer. + if not value or not isinstance(value, str): + return None + value = value.strip().lower() + if value.endswith("d") and value[:-1].isdigit(): + return int(value[:-1]) + return None + + +def trial_days(key: str) -> int | None: + handle = resolve_handle(key) + if not handle: + return None + now = time.monotonic() + cached = _trial_cache.get(handle) + if cached and now - cached[0] < _TTL_SECONDS: + return cached[1] + try: + plan = client.get_plan(handle) + except client.FrisbiiError: + logger.warning("Impossible de lire le plan Frisbii %s", handle) + return cached[1] if cached else None + days = _parse_trial_interval(plan.get("trial_interval")) + _trial_cache[handle] = (now, days) + return days +``` + +- [ ] **Step 4: Lancer les tests pour vérifier le succès** + +Run: `uv run pytest tests/subscriptions/test_plans.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/subscriptions/plans.py tests/subscriptions/test_plans.py +git commit -m "feat(abonnement): catalogue de plans + durée d'essai dynamique (#90)" +``` + +--- + +### Task 4: Signature et mapping webhook (`webhooks.py`) + +**Files:** + +- Create: `src/subscriptions/webhooks.py` +- Test: `tests/subscriptions/test_webhooks.py` + +**Interfaces:** + +- Consumes: rien (fonctions pures). +- Produces: + + - `verify_signature(payload: dict, secret: str) -> bool` + - `map_subscription(sub: dict) -> tuple[str, str | None]` (renvoie `(status, current_period_end)`) + +- [ ] **Step 1: Écrire les tests** + +Create `tests/subscriptions/test_webhooks.py` : + +```python +import hashlib +import hmac +from datetime import datetime, timedelta, timezone + +from src.subscriptions import webhooks + + +def _sign(secret, timestamp, event_id): + return hmac.new( + secret.encode(), (timestamp + event_id).encode(), hashlib.sha256 + ).hexdigest() + + +def test_verify_signature_accepts_valid(): + payload = {"id": "evt_1", "timestamp": "2026-06-25T10:00:00Z"} + payload["signature"] = _sign("s3cr3t", payload["timestamp"], payload["id"]) + assert webhooks.verify_signature(payload, "s3cr3t") is True + + +def test_verify_signature_rejects_tampered(): + payload = { + "id": "evt_1", + "timestamp": "2026-06-25T10:00:00Z", + "signature": "deadbeef", + } + assert webhooks.verify_signature(payload, "s3cr3t") is False + + +def test_verify_signature_rejects_without_secret(): + payload = {"id": "evt_1", "timestamp": "t", "signature": "x"} + assert webhooks.verify_signature(payload, "") is False + + +def _future(): + return (datetime.now(timezone.utc) + timedelta(days=2)).isoformat() + + +def test_map_trial(): + status, end = webhooks.map_subscription( + {"state": "active", "trial_end": _future(), "next_period_start": _future()} + ) + assert status == "trial" + + +def test_map_active(): + nxt = _future() + status, end = webhooks.map_subscription({"state": "active", "next_period_start": nxt}) + assert status == "active" + assert end == nxt + + +def test_map_cancelled(): + exp = _future() + status, end = webhooks.map_subscription( + {"state": "active", "is_cancelled": True, "expires": exp} + ) + assert status == "cancelled" + assert end == exp + + +def test_map_expired(): + status, end = webhooks.map_subscription({"state": "expired", "expires": "2020-01-01T00:00:00Z"}) + assert status == "expired" +``` + +- [ ] **Step 2: Lancer les tests pour vérifier l'échec** + +Run: `uv run pytest tests/subscriptions/test_webhooks.py -v` +Expected: FAIL (module absent). + +- [ ] **Step 3: Implémenter `webhooks.py`** + +Create `src/subscriptions/webhooks.py` : + +```python +import hashlib +import hmac +from datetime import datetime, timezone + + +def verify_signature(payload: dict, secret: str) -> bool: + # Schéma Reepay/Frisbii : HMAC-SHA256(secret, timestamp + id), hex. + # À confirmer dans la doc webhooks Frisbii lors de l'intégration. + if not secret: + return False + timestamp = payload.get("timestamp", "") + event_id = payload.get("id", "") + received = payload.get("signature", "") + expected = hmac.new( + secret.encode(), (timestamp + event_id).encode(), hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, received) + + +def _in_future(value) -> bool: + if not value: + return False + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) > datetime.now( + timezone.utc + ) + except ValueError: + return False + + +def map_subscription(sub: dict) -> tuple[str, str | None]: + """Mappe un objet subscription Frisbii vers (status, current_period_end). + + Noms de champs Reepay (à confirmer) : state, trial_end, expires, + is_cancelled, next_period_start. + """ + state = sub.get("state") + if state == "expired": + return "expired", sub.get("expires") + if sub.get("is_cancelled") or state == "cancelled": + return "cancelled", sub.get("expires") + if _in_future(sub.get("trial_end")): + return "trial", sub.get("trial_end") + if state == "active": + return "active", sub.get("next_period_start") + if state == "pending": + return "pending", None + return (state or "pending"), sub.get("next_period_start") +``` + +- [ ] **Step 4: Lancer les tests pour vérifier le succès** + +Run: `uv run pytest tests/subscriptions/test_webhooks.py -v` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/subscriptions/webhooks.py tests/subscriptions/test_webhooks.py +git commit -m "feat(abonnement): signature + mapping des webhooks Frisbii (#90)" +``` + +--- + +### Task 5: Routes Flask + setup + câblage app (`routes.py`, `setup.py`, `app.py`, `.template.env`) + +**Files:** + +- Create: `src/subscriptions/routes.py` +- Create: `src/subscriptions/setup.py` +- Modify: `src/app.py` (appeler `init_subscriptions` après `init_api`) +- Modify: `.template.env` (variables Frisbii) +- Test: `tests/subscriptions/test_routes.py` + +**Interfaces:** + +- Consumes: `client`, `db`, `plans`, `webhooks` (tasks 1-4) ; `src.auth.setup.init_auth`, `src.auth.db`, `flask_login`. +- Produces: + + - `subscriptions_bp` (Blueprint) + - `init_subscriptions(app) -> None` + +- [ ] **Step 1: Ajouter au conftest une app abonnements + un client connecté** + +Append to `tests/subscriptions/conftest.py` : + +```python +@pytest.fixture +def sub_app(users_db_path, monkeypatch): + from flask import Flask + + from src.auth.setup import init_auth + from src.subscriptions.setup import init_subscriptions + + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t") + app = Flask(__name__) + app.config["WTF_CSRF_ENABLED"] = False + init_auth(app) + init_subscriptions(app) + return app + + +@pytest.fixture +def logged_in_client(sub_app): + from src.auth import db as auth_db + + auth_db.init_schema() + uid = auth_db.create_user("sub@ex.fr", "hash") + auth_db.set_email_verified(uid) + client = sub_app.test_client() + with client.session_transaction() as sess: + sess["_user_id"] = str(uid) + sess["_fresh"] = True + return client, uid +``` + +- [ ] **Step 2: Écrire les tests des routes** + +Create `tests/subscriptions/test_routes.py` : + +```python +import hashlib +import hmac + +from src.subscriptions import client as frisbii_client +from src.subscriptions import db + + +def _sign(secret, timestamp, event_id): + return hmac.new( + secret.encode(), (timestamp + event_id).encode(), hashlib.sha256 + ).hexdigest() + + +def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): + client, uid = logged_in_client + monkeypatch.setattr( + frisbii_client, "get_or_create_customer", lambda h, e: {"handle": h} + ) + monkeypatch.setattr( + frisbii_client, + "create_subscription_session", + lambda plan, cust, ok, ko: "https://pay.test/cs_1", + ) + resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) + assert resp.status_code == 303 + assert resp.headers["Location"] == "https://pay.test/cs_1" + assert db.get_by_user(uid)["status"] == "pending" + + +def test_subscribe_unknown_plan(logged_in_client): + client, _ = logged_in_client + resp = client.post("/subscriptions/subscribe", data={"plan": "bidon"}) + assert resp.status_code == 400 + + +def test_subscribe_api_error_redirects_with_error(logged_in_client, monkeypatch): + client, _ = logged_in_client + + def boom(h, e): + raise frisbii_client.FrisbiiError(500, "boom") + + monkeypatch.setattr(frisbii_client, "get_or_create_customer", boom) + resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) + assert resp.status_code == 302 + assert "error=frisbii" in resp.headers["Location"] + + +def test_subscribe_requires_login(sub_app): + resp = sub_app.test_client().post("/subscriptions/subscribe", data={"plan": "simple"}) + assert resp.status_code in (302, 401) + + +def test_cancel_calls_api_and_marks_cancelled(logged_in_client, monkeypatch): + client, uid = logged_in_client + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + db.update_from_webhook("decpinfo-%d" % uid, "sub_42", "active", "2099-01-01T00:00:00+00:00") + monkeypatch.setattr( + frisbii_client, + "cancel_subscription", + lambda handle: {"expires": "2099-02-01T00:00:00+00:00"}, + ) + resp = client.post("/subscriptions/cancel") + assert resp.status_code == 302 + assert "resiliation=ok" in resp.headers["Location"] + row = db.get_by_user(uid) + assert row["status"] == "cancelled" + assert row["current_period_end"] == "2099-02-01T00:00:00+00:00" + + +def test_webhook_invalid_signature(sub_app): + resp = sub_app.test_client().post("/frisbii/webhook", json={"id": "e", "signature": "x"}) + assert resp.status_code == 403 + + +def test_webhook_updates_subscription(sub_app, monkeypatch): + from src.auth import db as auth_db + + auth_db.init_schema() + uid = auth_db.create_user("wh@ex.fr", "hash") + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + monkeypatch.setattr( + frisbii_client, + "get_subscription", + lambda h: {"state": "active", "next_period_start": "2099-01-01T00:00:00+00:00"}, + ) + payload = { + "id": "evt_1", + "timestamp": "2026-06-25T10:00:00Z", + "event_type": "subscription_created", + "customer": "decpinfo-%d" % uid, + "subscription": "sub_42", + } + payload["signature"] = _sign("s3cr3t", payload["timestamp"], payload["id"]) + resp = sub_app.test_client().post("/frisbii/webhook", json=payload) + assert resp.status_code == 200 + row = db.get_by_user(uid) + assert row["status"] == "active" + assert row["frisbii_subscription_handle"] == "sub_42" +``` + +- [ ] **Step 3: Lancer les tests pour vérifier l'échec** + +Run: `uv run pytest tests/subscriptions/test_routes.py -v` +Expected: FAIL (blueprint / setup absents). + +- [ ] **Step 4: Implémenter `routes.py`** + +Create `src/subscriptions/routes.py` : + +```python +import os + +from flask import Blueprint, redirect, request +from flask_login import current_user, login_required + +from src.subscriptions import client, db, plans, webhooks +from src.utils import logger + +subscriptions_bp = Blueprint("subscriptions", __name__) + + +def _customer_handle(user_id: int) -> str: + return f"decpinfo-{user_id}" + + +@subscriptions_bp.route("/subscriptions/subscribe", methods=["POST"]) +@login_required +def subscribe(): + plan_key = request.form.get("plan") or "" + handle = plans.resolve_handle(plan_key) + if handle is None: + return "Plan inconnu", 400 + + base = os.getenv("APP_BASE_URL", "") + cust = _customer_handle(current_user.id) + try: + client.get_or_create_customer(cust, current_user.email) + db.create_pending(current_user.id, cust, plan_key) + url = client.create_subscription_session( + handle, + cust, + f"{base}/compte/abonnement?paiement=succes", + f"{base}/compte/abonnement?paiement=annule", + ) + except client.FrisbiiError: + logger.exception("Échec de création de session d'abonnement Frisbii") + return redirect("/compte/abonnement?error=frisbii") + return redirect(url, code=303) + + +@subscriptions_bp.route("/subscriptions/cancel", methods=["POST"]) +@login_required +def cancel(): + row = db.get_by_user(current_user.id) + if row is None or not row["frisbii_subscription_handle"]: + return "Aucun abonnement à résilier", 400 + try: + sub = client.cancel_subscription(row["frisbii_subscription_handle"]) + except client.FrisbiiError: + logger.exception("Échec de résiliation Frisbii") + return redirect("/compte/abonnement?error=frisbii") + db.set_cancelled(current_user.id, sub.get("expires")) + return redirect("/compte/abonnement?resiliation=ok") + + +@subscriptions_bp.route("/frisbii/webhook", methods=["POST"]) +def webhook(): + payload = request.get_json(silent=True) or {} + if not webhooks.verify_signature(payload, os.getenv("FRISBII_WEBHOOK_SECRET", "")): + return "", 403 + + customer = payload.get("customer") + sub_handle = payload.get("subscription") + if not customer or db.get_by_customer(customer) is None: + return "", 200 # rien à rapprocher + try: + sub = client.get_subscription(sub_handle) if sub_handle else None + except client.FrisbiiError: + logger.exception("Webhook : lecture de l'abonnement Frisbii impossible") + return "", 502 # Frisbii réessaiera + if sub is None: + return "", 200 + status, current_period_end = webhooks.map_subscription(sub) + db.update_from_webhook(customer, sub_handle, status, current_period_end) + return "", 200 +``` + +- [ ] **Step 5: Implémenter `setup.py`** + +L'exemption CSRF doit cibler **la seule vue webhook** (pas tout le blueprint, sinon +`subscribe`/`cancel` perdraient la protection CSRF). Create `src/subscriptions/setup.py` : + +```python +import os + +from flask import Flask + +from src.subscriptions import db +from src.utils import logger + +_REQUIRED_ENV = ( + "FRISBII_API_KEY", + "FRISBII_WEBHOOK_SECRET", + "FRISBII_PLAN_SIMPLE", + "FRISBII_PLAN_SOUTIEN", +) + + +def init_subscriptions(app: Flask) -> None: + db.init_schema() + + from src.subscriptions.routes import subscriptions_bp, webhook + + app.register_blueprint(subscriptions_bp) + + # Le webhook reçoit des POST externes : pas de jeton CSRF possible. + from src.auth.setup import _csrf as _auth_csrf + + if _auth_csrf is not None: + _auth_csrf.exempt(webhook) # exempte la seule vue webhook + + missing = [name for name in _REQUIRED_ENV if not os.getenv(name)] + if missing: + logger.warning( + "Variables Frisbii manquantes (%s) : les abonnements échoueront. " + "Définissez-les dans .env (voir .template.env).", + ", ".join(missing), + ) +``` + +- [ ] **Step 6: Câbler dans `src/app.py`** + +Dans `src/app.py`, juste après le bloc `init_api(app.server)` (vers la ligne 91), ajouter : + +```python +from src.subscriptions.setup import init_subscriptions # noqa: E402 + +init_subscriptions(app.server) +``` + +- [ ] **Step 7: Documenter les variables dans `.template.env`** + +Ajouter à la fin de `.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 (déf. https://api.reepay.com, à confirmer) +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 +``` + +- [ ] **Step 8: Lancer les tests pour vérifier le succès** + +Run: `uv run pytest tests/subscriptions/test_routes.py -v` +Expected: PASS (7 tests). + +- [ ] **Step 9: Commit** + +```bash +git add src/subscriptions/routes.py src/subscriptions/setup.py src/app.py .template.env tests/subscriptions/ +git commit -m "feat(abonnement): routes subscribe/cancel/webhook + câblage app (#90)" +``` + +--- + +### Task 6: Page `/compte/abonnement` + câblage de l'accès + +**Files:** + +- Modify: `src/pages/compte_abonnement.py` (remplacer le placeholder) +- Modify: `src/pages/_compte_shell.py:41-43` (brancher `current_user_has_subscription`) +- Test: `tests/subscriptions/test_compte_abonnement.py` + +**Interfaces:** + +- Consumes: `src.subscriptions.db.has_active_subscription`, `src.subscriptions.db.get_by_user`, `src.subscriptions.plans.plan_meta`, `src.subscriptions.plans.trial_days`, `flask_login.current_user`. +- Produces: layout de page mis à jour ; `current_user_has_subscription()` réel. + +- [ ] **Step 1: Écrire les tests (rendu logique, sans navigateur)** + +Create `tests/subscriptions/test_compte_abonnement.py` : + +```python +from src.subscriptions import plans + + +def test_plan_cards_present_when_no_subscription(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + from src.pages import compte_abonnement + + cards = compte_abonnement._plan_cards(trial_for=lambda key: 2) + text = str(cards) + assert "Abonnement simple" in text + assert "Abonnement de soutien" in text + assert "2 jours d'essai gratuit" in text + + +def test_active_view_shows_cancel(monkeypatch): + from src.pages import compte_abonnement + + row = {"plan": "simple", "status": "active", "current_period_end": "2099-01-01T00:00:00+00:00"} + view = compte_abonnement._active_view(row) + assert "Résilier" in str(view) + + +def test_active_view_trial_banner(monkeypatch): + from src.pages import compte_abonnement + + row = {"plan": "simple", "status": "trial", "current_period_end": "2099-01-01T00:00:00+00:00"} + assert "Essai gratuit" in str(compte_abonnement._active_view(row)) +``` + +- [ ] **Step 2: Lancer les tests pour vérifier l'échec** + +Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v` +Expected: FAIL (`_plan_cards` / `_active_view` absents). + +- [ ] **Step 3: Réécrire `compte_abonnement.py`** + +Replace le contenu de `src/pages/compte_abonnement.py` : + +```python +import dash_bootstrap_components as dbc +from dash import dcc, html, register_page +from flask_login import current_user + +from src.pages._compte_shell import account_guard, account_shell +from src.subscriptions import db, plans + +register_page( + __name__, + path="/compte/abonnement", + title="Abonnement | decp.info", + name="Abonnement", + description="Gestion de votre abonnement decp.info.", +) + +_PLAN_KEYS = ("simple", "soutien") + + +def _csrf_input(index: str): + return dcc.Input( + type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token" + ) + + +def _plan_card(meta: dict, trial: int | None): + badge = ( + html.Div(f"{trial} jours d'essai gratuit", className="text-success mb-2") + if trial + else None + ) + return dbc.Card( + dbc.CardBody( + [ + html.H4(meta["label"]), + html.P(f"{meta['prix_ht']} € HT / mois"), + html.P(meta["description"]), + badge, + html.Form( + method="POST", + action="/subscriptions/subscribe", + children=[ + _csrf_input(f"subscribe-{meta['key']}"), + dcc.Input(type="hidden", name="plan", value=meta["key"]), + html.Button("S'abonner", type="submit", className="btn btn-primary"), + ], + ), + ] + ), + className="mb-3", + ) + + +def _plan_cards(trial_for=plans.trial_days): + cards = [] + for key in _PLAN_KEYS: + meta = plans.plan_meta(key) + if meta: + cards.append(_plan_card(meta, trial_for(key))) + return dbc.Row([dbc.Col(c, md=6) for c in cards]) + + +def _explainer(): + return html.Div( + [ + html.H4("À quoi servent les abonnements"), + html.Ul( + [ + html.Li("Abonnement Frisbii (solution de paiement) : 50 €"), + html.Li("Serveur Scaleway : 40 €"), + html.Li("Espace de coworking : 250 €"), + html.Li("Salaire médian : 3 840 €"), + ] + ), + html.H4("Ce que les abonnements de soutien permettraient"), + html.Ul( + [ + html.Li( + "Rédaction d'études à partir des données, par exemple sur les " + "acheteurs dont les données sont introuvables et les raisons de " + "cette non-publication." + ), + html.Li( + "Coordination des bonnes volontés souhaitant militer pour une " + "législation plus exigeante sur la transparence de la commande " + "publique." + ), + ] + ), + ] + ) + + +def _active_view(row): + meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]} + end = row["current_period_end"] + blocks = [html.H3(meta["label"]), html.P(f"Statut : {row['status']}")] + if row["status"] == "trial": + blocks.append( + dbc.Alert( + f"Essai gratuit jusqu'au {end}, puis débit automatique.", color="info" + ) + ) + elif row["status"] == "cancelled": + blocks.append( + dbc.Alert(f"Abonnement résilié, actif jusqu'au {end}.", color="warning") + ) + else: + blocks.append(html.P(f"Prochain renouvellement : {end}")) + if row["status"] in ("trial", "active"): + blocks.append( + html.Form( + method="POST", + action="/subscriptions/cancel", + children=[ + _csrf_input("cancel"), + html.Button("Résilier", type="submit", className="btn btn-outline-danger"), + ], + ) + ) + return html.Div(blocks) + + +def _feedback(query): + msgs = { + "succes": ("Merci, votre abonnement est en cours d'activation.", "success"), + "annule": ("Paiement annulé.", "secondary"), + } + out = [] + if query.get("paiement") in msgs: + text, color = msgs[query["paiement"]] + out.append(dbc.Alert(text, color=color)) + if query.get("resiliation") == "ok": + out.append(dbc.Alert("Votre abonnement a été résilié.", color="info")) + if query.get("error") == "frisbii": + out.append(dbc.Alert("Une erreur est survenue avec le service de paiement.", color="danger")) + return out + + +def layout(**query): + guard = account_guard("/compte/abonnement", require_subscription=False) + if guard is not None: + return guard + + row = db.get_by_user(current_user.id) if current_user.is_authenticated else None + has_access = db.has_active_subscription(current_user.id) if current_user.is_authenticated else False + + body = [html.H2("Abonnement"), *_feedback(query)] + if has_access and row is not None: + body.append(_active_view(row)) + else: + body.extend([_plan_cards(), html.Hr(), _explainer()]) + + return account_shell("abonnement", html.Div(body)) +``` + +- [ ] **Step 4: Brancher `current_user_has_subscription` dans `_compte_shell.py`** + +Dans `src/pages/_compte_shell.py`, remplacer la fonction stub (lignes ~41-43) : + +```python +def current_user_has_subscription() -> bool: + """Stub : à brancher sur la facturation (issue #73).""" + return False +``` + +par : + +```python +def current_user_has_subscription() -> bool: + from src.subscriptions import db + + if not current_user.is_authenticated: + return False + return db.has_active_subscription(current_user.id) +``` + +(L'import `from flask_login import current_user` existe déjà en tête de fichier.) + +- [ ] **Step 5: Lancer les tests pour vérifier le succès** + +Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 6: Lancer toute la suite abonnements + vérifs de non-régression compte** + +Run: `uv run pytest tests/subscriptions tests/test_compte_pages.py tests/test_compte_shell.py -v` +Expected: PASS (aucune régression sur l'espace compte). + +- [ ] **Step 7: Commit** + +```bash +git add src/pages/compte_abonnement.py src/pages/_compte_shell.py tests/subscriptions/test_compte_abonnement.py +git commit -m "feat(abonnement): page /compte/abonnement + accès premium réel (#90)" +``` + +--- + +## Notes d'intégration (hors code, à faire en environnement de test) + +À valider lors du branchement réel sur Frisbii (non bloquant pour le code/tests) : + +1. **Base URL & endpoints** : confirmer `FRISBII_API_BASE_URL` et les chemins + (`/v1/customer`, `/v1/session/subscription`, `/v1/subscription/{h}/cancel`, + `/v1/subscription/{h}`, `/v1/plan/{h}`) dans la doc Frisbii. +2. **Schéma de signature webhook** : confirmer `HMAC-SHA256(secret, timestamp + id)` + et ajuster `webhooks.verify_signature` si différent. +3. **Noms de champs subscription** (`state`, `trial_end`, `expires`, `is_cancelled`, + `next_period_start`) : confirmer et ajuster `webhooks.map_subscription`. +4. **Dashboard Frisbii** : créer les deux plans mensuels (mois glissants, essai 2 j, + carte collectée à la souscription) et configurer le webhook vers + `{APP_BASE_URL}/frisbii/webhook`. + +## Hors périmètre (rappel spec) + +Changement de plan en self-service, montant de soutien libre, réconciliation cron, +historique de factures dans l'UI. From a18d43dae4cfa2e6538f1e7cedfa0347f5ce1e24 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:41:31 +0200 Subject: [PATCH 05/14] docs(abonnement): anti-abus essai (trial_used + no_trial) (#90) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N --- .../plans/2026-06-25-frisbii-abonnements.md | 86 +++++++++++++++++-- .../2026-06-25-frisbii-abonnements-design.md | 16 ++++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md index 8082bd2..ed139ff 100644 --- a/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md +++ b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md @@ -39,7 +39,7 @@ - `class FrisbiiError(Exception)` avec attribut `.status_code: int` et `.body`. - `get_or_create_customer(handle: str, email: str) -> dict` - - `create_subscription_session(plan_handle: str, customer_handle: str, accept_url: str, cancel_url: str) -> str` (renvoie l'URL hébergée) + - `create_subscription_session(plan_handle: str, customer_handle: str, accept_url: str, cancel_url: str, no_trial: bool = False) -> str` (renvoie l'URL hébergée ; `no_trial=True` désactive l'essai du plan pour cette souscription) - `cancel_subscription(subscription_handle: str) -> dict` - `get_subscription(subscription_handle: str) -> dict` - `get_plan(plan_handle: str) -> dict` @@ -135,6 +135,14 @@ def test_create_subscription_session_returns_url(fake_httpx): assert body["cancel_url"] == "https://app/ko" +def test_create_subscription_session_no_trial(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](200, {"url": "https://pay.test/cs_2"})) + client.create_subscription_session( + "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True + ) + assert fake_httpx["calls"][0]["json"]["prepare_subscription"]["no_trial"] is True + + def test_http_error_raises_frisbii_error(fake_httpx): fake_httpx["queue"].append(fake_httpx["Response"](500, {"error": "boom"})) with pytest.raises(client.FrisbiiError) as exc: @@ -201,15 +209,22 @@ def get_or_create_customer(handle: str, email: str) -> dict: def create_subscription_session( - plan_handle: str, customer_handle: str, accept_url: str, cancel_url: str + plan_handle: str, + customer_handle: str, + accept_url: str, + cancel_url: str, + no_trial: bool = False, ) -> str: + prepare = {"plan": plan_handle, "customer": customer_handle} + if no_trial: + prepare["no_trial"] = True data = _call( "POST", "/v1/session/subscription", json={ "accept_url": accept_url, "cancel_url": cancel_url, - "prepare_subscription": {"plan": plan_handle, "customer": customer_handle}, + "prepare_subscription": prepare, }, ) return data["url"] @@ -230,7 +245,7 @@ def get_plan(plan_handle: str) -> dict: - [ ] **Step 5: Lancer les tests pour vérifier le succès** Run: `uv run pytest tests/subscriptions/test_client.py -v` -Expected: PASS (5 tests). +Expected: PASS (6 tests). - [ ] **Step 6: Commit** @@ -260,6 +275,7 @@ git commit -m "feat(abonnement): client HTTP Frisbii (#90)" - `update_from_webhook(customer_handle: str, subscription_handle: str | None, status: str, current_period_end: str | None) -> None` - `set_cancelled(user_id: int, current_period_end: str | None) -> None` - `has_active_subscription(user_id: int) -> bool` + - `has_used_trial(user_id: int) -> bool` (anti-abus : essai déjà consommé ?) - [ ] **Step 1: Écrire les tests du module DB** @@ -344,6 +360,20 @@ def test_set_cancelled(users_db_path): row = db.get_by_user(uid) assert row["status"] == "cancelled" assert row["current_period_end"] == end + + +def test_trial_used_is_sticky_across_resubscribe(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + assert db.has_used_trial(uid) is False + # l'abonnement entre en essai → trial_used positionné + db.update_from_webhook("decpinfo-1", "sub_42", "trial", _future()) + assert db.has_used_trial(uid) is True + # essai abandonné, puis nouvelle souscription : trial_used reste vrai + db.update_from_webhook("decpinfo-1", "sub_42", "expired", _past()) + db.create_pending(uid, "decpinfo-1", "soutien") + assert db.has_used_trial(uid) is True ``` - [ ] **Step 2: Lancer les tests pour vérifier l'échec** @@ -369,6 +399,7 @@ CREATE TABLE IF NOT EXISTS subscriptions ( plan TEXT, status TEXT, current_period_end TEXT, + trial_used INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -426,12 +457,21 @@ def update_from_webhook( status: str, current_period_end: str | None, ) -> None: + trial_flag = 1 if status in _ACCESS_STATUSES else 0 get_conn().execute( "UPDATE subscriptions SET " "frisbii_subscription_handle = COALESCE(?, frisbii_subscription_handle), " - "status = ?, current_period_end = ?, updated_at = ? " + "status = ?, current_period_end = ?, " + "trial_used = max(trial_used, ?), updated_at = ? " "WHERE frisbii_customer_handle = ?", - (subscription_handle, status, current_period_end, _now(), customer_handle), + ( + subscription_handle, + status, + current_period_end, + trial_flag, + _now(), + customer_handle, + ), ) @@ -452,12 +492,17 @@ def has_active_subscription(user_id: int) -> bool: if row["status"] == "cancelled" and row["current_period_end"]: return row["current_period_end"] > _now() return False + + +def has_used_trial(user_id: int) -> bool: + row = get_by_user(user_id) + return bool(row and row["trial_used"]) ``` - [ ] **Step 4: Lancer les tests pour vérifier le succès** Run: `uv run pytest tests/subscriptions/test_db.py -v` -Expected: PASS (5 tests). +Expected: PASS (6 tests). - [ ] **Step 5: Commit** @@ -875,7 +920,7 @@ def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): monkeypatch.setattr( frisbii_client, "create_subscription_session", - lambda plan, cust, ok, ko: "https://pay.test/cs_1", + lambda plan, cust, ok, ko, no_trial=False: "https://pay.test/cs_1", ) resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) assert resp.status_code == 303 @@ -883,6 +928,26 @@ def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): assert db.get_by_user(uid)["status"] == "pending" +def test_subscribe_disables_trial_after_first_use(logged_in_client, monkeypatch): + client, uid = logged_in_client + # L'utilisateur a déjà consommé un essai par le passé. + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + db.update_from_webhook("decpinfo-%d" % uid, "sub_42", "trial", "2099-01-01T00:00:00+00:00") + captured = {} + monkeypatch.setattr( + frisbii_client, "get_or_create_customer", lambda h, e: {"handle": h} + ) + + def fake_session(plan, cust, ok, ko, no_trial=False): + captured["no_trial"] = no_trial + return "https://pay.test/cs_9" + + monkeypatch.setattr(frisbii_client, "create_subscription_session", fake_session) + resp = client.post("/subscriptions/subscribe", data={"plan": "soutien"}) + assert resp.status_code == 303 + assert captured["no_trial"] is True + + def test_subscribe_unknown_plan(logged_in_client): client, _ = logged_in_client resp = client.post("/subscriptions/subscribe", data={"plan": "bidon"}) @@ -992,11 +1057,14 @@ def subscribe(): try: client.get_or_create_customer(cust, current_user.email) db.create_pending(current_user.id, cust, plan_key) + # Anti-abus : pas de nouvel essai si l'utilisateur en a déjà consommé un. + no_trial = db.has_used_trial(current_user.id) url = client.create_subscription_session( handle, cust, f"{base}/compte/abonnement?paiement=succes", f"{base}/compte/abonnement?paiement=annule", + no_trial=no_trial, ) except client.FrisbiiError: logger.exception("Échec de création de session d'abonnement Frisbii") @@ -1110,7 +1178,7 @@ FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks - [ ] **Step 8: Lancer les tests pour vérifier le succès** Run: `uv run pytest tests/subscriptions/test_routes.py -v` -Expected: PASS (7 tests). +Expected: PASS (8 tests). - [ ] **Step 9: Commit** diff --git a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md index 55f65b4..c268ea2 100644 --- a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md +++ b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md @@ -43,6 +43,17 @@ les branche sur Frisbii. automatiquement de `trial` à `active`. Si le paiement échoue → `expired`. Une résiliation pendant l'essai expire en **fin d'essai** (pas de débit). Pendant l'essai, l'utilisateur a **accès aux fonctions premium**. + + **Anti-abus (un seul essai par compte).** Frisbii ne restreint pas l'essai par + client : sans garde-fou, un utilisateur pourrait s'abonner, résilier avant la fin + de l'essai (sans débit) et recommencer indéfiniment. On mémorise donc côté app un + indicateur `trial_used` (positionné dès que l'abonnement entre en `trial` ou + `active`). À toute souscription ultérieure d'un utilisateur dont `trial_used` est + vrai, la session est créée avec **`no_trial=true`** → passage direct en `active`, + débit immédiat, sans nouvel essai. _Limite connue, non traitée :_ un utilisateur + pourrait créer plusieurs comptes decp.info (emails différents) pour refarmer des + essais — acceptable vu l'essai de 2 jours et le contexte d'intérêt public. + 5. **Résiliation en fin de période courante.** `POST` cancel Frisbii avec le comportement **par défaut** (expiration en fin de période courante — ou fin d'essai si en essai). L'accès est maintenu jusqu'à `current_period_end` renvoyé @@ -104,6 +115,7 @@ CREATE TABLE IF NOT EXISTS subscriptions ( plan TEXT, -- 'simple' | 'soutien' status TEXT, -- 'pending' | 'trial' | 'active' | 'cancelled' | 'expired' current_period_end TEXT, -- ISO 8601, nullable + trial_used INTEGER NOT NULL DEFAULT 0, -- 1 dès qu'un essai a été consommé created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -144,6 +156,8 @@ Fonctions : - 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')`. + - **Anti-abus** : si `trial_used` est déjà vrai pour cet utilisateur, la session est + créée avec `no_trial=true` (pas de nouvel essai gratuit). - `create_subscription_session(...)` avec `accept_url={APP_BASE_URL}/compte/abonnement?paiement=succes` et `cancel_url={APP_BASE_URL}/compte/abonnement?paiement=annule`. @@ -169,6 +183,8 @@ Fonctions : - `invoice_settled` / renouvellement → `current_period_end` maj. - `subscription_cancelled` → `status='cancelled'`, `current_period_end` maj. - `subscription_expired` / échec de paiement terminal → `status='expired'`. + - Dès que le statut calculé est `trial` ou `active`, positionne `trial_used=1` + (anti-abus, valeur collante). - É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. From b0543c0c03600304d5550fb3be6e4375fe59ca10 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:43:51 +0200 Subject: [PATCH 06/14] =?UTF-8?q?docs(abonnement):=20mention=20explicite?= =?UTF-8?q?=20'sans=20p=C3=A9riode=20d'essai'=20si=20essai=20d=C3=A9j?= =?UTF-8?q?=C3=A0=20utilis=C3=A9=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 --- .../plans/2026-06-25-frisbii-abonnements.md | 38 ++++++++++++++----- .../2026-06-25-frisbii-abonnements-design.md | 9 ++++- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md index ed139ff..db814a4 100644 --- a/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md +++ b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md @@ -1222,6 +1222,16 @@ def test_plan_cards_present_when_no_subscription(monkeypatch): assert "2 jours d'essai gratuit" in text +def test_plan_cards_no_trial_when_trial_used(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + from src.pages import compte_abonnement + + text = str(compte_abonnement._plan_cards(trial_used=True, trial_for=lambda key: 2)) + assert "Sans période d'essai" in text + assert "2 jours d'essai gratuit" not in text + + def test_active_view_shows_cancel(monkeypatch): from src.pages import compte_abonnement @@ -1271,12 +1281,16 @@ def _csrf_input(index: str): ) -def _plan_card(meta: dict, trial: int | None): - badge = ( - html.Div(f"{trial} jours d'essai gratuit", className="text-success mb-2") - if trial - else None - ) +def _plan_card(meta: dict, trial: int | None, trial_used: bool): + if trial_used: + badge = html.Div( + "Sans période d'essai (déjà utilisée) — débit immédiat", + className="text-muted mb-2", + ) + elif trial: + badge = html.Div(f"{trial} jours d'essai gratuit", className="text-success mb-2") + else: + badge = None return dbc.Card( dbc.CardBody( [ @@ -1299,12 +1313,12 @@ def _plan_card(meta: dict, trial: int | None): ) -def _plan_cards(trial_for=plans.trial_days): +def _plan_cards(trial_used=False, trial_for=plans.trial_days): cards = [] for key in _PLAN_KEYS: meta = plans.plan_meta(key) if meta: - cards.append(_plan_card(meta, trial_for(key))) + cards.append(_plan_card(meta, trial_for(key), trial_used)) return dbc.Row([dbc.Col(c, md=6) for c in cards]) @@ -1393,11 +1407,15 @@ def layout(**query): row = db.get_by_user(current_user.id) if current_user.is_authenticated else None has_access = db.has_active_subscription(current_user.id) if current_user.is_authenticated else False + trial_used = ( + db.has_used_trial(current_user.id) if current_user.is_authenticated else False + ) + body = [html.H2("Abonnement"), *_feedback(query)] if has_access and row is not None: body.append(_active_view(row)) else: - body.extend([_plan_cards(), html.Hr(), _explainer()]) + body.extend([_plan_cards(trial_used=trial_used), html.Hr(), _explainer()]) return account_shell("abonnement", html.Div(body)) ``` @@ -1428,7 +1446,7 @@ def current_user_has_subscription() -> bool: - [ ] **Step 5: Lancer les tests pour vérifier le succès** Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v` -Expected: PASS (3 tests). +Expected: PASS (4 tests). - [ ] **Step 6: Lancer toute la suite abonnements + vérifs de non-régression compte** diff --git a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md index c268ea2..4e7023d 100644 --- a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md +++ b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md @@ -230,8 +230,13 @@ accessible sans abonnement). Le contenu dépend de l'état : - 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 ». Mention « {n} jours d'essai gratuit » sur les cartes, où `{n}` est - lu depuis le plan via `plans.trial_days(key)` (masquée si le plan n'a pas d'essai). + « S'abonner ». Mention d'essai sur les cartes, selon l'éligibilité de l'utilisateur + (`db.has_used_trial`) : + - essai non encore utilisé et plan avec essai → « {n} jours d'essai gratuit » + (`{n}` lu depuis le plan via `plans.trial_days(key)`) ; + - essai déjà utilisé (souscription créée avec `no_trial=true`) → mention explicite + **« Sans période d'essai (déjà utilisée) — débit immédiat »** ; + - plan sans essai configuré → aucune mention. - Contenu pédagogique (issue #90) : - **À quoi servent les abonnements** : abonnement Frisbii 50 €, serveur Scaleway 40 €, espace de coworking 250 €, salaire médian 3 840 €. From 007cb28b21f55dfd1d20e49b7341e91b9afe95f9 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:47:21 +0200 Subject: [PATCH 07/14] feat(abonnement): client HTTP Frisbii (#90) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N --- src/subscriptions/__init__.py | 0 src/subscriptions/client.py | 80 ++++++++++++++++++++++++++++++ tests/subscriptions/__init__.py | 0 tests/subscriptions/conftest.py | 36 ++++++++++++++ tests/subscriptions/test_client.py | 61 +++++++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 src/subscriptions/__init__.py create mode 100644 src/subscriptions/client.py create mode 100644 tests/subscriptions/__init__.py create mode 100644 tests/subscriptions/conftest.py create mode 100644 tests/subscriptions/test_client.py diff --git a/src/subscriptions/__init__.py b/src/subscriptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/subscriptions/client.py b/src/subscriptions/client.py new file mode 100644 index 0000000..4ce2ad2 --- /dev/null +++ b/src/subscriptions/client.py @@ -0,0 +1,80 @@ +import os + +import httpx + +# NB : base URL et schémas d'endpoints à confirmer dans la doc Frisbii lors de la +# première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur +# l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin. +_DEFAULT_BASE_URL = "https://api.reepay.com" +_TIMEOUT = 15.0 + + +class FrisbiiError(Exception): + def __init__(self, status_code: int, body): + super().__init__(f"Frisbii API error {status_code}: {body}") + self.status_code = status_code + self.body = body + + +def _api_key() -> str: + return os.getenv("FRISBII_API_KEY", "") + + +def _base_url() -> str: + return os.getenv("FRISBII_API_BASE_URL") or _DEFAULT_BASE_URL + + +def _call(method: str, path: str, json: dict | None = None) -> dict: + resp = httpx.request( + method, + f"{_base_url()}{path}", + auth=(_api_key(), ""), + json=json, + timeout=_TIMEOUT, + ) + if resp.status_code >= 400: + raise FrisbiiError(resp.status_code, resp.text) + return resp.json() + + +def get_or_create_customer(handle: str, email: str) -> dict: + try: + return _call("GET", f"/v1/customer/{handle}") + except FrisbiiError as exc: + if exc.status_code != 404: + raise + return _call("POST", "/v1/customer", json={"handle": handle, "email": email}) + + +def create_subscription_session( + plan_handle: str, + customer_handle: str, + accept_url: str, + cancel_url: str, + no_trial: bool = False, +) -> str: + prepare = {"plan": plan_handle, "customer": customer_handle} + if no_trial: + prepare["no_trial"] = True + data = _call( + "POST", + "/v1/session/subscription", + json={ + "accept_url": accept_url, + "cancel_url": cancel_url, + "prepare_subscription": prepare, + }, + ) + return data["url"] + + +def cancel_subscription(subscription_handle: str) -> dict: + return _call("POST", f"/v1/subscription/{subscription_handle}/cancel") + + +def get_subscription(subscription_handle: str) -> dict: + return _call("GET", f"/v1/subscription/{subscription_handle}") + + +def get_plan(plan_handle: str) -> dict: + return _call("GET", f"/v1/plan/{plan_handle}") diff --git a/tests/subscriptions/__init__.py b/tests/subscriptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py new file mode 100644 index 0000000..a3d4ad1 --- /dev/null +++ b/tests/subscriptions/conftest.py @@ -0,0 +1,36 @@ +import json as _json + +import pytest + + +class FakeResponse: + def __init__(self, status_code: int, payload=None): + self.status_code = status_code + self._payload = payload if payload is not None else {} + + def json(self): + return self._payload + + @property + def text(self): + return _json.dumps(self._payload) + + +@pytest.fixture +def fake_httpx(monkeypatch): + """Capture les appels httpx.request du client Frisbii et renvoie des réponses programmées.""" + from src.subscriptions import client + + calls = [] + queue = [] + + def _fake_request(method, url, **kwargs): + calls.append({"method": method, "url": url, **kwargs}) + if queue: + return queue.pop(0) + return FakeResponse(200, {}) + + monkeypatch.setenv("FRISBII_API_KEY", "priv_test") + monkeypatch.setenv("FRISBII_API_BASE_URL", "https://api.test") + monkeypatch.setattr(client.httpx, "request", _fake_request) + return {"calls": calls, "queue": queue, "Response": FakeResponse} diff --git a/tests/subscriptions/test_client.py b/tests/subscriptions/test_client.py new file mode 100644 index 0000000..18e52c2 --- /dev/null +++ b/tests/subscriptions/test_client.py @@ -0,0 +1,61 @@ +import pytest + +from src.subscriptions import client + + +def test_auth_and_base_url_used(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "decpinfo-1"})) + client.get_or_create_customer("decpinfo-1", "a@b.fr") + call = fake_httpx["calls"][0] + assert call["url"] == "https://api.test/v1/customer/decpinfo-1" + assert call["auth"] == ("priv_test", "") + + +def test_get_or_create_customer_existing(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "decpinfo-1"})) + result = client.get_or_create_customer("decpinfo-1", "a@b.fr") + assert result == {"handle": "decpinfo-1"} + assert len(fake_httpx["calls"]) == 1 # pas de POST de création + + +def test_get_or_create_customer_creates_on_404(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](404, {"error": "not found"})) + fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "decpinfo-1"})) + result = client.get_or_create_customer("decpinfo-1", "a@b.fr") + assert result == {"handle": "decpinfo-1"} + assert fake_httpx["calls"][1]["method"] == "POST" + assert fake_httpx["calls"][1]["json"] == {"handle": "decpinfo-1", "email": "a@b.fr"} + + +def test_create_subscription_session_returns_url(fake_httpx): + fake_httpx["queue"].append( + fake_httpx["Response"](200, {"id": "cs_1", "url": "https://pay.test/cs_1"}) + ) + url = client.create_subscription_session( + "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko" + ) + assert url == "https://pay.test/cs_1" + body = fake_httpx["calls"][0]["json"] + assert body["prepare_subscription"] == { + "plan": "plan_simple", + "customer": "decpinfo-1", + } + assert body["accept_url"] == "https://app/ok" + assert body["cancel_url"] == "https://app/ko" + + +def test_create_subscription_session_no_trial(fake_httpx): + fake_httpx["queue"].append( + fake_httpx["Response"](200, {"url": "https://pay.test/cs_2"}) + ) + client.create_subscription_session( + "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True + ) + assert fake_httpx["calls"][0]["json"]["prepare_subscription"]["no_trial"] is True + + +def test_http_error_raises_frisbii_error(fake_httpx): + fake_httpx["queue"].append(fake_httpx["Response"](500, {"error": "boom"})) + with pytest.raises(client.FrisbiiError) as exc: + client.get_plan("plan_simple") + assert exc.value.status_code == 500 From b7a79d8d86286aa296392092307a48de36efda74 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:50:50 +0200 Subject: [PATCH 08/14] =?UTF-8?q?feat(abonnement):=20table=20subscriptions?= =?UTF-8?q?=20+=20=C3=A9tat=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/subscriptions/db.py | 111 ++++++++++++++++++++++++++++++++ tests/subscriptions/conftest.py | 11 ++++ tests/subscriptions/test_db.py | 92 ++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/subscriptions/db.py create mode 100644 tests/subscriptions/test_db.py diff --git a/src/subscriptions/db.py b/src/subscriptions/db.py new file mode 100644 index 0000000..d7611a6 --- /dev/null +++ b/src/subscriptions/db.py @@ -0,0 +1,111 @@ +import sqlite3 +from datetime import datetime, timezone + +from src.auth.db import get_conn + +SUBSCRIPTIONS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS subscriptions ( + user_id INTEGER PRIMARY KEY, + frisbii_customer_handle TEXT, + frisbii_subscription_handle TEXT, + plan TEXT, + status TEXT, + current_period_end TEXT, + trial_used INTEGER NOT NULL DEFAULT 0, + 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); +""" + +_ACCESS_STATUSES = ("trial", "active") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def init_schema() -> None: + get_conn().executescript(SUBSCRIPTIONS_SCHEMA) + + +def create_pending(user_id: int, customer_handle: str, plan: str) -> None: + now = _now() + get_conn().execute( + "INSERT INTO subscriptions " + "(user_id, frisbii_customer_handle, plan, status, created_at, updated_at) " + "VALUES (?, ?, ?, 'pending', ?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET " + "frisbii_customer_handle=excluded.frisbii_customer_handle, " + "plan=excluded.plan, status='pending', updated_at=excluded.updated_at", + (user_id, customer_handle, plan, now, now), + ) + + +def get_by_user(user_id: int) -> sqlite3.Row | None: + return ( + get_conn() + .execute("SELECT * FROM subscriptions WHERE user_id = ?", (user_id,)) + .fetchone() + ) + + +def get_by_customer(customer_handle: str) -> sqlite3.Row | None: + return ( + get_conn() + .execute( + "SELECT * FROM subscriptions WHERE frisbii_customer_handle = ?", + (customer_handle,), + ) + .fetchone() + ) + + +def update_from_webhook( + customer_handle: str, + subscription_handle: str | None, + status: str, + current_period_end: str | None, +) -> None: + trial_flag = 1 if status in _ACCESS_STATUSES else 0 + get_conn().execute( + "UPDATE subscriptions SET " + "frisbii_subscription_handle = COALESCE(?, frisbii_subscription_handle), " + "status = ?, current_period_end = ?, " + "trial_used = max(trial_used, ?), updated_at = ? " + "WHERE frisbii_customer_handle = ?", + ( + subscription_handle, + status, + current_period_end, + trial_flag, + _now(), + customer_handle, + ), + ) + + +def set_cancelled(user_id: int, current_period_end: str | None) -> None: + get_conn().execute( + "UPDATE subscriptions SET status = 'cancelled', current_period_end = ?, " + "updated_at = ? WHERE user_id = ?", + (current_period_end, _now(), user_id), + ) + + +def has_active_subscription(user_id: int) -> bool: + row = get_by_user(user_id) + if row is None: + return False + if row["status"] in _ACCESS_STATUSES: + return True + if row["status"] == "cancelled" and row["current_period_end"]: + return row["current_period_end"] > _now() + return False + + +def has_used_trial(user_id: int) -> bool: + row = get_by_user(user_id) + return bool(row and row["trial_used"]) diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py index a3d4ad1..104c6d8 100644 --- a/tests/subscriptions/conftest.py +++ b/tests/subscriptions/conftest.py @@ -16,6 +16,17 @@ class FakeResponse: return _json.dumps(self._payload) +@pytest.fixture +def users_db_path(monkeypatch, tmp_path): + from src.auth.db import reset_conn_for_tests + + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + reset_conn_for_tests() + yield db_path + reset_conn_for_tests() + + @pytest.fixture def fake_httpx(monkeypatch): """Capture les appels httpx.request du client Frisbii et renvoie des réponses programmées.""" diff --git a/tests/subscriptions/test_db.py b/tests/subscriptions/test_db.py new file mode 100644 index 0000000..e4e9a82 --- /dev/null +++ b/tests/subscriptions/test_db.py @@ -0,0 +1,92 @@ +from datetime import datetime, timedelta, timezone + +from src.auth import db as auth_db +from src.subscriptions import db + + +def _future(): + return (datetime.now(timezone.utc) + timedelta(days=2)).isoformat() + + +def _past(): + return (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + + +def _make_user(email="u@ex.fr"): + auth_db.init_schema() + return auth_db.create_user(email, "hash") + + +def test_init_schema_creates_table(users_db_path): + db.init_schema() + conn = auth_db.get_conn() + tables = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + assert "subscriptions" in tables + + +def test_create_pending_and_get_by_user(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + row = db.get_by_user(uid) + assert row["status"] == "pending" + assert row["plan"] == "simple" + assert row["frisbii_customer_handle"] == "decpinfo-1" + + +def test_update_from_webhook_sets_status_and_handle(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + db.update_from_webhook("decpinfo-1", "sub_42", "trial", _future()) + row = db.get_by_user(uid) + assert row["status"] == "trial" + assert row["frisbii_subscription_handle"] == "sub_42" + assert db.get_by_customer("decpinfo-1")["user_id"] == uid + + +def test_has_active_subscription_by_status(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + # pending → faux + assert db.has_active_subscription(uid) is False + for status in ("trial", "active"): + db.update_from_webhook("decpinfo-1", "sub_42", status, _future()) + assert db.has_active_subscription(uid) is True + # cancelled futur → vrai, cancelled passé → faux + db.update_from_webhook("decpinfo-1", "sub_42", "cancelled", _future()) + assert db.has_active_subscription(uid) is True + db.update_from_webhook("decpinfo-1", "sub_42", "cancelled", _past()) + assert db.has_active_subscription(uid) is False + db.update_from_webhook("decpinfo-1", "sub_42", "expired", None) + assert db.has_active_subscription(uid) is False + + +def test_set_cancelled(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + db.update_from_webhook("decpinfo-1", "sub_42", "active", _future()) + end = _future() + db.set_cancelled(uid, end) + row = db.get_by_user(uid) + assert row["status"] == "cancelled" + assert row["current_period_end"] == end + + +def test_trial_used_is_sticky_across_resubscribe(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + assert db.has_used_trial(uid) is False + # l'abonnement entre en essai → trial_used positionné + db.update_from_webhook("decpinfo-1", "sub_42", "trial", _future()) + assert db.has_used_trial(uid) is True + # essai abandonné, puis nouvelle souscription : trial_used reste vrai + db.update_from_webhook("decpinfo-1", "sub_42", "expired", _past()) + db.create_pending(uid, "decpinfo-1", "soutien") + assert db.has_used_trial(uid) is True From 4f90184ddd1b515b1cf351fa157e8be655f69dca Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:53:41 +0200 Subject: [PATCH 09/14] =?UTF-8?q?feat(abonnement):=20catalogue=20de=20plan?= =?UTF-8?q?s=20+=20dur=C3=A9e=20d'essai=20dynamique=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/subscriptions/plans.py | 75 +++++++++++++++++++++++++++++++ tests/subscriptions/test_plans.py | 41 +++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/subscriptions/plans.py create mode 100644 tests/subscriptions/test_plans.py diff --git a/src/subscriptions/plans.py b/src/subscriptions/plans.py new file mode 100644 index 0000000..3e5a9c0 --- /dev/null +++ b/src/subscriptions/plans.py @@ -0,0 +1,75 @@ +import os +import time + +from src.subscriptions import client +from src.utils import logger + +_TTL_SECONDS = 3600 +_trial_cache: dict[str, tuple[float, int | None]] = {} + + +def _handle(env_name: str) -> str: + return os.getenv(env_name, "") + + +PLANS = { + "simple": { + "env": "FRISBII_PLAN_SIMPLE", + "label": "Abonnement simple", + "prix_ht": 20, + "description": "Accès aux fonctionnalités premium de decp.info.", + }, + "soutien": { + "env": "FRISBII_PLAN_SOUTIEN", + "label": "Abonnement de soutien", + "prix_ht": 50, + "description": "Mêmes fonctionnalités, contribution renforcée au projet.", + }, +} + + +def resolve_handle(key: str) -> str | None: + meta = PLANS.get(key) + return _handle(meta["env"]) if meta else None + + +def plan_meta(key: str) -> dict | None: + meta = PLANS.get(key) + if meta is None: + return None + return { + "key": key, + "handle": _handle(meta["env"]), + "label": meta["label"], + "prix_ht": meta["prix_ht"], + "description": meta["description"], + } + + +def _parse_trial_interval(value) -> int | None: + # Reepay/Frisbii renvoie une durée type "2d" (jours), "1m" (mois), etc. + # On ne sait afficher que les jours pour l'instant ; format à confirmer. + if not value or not isinstance(value, str): + return None + value = value.strip().lower() + if value.endswith("d") and value[:-1].isdigit(): + return int(value[:-1]) + return None + + +def trial_days(key: str) -> int | None: + handle = resolve_handle(key) + if not handle: + return None + now = time.monotonic() + cached = _trial_cache.get(handle) + if cached and now - cached[0] < _TTL_SECONDS: + return cached[1] + try: + plan = client.get_plan(handle) + except client.FrisbiiError: + logger.warning("Impossible de lire le plan Frisbii %s", handle) + return cached[1] if cached else None + days = _parse_trial_interval(plan.get("trial_interval")) + _trial_cache[handle] = (now, days) + return days diff --git a/tests/subscriptions/test_plans.py b/tests/subscriptions/test_plans.py new file mode 100644 index 0000000..91b4d5d --- /dev/null +++ b/tests/subscriptions/test_plans.py @@ -0,0 +1,41 @@ +import pytest + +from src.subscriptions import client, plans + + +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + plans._trial_cache.clear() + + +def test_resolve_handle(monkeypatch): + assert plans.resolve_handle("simple") == "plan_simple" + assert plans.resolve_handle("inconnu") is None + + +def test_trial_days_parses_and_caches(monkeypatch): + calls = [] + + def fake_get_plan(handle): + calls.append(handle) + return {"trial_interval": "2d"} + + monkeypatch.setattr(client, "get_plan", fake_get_plan) + assert plans.trial_days("simple") == 2 + assert plans.trial_days("simple") == 2 # depuis le cache + assert calls == ["plan_simple"] # un seul appel API + + +def test_trial_days_none_on_error(monkeypatch): + def fake_get_plan(handle): + raise client.FrisbiiError(500, "boom") + + monkeypatch.setattr(client, "get_plan", fake_get_plan) + assert plans.trial_days("simple") is None + + +def test_trial_days_none_when_no_trial(monkeypatch): + monkeypatch.setattr(client, "get_plan", lambda h: {"trial_interval": None}) + assert plans.trial_days("simple") is None From 4b1d8bf5c387ba5fe6d219cc847a632c62d17520 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:56:26 +0200 Subject: [PATCH 10/14] feat(abonnement): signature + mapping des webhooks Frisbii (#90) --- src/subscriptions/webhooks.py | 48 ++++++++++++++++++++ tests/subscriptions/test_webhooks.py | 67 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/subscriptions/webhooks.py create mode 100644 tests/subscriptions/test_webhooks.py diff --git a/src/subscriptions/webhooks.py b/src/subscriptions/webhooks.py new file mode 100644 index 0000000..2a5c1ff --- /dev/null +++ b/src/subscriptions/webhooks.py @@ -0,0 +1,48 @@ +import hashlib +import hmac +from datetime import datetime, timezone + + +def verify_signature(payload: dict, secret: str) -> bool: + # Schéma Reepay/Frisbii : HMAC-SHA256(secret, timestamp + id), hex. + # À confirmer dans la doc webhooks Frisbii lors de l'intégration. + if not secret: + return False + timestamp = payload.get("timestamp", "") + event_id = payload.get("id", "") + received = payload.get("signature", "") + expected = hmac.new( + secret.encode(), (timestamp + event_id).encode(), hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, received) + + +def _in_future(value) -> bool: + if not value: + return False + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) > datetime.now( + timezone.utc + ) + except ValueError: + return False + + +def map_subscription(sub: dict) -> tuple[str, str | None]: + """Mappe un objet subscription Frisbii vers (status, current_period_end). + + Noms de champs Reepay (à confirmer) : state, trial_end, expires, + is_cancelled, next_period_start. + """ + state = sub.get("state") + if state == "expired": + return "expired", sub.get("expires") + if sub.get("is_cancelled") or state == "cancelled": + return "cancelled", sub.get("expires") + if _in_future(sub.get("trial_end")): + return "trial", sub.get("trial_end") + if state == "active": + return "active", sub.get("next_period_start") + if state == "pending": + return "pending", None + return (state or "pending"), sub.get("next_period_start") diff --git a/tests/subscriptions/test_webhooks.py b/tests/subscriptions/test_webhooks.py new file mode 100644 index 0000000..de0e6bb --- /dev/null +++ b/tests/subscriptions/test_webhooks.py @@ -0,0 +1,67 @@ +import hashlib +import hmac +from datetime import datetime, timedelta, timezone + +from src.subscriptions import webhooks + + +def _sign(secret, timestamp, event_id): + return hmac.new( + secret.encode(), (timestamp + event_id).encode(), hashlib.sha256 + ).hexdigest() + + +def test_verify_signature_accepts_valid(): + payload = {"id": "evt_1", "timestamp": "2026-06-25T10:00:00Z"} + payload["signature"] = _sign("s3cr3t", payload["timestamp"], payload["id"]) + assert webhooks.verify_signature(payload, "s3cr3t") is True + + +def test_verify_signature_rejects_tampered(): + payload = { + "id": "evt_1", + "timestamp": "2026-06-25T10:00:00Z", + "signature": "deadbeef", + } + assert webhooks.verify_signature(payload, "s3cr3t") is False + + +def test_verify_signature_rejects_without_secret(): + payload = {"id": "evt_1", "timestamp": "t", "signature": "x"} + assert webhooks.verify_signature(payload, "") is False + + +def _future(): + return (datetime.now(timezone.utc) + timedelta(days=2)).isoformat() + + +def test_map_trial(): + status, end = webhooks.map_subscription( + {"state": "active", "trial_end": _future(), "next_period_start": _future()} + ) + assert status == "trial" + + +def test_map_active(): + nxt = _future() + status, end = webhooks.map_subscription( + {"state": "active", "next_period_start": nxt} + ) + assert status == "active" + assert end == nxt + + +def test_map_cancelled(): + exp = _future() + status, end = webhooks.map_subscription( + {"state": "active", "is_cancelled": True, "expires": exp} + ) + assert status == "cancelled" + assert end == exp + + +def test_map_expired(): + status, end = webhooks.map_subscription( + {"state": "expired", "expires": "2020-01-01T00:00:00Z"} + ) + assert status == "expired" From 3d1cb69463f02672330e75408f1ba11b4ea1c899 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 19:00:53 +0200 Subject: [PATCH 11/14] =?UTF-8?q?feat(abonnement):=20routes=20subscribe/ca?= =?UTF-8?q?ncel/webhook=20+=20c=C3=A2blage=20app=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .template.env | 7 ++ src/app.py | 4 + src/subscriptions/routes.py | 78 ++++++++++++++++++ src/subscriptions/setup.py | 35 ++++++++ tests/subscriptions/conftest.py | 33 ++++++++ tests/subscriptions/test_routes.py | 126 +++++++++++++++++++++++++++++ 6 files changed, 283 insertions(+) create mode 100644 src/subscriptions/routes.py create mode 100644 src/subscriptions/setup.py create mode 100644 tests/subscriptions/test_routes.py diff --git a/.template.env b/.template.env index 31b147b..1c6a333 100644 --- a/.template.env +++ b/.template.env @@ -56,3 +56,10 @@ S3_SECRET_ACCESS_KEY= # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables. BACKUP_ENCRYPTION_KEY= + +# 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 (déf. https://api.reepay.com, à confirmer) +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 diff --git a/src/app.py b/src/app.py index a2b5f72..b3bc72f 100644 --- a/src/app.py +++ b/src/app.py @@ -90,6 +90,10 @@ from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready init_api(app.server) +from src.subscriptions.setup import init_subscriptions # noqa: E402 + +init_subscriptions(app.server) + # robots.txt @app.server.route("/robots.txt") diff --git a/src/subscriptions/routes.py b/src/subscriptions/routes.py new file mode 100644 index 0000000..df66a0b --- /dev/null +++ b/src/subscriptions/routes.py @@ -0,0 +1,78 @@ +import os + +from flask import Blueprint, redirect, request +from flask_login import current_user, login_required + +from src.subscriptions import client, db, plans, webhooks +from src.utils import logger + +subscriptions_bp = Blueprint("subscriptions", __name__) + + +def _customer_handle(user_id: int) -> str: + return f"decpinfo-{user_id}" + + +@subscriptions_bp.route("/subscriptions/subscribe", methods=["POST"]) +@login_required +def subscribe(): + plan_key = request.form.get("plan") or "" + handle = plans.resolve_handle(plan_key) + if handle is None: + return "Plan inconnu", 400 + + base = os.getenv("APP_BASE_URL", "") + cust = _customer_handle(current_user.id) + try: + client.get_or_create_customer(cust, current_user.email) + db.create_pending(current_user.id, cust, plan_key) + # Anti-abus : pas de nouvel essai si l'utilisateur en a déjà consommé un. + no_trial = db.has_used_trial(current_user.id) + url = client.create_subscription_session( + handle, + cust, + f"{base}/compte/abonnement?paiement=succes", + f"{base}/compte/abonnement?paiement=annule", + no_trial=no_trial, + ) + except client.FrisbiiError: + logger.exception("Échec de création de session d'abonnement Frisbii") + return redirect("/compte/abonnement?error=frisbii") + return redirect(url, code=303) + + +@subscriptions_bp.route("/subscriptions/cancel", methods=["POST"]) +@login_required +def cancel(): + row = db.get_by_user(current_user.id) + if row is None or not row["frisbii_subscription_handle"]: + return "Aucun abonnement à résilier", 400 + try: + sub = client.cancel_subscription(row["frisbii_subscription_handle"]) + except client.FrisbiiError: + logger.exception("Échec de résiliation Frisbii") + return redirect("/compte/abonnement?error=frisbii") + db.set_cancelled(current_user.id, sub.get("expires")) + return redirect("/compte/abonnement?resiliation=ok") + + +@subscriptions_bp.route("/frisbii/webhook", methods=["POST"]) +def webhook(): + payload = request.get_json(silent=True) or {} + if not webhooks.verify_signature(payload, os.getenv("FRISBII_WEBHOOK_SECRET", "")): + return "", 403 + + customer = payload.get("customer") + sub_handle = payload.get("subscription") + if not customer or db.get_by_customer(customer) is None: + return "", 200 # rien à rapprocher + try: + sub = client.get_subscription(sub_handle) if sub_handle else None + except client.FrisbiiError: + logger.exception("Webhook : lecture de l'abonnement Frisbii impossible") + return "", 502 # Frisbii réessaiera + if sub is None: + return "", 200 + status, current_period_end = webhooks.map_subscription(sub) + db.update_from_webhook(customer, sub_handle, status, current_period_end) + return "", 200 diff --git a/src/subscriptions/setup.py b/src/subscriptions/setup.py new file mode 100644 index 0000000..2f77e22 --- /dev/null +++ b/src/subscriptions/setup.py @@ -0,0 +1,35 @@ +import os + +from flask import Flask + +from src.subscriptions import db +from src.utils import logger + +_REQUIRED_ENV = ( + "FRISBII_API_KEY", + "FRISBII_WEBHOOK_SECRET", + "FRISBII_PLAN_SIMPLE", + "FRISBII_PLAN_SOUTIEN", +) + + +def init_subscriptions(app: Flask) -> None: + db.init_schema() + + from src.subscriptions.routes import subscriptions_bp, webhook + + app.register_blueprint(subscriptions_bp) + + # Le webhook reçoit des POST externes : pas de jeton CSRF possible. + from src.auth.setup import _csrf as _auth_csrf + + if _auth_csrf is not None: + _auth_csrf.exempt(webhook) # exempte la seule vue webhook + + missing = [name for name in _REQUIRED_ENV if not os.getenv(name)] + if missing: + logger.warning( + "Variables Frisbii manquantes (%s) : les abonnements échoueront. " + "Définissez-les dans .env (voir .template.env).", + ", ".join(missing), + ) diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py index 104c6d8..8b7ed2f 100644 --- a/tests/subscriptions/conftest.py +++ b/tests/subscriptions/conftest.py @@ -45,3 +45,36 @@ def fake_httpx(monkeypatch): monkeypatch.setenv("FRISBII_API_BASE_URL", "https://api.test") monkeypatch.setattr(client.httpx, "request", _fake_request) return {"calls": calls, "queue": queue, "Response": FakeResponse} + + +@pytest.fixture +def sub_app(users_db_path, monkeypatch): + from flask import Flask + + from src.auth.setup import init_auth + from src.subscriptions.setup import init_subscriptions + + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t") + app = Flask(__name__) + app.config["WTF_CSRF_ENABLED"] = False + init_auth(app) + init_subscriptions(app) + return app + + +@pytest.fixture +def logged_in_client(sub_app): + from src.auth import db as auth_db + + auth_db.init_schema() + uid = auth_db.create_user("sub@ex.fr", "hash") + auth_db.set_email_verified(uid) + client = sub_app.test_client() + with client.session_transaction() as sess: + sess["_user_id"] = str(uid) + sess["_fresh"] = True + return client, uid diff --git a/tests/subscriptions/test_routes.py b/tests/subscriptions/test_routes.py new file mode 100644 index 0000000..855dac0 --- /dev/null +++ b/tests/subscriptions/test_routes.py @@ -0,0 +1,126 @@ +import hashlib +import hmac + +from src.subscriptions import client as frisbii_client +from src.subscriptions import db + + +def _sign(secret, timestamp, event_id): + return hmac.new( + secret.encode(), (timestamp + event_id).encode(), hashlib.sha256 + ).hexdigest() + + +def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): + client, uid = logged_in_client + monkeypatch.setattr( + frisbii_client, "get_or_create_customer", lambda h, e: {"handle": h} + ) + monkeypatch.setattr( + frisbii_client, + "create_subscription_session", + lambda plan, cust, ok, ko, no_trial=False: "https://pay.test/cs_1", + ) + resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) + assert resp.status_code == 303 + assert resp.headers["Location"] == "https://pay.test/cs_1" + assert db.get_by_user(uid)["status"] == "pending" + + +def test_subscribe_disables_trial_after_first_use(logged_in_client, monkeypatch): + client, uid = logged_in_client + # L'utilisateur a déjà consommé un essai par le passé. + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + db.update_from_webhook( + "decpinfo-%d" % uid, "sub_42", "trial", "2099-01-01T00:00:00+00:00" + ) + captured = {} + monkeypatch.setattr( + frisbii_client, "get_or_create_customer", lambda h, e: {"handle": h} + ) + + def fake_session(plan, cust, ok, ko, no_trial=False): + captured["no_trial"] = no_trial + return "https://pay.test/cs_9" + + monkeypatch.setattr(frisbii_client, "create_subscription_session", fake_session) + resp = client.post("/subscriptions/subscribe", data={"plan": "soutien"}) + assert resp.status_code == 303 + assert captured["no_trial"] is True + + +def test_subscribe_unknown_plan(logged_in_client): + client, _ = logged_in_client + resp = client.post("/subscriptions/subscribe", data={"plan": "bidon"}) + assert resp.status_code == 400 + + +def test_subscribe_api_error_redirects_with_error(logged_in_client, monkeypatch): + client, _ = logged_in_client + + def boom(h, e): + raise frisbii_client.FrisbiiError(500, "boom") + + monkeypatch.setattr(frisbii_client, "get_or_create_customer", boom) + resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) + assert resp.status_code == 302 + assert "error=frisbii" in resp.headers["Location"] + + +def test_subscribe_requires_login(sub_app): + resp = sub_app.test_client().post( + "/subscriptions/subscribe", data={"plan": "simple"} + ) + assert resp.status_code in (302, 401) + + +def test_cancel_calls_api_and_marks_cancelled(logged_in_client, monkeypatch): + client, uid = logged_in_client + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + db.update_from_webhook( + "decpinfo-%d" % uid, "sub_42", "active", "2099-01-01T00:00:00+00:00" + ) + monkeypatch.setattr( + frisbii_client, + "cancel_subscription", + lambda handle: {"expires": "2099-02-01T00:00:00+00:00"}, + ) + resp = client.post("/subscriptions/cancel") + assert resp.status_code == 302 + assert "resiliation=ok" in resp.headers["Location"] + row = db.get_by_user(uid) + assert row["status"] == "cancelled" + assert row["current_period_end"] == "2099-02-01T00:00:00+00:00" + + +def test_webhook_invalid_signature(sub_app): + resp = sub_app.test_client().post( + "/frisbii/webhook", json={"id": "e", "signature": "x"} + ) + assert resp.status_code == 403 + + +def test_webhook_updates_subscription(sub_app, monkeypatch): + from src.auth import db as auth_db + + auth_db.init_schema() + uid = auth_db.create_user("wh@ex.fr", "hash") + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + monkeypatch.setattr( + frisbii_client, + "get_subscription", + lambda h: {"state": "active", "next_period_start": "2099-01-01T00:00:00+00:00"}, + ) + payload = { + "id": "evt_1", + "timestamp": "2026-06-25T10:00:00Z", + "event_type": "subscription_created", + "customer": "decpinfo-%d" % uid, + "subscription": "sub_42", + } + payload["signature"] = _sign("s3cr3t", payload["timestamp"], payload["id"]) + resp = sub_app.test_client().post("/frisbii/webhook", json=payload) + assert resp.status_code == 200 + row = db.get_by_user(uid) + assert row["status"] == "active" + assert row["frisbii_subscription_handle"] == "sub_42" From d3025e323b698667cd3c4dbf4d230ce312478f01 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 19:08:22 +0200 Subject: [PATCH 12/14] =?UTF-8?q?feat(abonnement):=20page=20/compte/abonne?= =?UTF-8?q?ment=20+=20acc=C3=A8s=20premium=20r=C3=A9el=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N --- src/pages/_compte_shell.py | 7 +- src/pages/compte_abonnement.py | 166 +++++++++++++++++- tests/subscriptions/conftest.py | 6 + tests/subscriptions/test_compte_abonnement.py | 43 +++++ 4 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 tests/subscriptions/test_compte_abonnement.py diff --git a/src/pages/_compte_shell.py b/src/pages/_compte_shell.py index 7f4149b..984949e 100644 --- a/src/pages/_compte_shell.py +++ b/src/pages/_compte_shell.py @@ -39,8 +39,11 @@ SECTIONS = [ def current_user_has_subscription() -> bool: - """Stub : à brancher sur la facturation (issue #73).""" - return False + from src.subscriptions import db + + if not current_user.is_authenticated: + return False + return db.has_active_subscription(current_user.id) def visible_sections(has_subscription: bool) -> list[dict]: diff --git a/src/pages/compte_abonnement.py b/src/pages/compte_abonnement.py index bb5637e..de047c1 100644 --- a/src/pages/compte_abonnement.py +++ b/src/pages/compte_abonnement.py @@ -1,6 +1,9 @@ -from dash import html, register_page +import dash_bootstrap_components as dbc +from dash import dcc, html, register_page +from flask_login import current_user from src.pages._compte_shell import account_guard, account_shell +from src.subscriptions import db, plans register_page( __name__, @@ -10,16 +13,163 @@ register_page( description="Gestion de votre abonnement decp.info.", ) +_PLAN_KEYS = ("simple", "soutien") -def layout(**_): + +def _csrf_input(index: str): + return dcc.Input( + type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token" + ) + + +def _plan_card(meta: dict, trial: int | None, trial_used: bool): + if trial_used: + badge = html.Div( + "Sans période d'essai (déjà utilisée) — débit immédiat", + className="text-muted mb-2", + ) + elif trial: + badge = html.Div( + f"{trial} jours d'essai gratuit", className="text-success mb-2" + ) + else: + badge = None + return dbc.Card( + dbc.CardBody( + [ + html.H4(meta["label"]), + html.P(f"{meta['prix_ht']} € HT / mois"), + html.P(meta["description"]), + badge, + html.Form( + method="POST", + action="/subscriptions/subscribe", + children=[ + _csrf_input(f"subscribe-{meta['key']}"), + dcc.Input(type="hidden", name="plan", value=meta["key"]), + html.Button( + "S'abonner", type="submit", className="btn btn-primary" + ), + ], + ), + ] + ), + className="mb-3", + ) + + +def _plan_cards(trial_used=False, trial_for=plans.trial_days): + cards = [] + for key in _PLAN_KEYS: + meta = plans.plan_meta(key) + if meta: + cards.append(_plan_card(meta, trial_for(key), trial_used)) + return dbc.Row([dbc.Col(c, md=6) for c in cards]) + + +def _explainer(): + return html.Div( + [ + html.H4("À quoi servent les abonnements"), + html.Ul( + [ + html.Li("Abonnement Frisbii (solution de paiement) : 50 €"), + html.Li("Serveur Scaleway : 40 €"), + html.Li("Espace de coworking : 250 €"), + html.Li("Salaire médian : 3 840 €"), + ] + ), + html.H4("Ce que les abonnements de soutien permettraient"), + html.Ul( + [ + html.Li( + "Rédaction d'études à partir des données, par exemple sur les " + "acheteurs dont les données sont introuvables et les raisons de " + "cette non-publication." + ), + html.Li( + "Coordination des bonnes volontés souhaitant militer pour une " + "législation plus exigeante sur la transparence de la commande " + "publique." + ), + ] + ), + ] + ) + + +def _active_view(row): + meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]} + end = row["current_period_end"] + blocks = [html.H3(meta["label"]), html.P(f"Statut : {row['status']}")] + if row["status"] == "trial": + blocks.append( + dbc.Alert( + f"Essai gratuit jusqu'au {end}, puis débit automatique.", color="info" + ) + ) + elif row["status"] == "cancelled": + blocks.append( + dbc.Alert(f"Abonnement résilié, actif jusqu'au {end}.", color="warning") + ) + else: + blocks.append(html.P(f"Prochain renouvellement : {end}")) + if row["status"] in ("trial", "active"): + blocks.append( + html.Form( + method="POST", + action="/subscriptions/cancel", + children=[ + _csrf_input("cancel"), + html.Button( + "Résilier", type="submit", className="btn btn-outline-danger" + ), + ], + ) + ) + return html.Div(blocks) + + +def _feedback(query): + msgs = { + "succes": ("Merci, votre abonnement est en cours d'activation.", "success"), + "annule": ("Paiement annulé.", "secondary"), + } + out = [] + if query.get("paiement") in msgs: + text, color = msgs[query["paiement"]] + out.append(dbc.Alert(text, color=color)) + if query.get("resiliation") == "ok": + out.append(dbc.Alert("Votre abonnement a été résilié.", color="info")) + if query.get("error") == "frisbii": + out.append( + dbc.Alert( + "Une erreur est survenue avec le service de paiement.", color="danger" + ) + ) + return out + + +def layout(**query): guard = account_guard("/compte/abonnement", require_subscription=False) if guard is not None: return guard - contenu = html.Div( - [ - html.H2("Abonnement"), - html.P("La gestion de l'abonnement arrive bientôt."), - ] + row = db.get_by_user(current_user.id) if current_user.is_authenticated else None + has_access = ( + db.has_active_subscription(current_user.id) + if current_user.is_authenticated + else False ) - return account_shell("abonnement", contenu) + + trial_used = ( + db.has_used_trial(current_user.id) if current_user.is_authenticated else False + ) + + body = [html.H2("Abonnement"), *_feedback(query)] + if has_access and row is not None: + body.append(_active_view(row)) + else: + body.extend([_plan_cards(trial_used=trial_used), html.Hr(), _explainer()]) + + return account_shell("abonnement", html.Div(body)) diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py index 8b7ed2f..86f0b4e 100644 --- a/tests/subscriptions/conftest.py +++ b/tests/subscriptions/conftest.py @@ -2,6 +2,12 @@ import json as _json import pytest +# Initialise un Dash minimal pour que register_page() fonctionne dans les tests +# unitaires de pages (pas besoin du serveur complet — juste que CONFIG soit peuplé). +from dash import Dash as _Dash + +_Dash(__name__, assets_folder="assets") + class FakeResponse: def __init__(self, status_code: int, payload=None): diff --git a/tests/subscriptions/test_compte_abonnement.py b/tests/subscriptions/test_compte_abonnement.py new file mode 100644 index 0000000..93f191b --- /dev/null +++ b/tests/subscriptions/test_compte_abonnement.py @@ -0,0 +1,43 @@ +def test_plan_cards_present_when_no_subscription(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + from src.pages import compte_abonnement + + cards = compte_abonnement._plan_cards(trial_for=lambda key: 2) + text = str(cards) + assert "Abonnement simple" in text + assert "Abonnement de soutien" in text + assert "2 jours d'essai gratuit" in text + + +def test_plan_cards_no_trial_when_trial_used(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + from src.pages import compte_abonnement + + text = str(compte_abonnement._plan_cards(trial_used=True, trial_for=lambda key: 2)) + assert "Sans période d'essai" in text + assert "2 jours d'essai gratuit" not in text + + +def test_active_view_shows_cancel(monkeypatch): + from src.pages import compte_abonnement + + row = { + "plan": "simple", + "status": "active", + "current_period_end": "2099-01-01T00:00:00+00:00", + } + view = compte_abonnement._active_view(row) + assert "Résilier" in str(view) + + +def test_active_view_trial_banner(monkeypatch): + from src.pages import compte_abonnement + + row = { + "plan": "simple", + "status": "trial", + "current_period_end": "2099-01-01T00:00:00+00:00", + } + assert "Essai gratuit" in str(compte_abonnement._active_view(row)) From 5f500a1aa8b70e089cbb7855da0fb414d1c8a86a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 19:26:15 +0200 Subject: [PATCH 13/14] =?UTF-8?q?fix(abonnement):=20garde=20active?= =?UTF-8?q?=E2=86=92pending,=20datetime=20parse,=20resolve=5Fhandle=20vide?= =?UTF-8?q?=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N --- src/subscriptions/db.py | 8 +++++- src/subscriptions/plans.py | 8 ++++-- src/subscriptions/routes.py | 3 +++ tests/subscriptions/test_db.py | 28 +++++++++++++++++++++ tests/subscriptions/test_plans.py | 14 +++++++++++ tests/subscriptions/test_routes.py | 39 +++++++++++++++++++++++++++++- 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/subscriptions/db.py b/src/subscriptions/db.py index d7611a6..9935eba 100644 --- a/src/subscriptions/db.py +++ b/src/subscriptions/db.py @@ -102,7 +102,13 @@ def has_active_subscription(user_id: int) -> bool: if row["status"] in _ACCESS_STATUSES: return True if row["status"] == "cancelled" and row["current_period_end"]: - return row["current_period_end"] > _now() + try: + end = datetime.fromisoformat( + row["current_period_end"].replace("Z", "+00:00") + ) + return end > datetime.now(timezone.utc) + except ValueError: + return False return False diff --git a/src/subscriptions/plans.py b/src/subscriptions/plans.py index 3e5a9c0..b0a13c1 100644 --- a/src/subscriptions/plans.py +++ b/src/subscriptions/plans.py @@ -30,16 +30,20 @@ PLANS = { def resolve_handle(key: str) -> str | None: meta = PLANS.get(key) - return _handle(meta["env"]) if meta else None + if meta is None: + return None + h = _handle(meta["env"]) + return h if h else None def plan_meta(key: str) -> dict | None: meta = PLANS.get(key) if meta is None: return None + h = _handle(meta["env"]) return { "key": key, - "handle": _handle(meta["env"]), + "handle": h if h else None, "label": meta["label"], "prix_ht": meta["prix_ht"], "description": meta["description"], diff --git a/src/subscriptions/routes.py b/src/subscriptions/routes.py index df66a0b..3ba142e 100644 --- a/src/subscriptions/routes.py +++ b/src/subscriptions/routes.py @@ -22,6 +22,9 @@ def subscribe(): return "Plan inconnu", 400 base = os.getenv("APP_BASE_URL", "") + if db.has_active_subscription(current_user.id): + return redirect(f"{base}/compte/abonnement") + cust = _customer_handle(current_user.id) try: client.get_or_create_customer(cust, current_user.email) diff --git a/tests/subscriptions/test_db.py b/tests/subscriptions/test_db.py index e4e9a82..3f9aa61 100644 --- a/tests/subscriptions/test_db.py +++ b/tests/subscriptions/test_db.py @@ -78,6 +78,34 @@ def test_set_cancelled(users_db_path): assert row["current_period_end"] == end +def test_has_active_subscription_z_suffix_datetime(users_db_path): + """Fix 2 : suffix 'Z' dans current_period_end doit être parsé correctement.""" + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + # Frisbii peut renvoyer des dates avec 'Z' au lieu de '+00:00'. + db.update_from_webhook("decpinfo-1", "sub_42", "cancelled", "2099-12-31T23:59:59Z") + assert db.has_active_subscription(uid) is True + db.update_from_webhook("decpinfo-1", "sub_42", "cancelled", "2020-01-01T00:00:00Z") + assert db.has_active_subscription(uid) is False + + +def test_has_active_subscription_bad_datetime_returns_false(users_db_path): + """Fix 2 : une date invalide ne doit pas lever d'exception, juste retourner False.""" + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + # Injection directe d'une valeur invalide via update bas-niveau. + from src.auth.db import get_conn + + get_conn().execute( + "UPDATE subscriptions SET status='cancelled', current_period_end='not-a-date' " + "WHERE user_id=?", + (uid,), + ) + assert db.has_active_subscription(uid) is False + + def test_trial_used_is_sticky_across_resubscribe(users_db_path): db.init_schema() uid = _make_user() diff --git a/tests/subscriptions/test_plans.py b/tests/subscriptions/test_plans.py index 91b4d5d..031ed89 100644 --- a/tests/subscriptions/test_plans.py +++ b/tests/subscriptions/test_plans.py @@ -39,3 +39,17 @@ def test_trial_days_none_on_error(monkeypatch): def test_trial_days_none_when_no_trial(monkeypatch): monkeypatch.setattr(client, "get_plan", lambda h: {"trial_interval": None}) assert plans.trial_days("simple") is None + + +def test_resolve_handle_unset_env_returns_none(monkeypatch): + """Fix 3 : une variable d'env vide doit donner None, pas une chaîne vide.""" + monkeypatch.delenv("FRISBII_PLAN_SIMPLE", raising=False) + assert plans.resolve_handle("simple") is None + + +def test_plan_meta_handle_none_when_unset(monkeypatch): + """Fix 3 : plan_meta doit exposer handle=None quand l'env var est absente.""" + monkeypatch.delenv("FRISBII_PLAN_SIMPLE", raising=False) + meta = plans.plan_meta("simple") + assert meta is not None + assert meta["handle"] is None diff --git a/tests/subscriptions/test_routes.py b/tests/subscriptions/test_routes.py index 855dac0..f2ba6a8 100644 --- a/tests/subscriptions/test_routes.py +++ b/tests/subscriptions/test_routes.py @@ -29,11 +29,15 @@ def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): def test_subscribe_disables_trial_after_first_use(logged_in_client, monkeypatch): client, uid = logged_in_client - # L'utilisateur a déjà consommé un essai par le passé. + # L'utilisateur a déjà consommé un essai par le passé (abonnement maintenant expiré). db.create_pending(uid, "decpinfo-%d" % uid, "simple") db.update_from_webhook( "decpinfo-%d" % uid, "sub_42", "trial", "2099-01-01T00:00:00+00:00" ) + # L'essai est terminé : abonnement expiré, trial_used reste à 1. + db.update_from_webhook( + "decpinfo-%d" % uid, "sub_42", "expired", "2020-01-01T00:00:00+00:00" + ) captured = {} monkeypatch.setattr( frisbii_client, "get_or_create_customer", lambda h, e: {"handle": h} @@ -124,3 +128,36 @@ def test_webhook_updates_subscription(sub_app, monkeypatch): row = db.get_by_user(uid) assert row["status"] == "active" assert row["frisbii_subscription_handle"] == "sub_42" + + +def test_subscribe_skips_if_already_active(logged_in_client, monkeypatch): + """Fix 1 : un abonné actif ne doit pas voir son statut remis à 'pending'.""" + client, uid = logged_in_client + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + db.update_from_webhook( + "decpinfo-%d" % uid, "sub_42", "active", "2099-01-01T00:00:00+00:00" + ) + called = [] + monkeypatch.setattr( + frisbii_client, "get_or_create_customer", lambda h, e: called.append(h) + ) + resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) + # Doit rediriger vers la page abonnement, sans appeler Frisbii. + assert resp.status_code == 302 + assert "compte/abonnement" in resp.headers["Location"] + assert called == [], "Frisbii ne doit pas être appelé pour un abonné actif" + # Le statut DB ne doit pas avoir été écrasé. + assert db.get_by_user(uid)["status"] == "active" + + +def test_subscribe_skips_if_trial_active(logged_in_client, monkeypatch): + """Fix 1 : un abonné en période d'essai est protégé de la même façon.""" + client, uid = logged_in_client + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + db.update_from_webhook( + "decpinfo-%d" % uid, "sub_42", "trial", "2099-01-01T00:00:00+00:00" + ) + resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) + assert resp.status_code == 302 + assert "compte/abonnement" in resp.headers["Location"] + assert db.get_by_user(uid)["status"] == "trial" From e932c66af07d2b6667b67c380abf02add2489f2f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 21:14:35 +0200 Subject: [PATCH 14/14] =?UTF-8?q?Corrections=20des=20bugs=20de=20la=20conn?= =?UTF-8?q?exion=20=C3=A0=20Frisbii=20#90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .template.env | 6 +- src/assets/salaire.png | Bin 0 -> 53900 bytes src/auth/routes.py | 20 +++++-- src/pages/_compte_shell.py | 12 ++-- src/pages/compte_abonnement.py | 92 ++++++++++++++++++++++------- src/subscriptions/client.py | 23 ++++---- src/subscriptions/plans.py | 7 ++- tests/subscriptions/test_client.py | 33 +++++++---- tests/users.test.sqlite | Bin 53248 -> 53248 bytes 9 files changed, 133 insertions(+), 60 deletions(-) create mode 100644 src/assets/salaire.png diff --git a/.template.env b/.template.env index 1c6a333..79624f9 100644 --- a/.template.env +++ b/.template.env @@ -59,7 +59,7 @@ BACKUP_ENCRYPTION_KEY= # 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 (déf. https://api.reepay.com, à confirmer) -FRISBII_PLAN_SIMPLE= # handle du plan "abonnement simple" (20 € HT/mois) -FRISBII_PLAN_SOUTIEN= # handle du plan "abonnement de soutien" (50 € HT/mois) +FRISBII_API_BASE_URL=https://api.frisbii.com # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer) +FRISBII_PLAN_SIMPLE=abonnement-decp.info # handle du plan "abonnement simple" (20 € HT/mois) +FRISBII_PLAN_SOUTIEN=soutien-decp.info # handle du plan "abonnement de soutien" (50 € HT/mois) FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks diff --git a/src/assets/salaire.png b/src/assets/salaire.png new file mode 100644 index 0000000000000000000000000000000000000000..6c742877896480f791a2c07cd17e956106840763 GIT binary patch literal 53900 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 str: + try: + from src.subscriptions import db as sub_db + + if sub_db.has_active_subscription(user_id): + return "/compte/admin" + except Exception: + pass + return "/compte/abonnement" + + MIN_PASSWORD_LENGTH = 8 # Hash bidon pré-calculé pour uniformiser le timing login @@ -93,7 +105,7 @@ def verify_email(): def login(): email = (request.form.get("email") or "").strip().lower() password = request.form.get("password") or "" - next_url = safe_next(request.form.get("next"), fallback="/compte/admin") + next_url = request.form.get("next") row = db.get_user_by_email(email) if row is None: @@ -109,7 +121,7 @@ def login(): return _redirect_with_error("/connexion", "email_not_verified", email) login_user(User(row), remember=True) - return redirect(next_url) + return redirect(safe_next(next_url, fallback=_post_login_url(row["id"]))) @auth_bp.route("/logout", methods=["POST"]) @@ -259,7 +271,7 @@ def linkedin_login(): @auth_bp.route("/linkedin/callback", methods=["GET"]) def linkedin_callback(): - next_url = safe_next(session.pop("oauth_next", None), fallback="/compte/admin") + oauth_next = session.pop("oauth_next", None) if request.args.get("error"): # L'utilisateur a refusé / annulé l'autorisation côté LinkedIn. return _redirect_with_error("/connexion", "oauth_cancelled") @@ -290,4 +302,4 @@ def linkedin_callback(): "linkedin", subject, email, bool(userinfo.get("email_verified")) ) login_user(user, remember=True) - return redirect(next_url) + return redirect(safe_next(oauth_next, fallback=_post_login_url(user.id))) diff --git a/src/pages/_compte_shell.py b/src/pages/_compte_shell.py index 984949e..dec2cf9 100644 --- a/src/pages/_compte_shell.py +++ b/src/pages/_compte_shell.py @@ -5,18 +5,18 @@ from flask_login import current_user # Définition centralisée des sections de l'espace compte. # Ajouter une section future = ajouter une ligne ici (+ créer sa page). SECTIONS = [ - { - "key": "admin", - "label": "Compte", - "href": "/compte/admin", - "require_subscription": False, - }, { "key": "abonnement", "label": "Abonnement", "href": "/compte/abonnement", "require_subscription": False, }, + { + "key": "admin", + "label": "Compte", + "href": "/compte/admin", + "require_subscription": False, + }, { "key": "archives", "label": "Mes archives", diff --git a/src/pages/compte_abonnement.py b/src/pages/compte_abonnement.py index de047c1..a15bcc0 100644 --- a/src/pages/compte_abonnement.py +++ b/src/pages/compte_abonnement.py @@ -1,5 +1,5 @@ import dash_bootstrap_components as dbc -from dash import dcc, html, register_page +from dash import Input, Output, callback, dcc, html, register_page from flask_login import current_user from src.pages._compte_shell import account_guard, account_shell @@ -16,10 +16,10 @@ register_page( _PLAN_KEYS = ("simple", "soutien") -def _csrf_input(index: str): - return dcc.Input( - type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token" - ) +def _csrf_input(): + from flask_wtf.csrf import generate_csrf + + return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf()) def _plan_card(meta: dict, trial: int | None, trial_used: bool): @@ -37,24 +37,25 @@ def _plan_card(meta: dict, trial: int | None, trial_used: bool): return dbc.Card( dbc.CardBody( [ - html.H4(meta["label"]), - html.P(f"{meta['prix_ht']} € HT / mois"), - html.P(meta["description"]), + html.H4(meta["label"], className="mb-1"), + html.P(f"{meta['prix_ht']} € HT / mois", className="text-muted mb-3"), + html.P(meta["description"], className="mb-3"), badge, html.Form( method="POST", action="/subscriptions/subscribe", children=[ - _csrf_input(f"subscribe-{meta['key']}"), + _csrf_input(), dcc.Input(type="hidden", name="plan", value=meta["key"]), html.Button( "S'abonner", type="submit", className="btn btn-primary" ), ], ), - ] + ], + className="p-4", ), - className="mb-3", + className="h-100", ) @@ -64,22 +65,45 @@ def _plan_cards(trial_used=False, trial_for=plans.trial_days): meta = plans.plan_meta(key) if meta: cards.append(_plan_card(meta, trial_for(key), trial_used)) - return dbc.Row([dbc.Col(c, md=6) for c in cards]) + return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-5") def _explainer(): - return html.Div( + col_left = dbc.Col( [ - html.H4("À quoi servent les abonnements"), + html.H4("Ce que les abonnements financent"), html.Ul( [ html.Li("Abonnement Frisbii (solution de paiement) : 50 €"), html.Li("Serveur Scaleway : 40 €"), html.Li("Espace de coworking : 250 €"), - html.Li("Salaire médian : 3 840 €"), + html.Li( + [ + "Salaire médian (", + html.Span( + "coût employeur", + id="salaire-modal-trigger", + style={ + "cursor": "pointer", + "textDecoration": "underline", + "color": "var(--bs-link-color)", + }, + ), + ") : 3 840 €", + ] + ), ] ), - html.H4("Ce que les abonnements de soutien permettraient"), + ], + md=6, + style={ + "borderRight": "1px solid var(--bs-border-color)", + "paddingRight": "2rem", + }, + ) + col_right = dbc.Col( + [ + html.H4("Ce que les abonnements de soutien permettent"), html.Ul( [ html.Li( @@ -88,14 +112,17 @@ def _explainer(): "cette non-publication." ), html.Li( - "Coordination des bonnes volontés souhaitant militer pour une " - "législation plus exigeante sur la transparence de la commande " + "Fédération des bonnes volontés souhaitant militer pour une " + "législation plus ambitieuse sur la transparence de la commande " "publique." ), ] ), - ] + ], + md=6, + style={"paddingLeft": "2rem"}, ) + return dbc.Row([col_left, col_right], className="align-items-start pt-2") def _active_view(row): @@ -120,7 +147,7 @@ def _active_view(row): method="POST", action="/subscriptions/cancel", children=[ - _csrf_input("cancel"), + _csrf_input(), html.Button( "Résilier", type="submit", className="btn btn-outline-danger" ), @@ -150,6 +177,26 @@ def _feedback(query): return out +_salaire_modal = dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Salaire médian — coût employeur")), + dbc.ModalBody(html.Img(src="/assets/salaire.png", style={"width": "100%"})), + ], + id="salaire-modal", + size="lg", + is_open=False, +) + + +@callback( + Output("salaire-modal", "is_open"), + Input("salaire-modal-trigger", "n_clicks"), + prevent_initial_call=True, +) +def _open_salaire_modal(_): + return True + + def layout(**query): guard = account_guard("/compte/abonnement", require_subscription=False) if guard is not None: @@ -166,10 +213,11 @@ def layout(**query): db.has_used_trial(current_user.id) if current_user.is_authenticated else False ) - body = [html.H2("Abonnement"), *_feedback(query)] + body = [html.H2("Abonnement", className="mb-4"), *_feedback(query)] if has_access and row is not None: body.append(_active_view(row)) else: - body.extend([_plan_cards(trial_used=trial_used), html.Hr(), _explainer()]) + body.extend([_plan_cards(trial_used=trial_used), _explainer()]) + body.append(_salaire_modal) return account_shell("abonnement", html.Div(body)) diff --git a/src/subscriptions/client.py b/src/subscriptions/client.py index 4ce2ad2..6e2b8a0 100644 --- a/src/subscriptions/client.py +++ b/src/subscriptions/client.py @@ -5,7 +5,7 @@ import httpx # NB : base URL et schémas d'endpoints à confirmer dans la doc Frisbii lors de la # première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur # l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin. -_DEFAULT_BASE_URL = "https://api.reepay.com" +_DEFAULT_BASE_URL = "https://api.frisbii.com" _TIMEOUT = 15.0 @@ -53,19 +53,16 @@ def create_subscription_session( cancel_url: str, no_trial: bool = False, ) -> str: - prepare = {"plan": plan_handle, "customer": customer_handle} + body: dict = { + "plan": plan_handle, + "customer": customer_handle, + "signup_method": "link", + "generate_handle": True, + } if no_trial: - prepare["no_trial"] = True - data = _call( - "POST", - "/v1/session/subscription", - json={ - "accept_url": accept_url, - "cancel_url": cancel_url, - "prepare_subscription": prepare, - }, - ) - return data["url"] + body["no_trial"] = True + data = _call("POST", "/v1/subscription", json=body) + return data["hosted_page_links"]["payment_info"] def cancel_subscription(subscription_handle: str) -> dict: diff --git a/src/subscriptions/plans.py b/src/subscriptions/plans.py index b0a13c1..7fc3bac 100644 --- a/src/subscriptions/plans.py +++ b/src/subscriptions/plans.py @@ -17,11 +17,11 @@ PLANS = { "env": "FRISBII_PLAN_SIMPLE", "label": "Abonnement simple", "prix_ht": 20, - "description": "Accès aux fonctionnalités premium de decp.info.", + "description": "Accès à des fonctionnalités supplémentaires de decp.info.", }, "soutien": { "env": "FRISBII_PLAN_SOUTIEN", - "label": "Abonnement de soutien", + "label": "Abonnement de soutien ✊", "prix_ht": 50, "description": "Mêmes fonctionnalités, contribution renforcée au projet.", }, @@ -74,6 +74,9 @@ def trial_days(key: str) -> int | None: except client.FrisbiiError: logger.warning("Impossible de lire le plan Frisbii %s", handle) return cached[1] if cached else None + # L'API Frisbii/Reepay renvoie une liste de versions ; on prend la dernière (la plus récente). + if isinstance(plan, list): + plan = plan[-1] if plan else {} days = _parse_trial_interval(plan.get("trial_interval")) _trial_cache[handle] = (now, days) return days diff --git a/tests/subscriptions/test_client.py b/tests/subscriptions/test_client.py index 18e52c2..011e727 100644 --- a/tests/subscriptions/test_client.py +++ b/tests/subscriptions/test_client.py @@ -29,29 +29,42 @@ def test_get_or_create_customer_creates_on_404(fake_httpx): def test_create_subscription_session_returns_url(fake_httpx): fake_httpx["queue"].append( - fake_httpx["Response"](200, {"id": "cs_1", "url": "https://pay.test/cs_1"}) + fake_httpx["Response"]( + 200, + { + "hosted_page_links": { + "payment_info": "https://checkout.reepay.com/#/sub-1" + } + }, + ) ) url = client.create_subscription_session( "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko" ) - assert url == "https://pay.test/cs_1" + assert url == "https://checkout.reepay.com/#/sub-1" body = fake_httpx["calls"][0]["json"] - assert body["prepare_subscription"] == { - "plan": "plan_simple", - "customer": "decpinfo-1", - } - assert body["accept_url"] == "https://app/ok" - assert body["cancel_url"] == "https://app/ko" + assert body["plan"] == "plan_simple" + assert body["customer"] == "decpinfo-1" + assert body["signup_method"] == "link" + assert "prepare_subscription" not in body + assert "accept_url" not in body def test_create_subscription_session_no_trial(fake_httpx): fake_httpx["queue"].append( - fake_httpx["Response"](200, {"url": "https://pay.test/cs_2"}) + fake_httpx["Response"]( + 200, + { + "hosted_page_links": { + "payment_info": "https://checkout.reepay.com/#/sub-2" + } + }, + ) ) client.create_subscription_session( "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True ) - assert fake_httpx["calls"][0]["json"]["prepare_subscription"]["no_trial"] is True + assert fake_httpx["calls"][0]["json"]["no_trial"] is True def test_http_error_raises_frisbii_error(fake_httpx): diff --git a/tests/users.test.sqlite b/tests/users.test.sqlite index 16de7617d850c7ffc948e96a5acfc449d610c873..6370d61770f88526c3495620a8a53670c2ed1d3f 100644 GIT binary patch delta 676 zcmZozz}&Ead4jYcD+2=qFA&23&qN(#RaOSQ-e0_2Y79KA1q^&;Jj-~`aoh7mbNu0N zVQ=FW;o8qyz-7E~;v1Iw7GXYiadmaZ_SBNZq@2{^(xl?#qRfJl%>2A!6b`de zt3rsQlaH$cij;x|mjV!!7N-`)XQqId7{Jpn#MRw3NFgA|)7LR5Qo-9bQU|1|ULmb0 zvp6X;Gd{VrxFkOpXhKF}UP?|X*n|+*h!C(cxGH3OVJZ=-3UU(jFsz0OAXF8XB$kvG zLxiKzl_QiTmlhSJ=9R=3q!wl7r^Kh`A^8lUs-!40F()4AbtGA+nUkYA6upv*QWHy3 zQ{od#&}~vs0GXoT=MTi8K0XkCmKLPoQa-tcH)wMk`(mbgM07WD^0SLOsxvm4XXd4( zR%E7B#3P4LF+5aJL|LIx>FMX<8liw&6F64={ZJ!YBTXE8jA?G(&*dn(C_rG5fxx1K If<*xe0FFAo5&!@I delta 137 zcmZozz}&Ead4jYc3j+fKFA%c=F%uATP1G?~Wns|k{l&|r#=yni#=uv`vyAr~w>?iZ z#~=O{_BL)2uKoO;{GOWy1(?`3x3Mo~Vw^c~;?&Lixg13|D+oN`pSVC_k%52!&@d2S Z1Y%|&W&)~z&%gP