Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
16 KiB
Abonnements payants via Frisbii — design
- Issue : #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 (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
-
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. -
Deux plans fixes, définis côté Frisbii. decp.info ne référence que leurs handles (via
.env) :simple— 20 € HT / moissoutien— 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.
-
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.
-
Essai gratuit configuré côté Frisbii (2 jours souhaités). L'essai est un
trial_intervalréglé sur chaque plan dans le dashboard Frisbii (aucun code 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 detrialà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. -
Résiliation en fin de période courante.
POSTcancel 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_endrenvoyé par Frisbii ; aucun calcul de date côté app. -
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é sursubscriptions.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.
- Handle déterministe
create_subscription_session(plan_handle, customer_handle, accept_url, cancel_url) -> strPOST /v1/session/subscriptionavecprepare_subscription(plan + customer) et les URLs de retour. Renvoie l'urlhé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).get_plan(plan_handle) -> dictGET /v1/plan/{handle}. Sert à lire les caractéristiques du plan (dont la durée d'essaitrial_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
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) :
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' | 'trial' | '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 | Noneget_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→Truesi une ligne existe avecstatusdans (trial,active) ou (status='cancelled'etcurrent_period_enddans 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 |
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
subscriptions_bp (préfixe explicite par route) :
POST /subscriptions/subscribe—@login_required, CSRF protégé.- Form
plan=simple|soutien. Résout le handle viaplans.py(400 si inconnu). get_or_create_customer("decpinfo-{user_id}", email).upsert_subscription(..., status='pending').create_subscription_session(...)avecaccept_url={APP_BASE_URL}/compte/abonnement?paiement=succesetcancel_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.
- Form
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_endsi 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_SECRETselon 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→status='trial'si l'abonnement démarre en essai (champ d'essai du payload), sinonstatus='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_endmaj. invoice_settled/ renouvellement →current_period_endmaj.subscription_cancelled→status='cancelled',current_period_endmaj.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_HANDLERSfacile à étendre.
- Vérifie la signature via
setup.py — initialisation
init_subscriptions(app) :
db.init_schema().- Enregistre
subscriptions_bp. - Exempte la vue webhook de CSRF (même approche que
_auth_csrf.exemptdanssrc/app.py). - Warnings au démarrage si
FRISBII_API_KEY,FRISBII_WEBHOOK_SECRET,FRISBII_PLAN_SIMPLEouFRISBII_PLAN_SOUTIENmanquent (comme Brevo/LinkedIn).
Appelé depuis src/app.py après init_auth(...).
plans.py — catalogue
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.
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
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 ». Mention « {n} jours d'essai gratuit » sur les cartes, où{n}est lu depuis le plan viaplans.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 €.
- 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
trial: bandeau « Essai gratuit jusqu'au {date}, puis débit automatique ». - Si
cancelled: bandeau « Abonnement résilié, actif jusqu'au {date} ». - Si
trialouactive: formulairePOST /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,
resiliation=ok, error=frisbii.
current_user_has_subscription()
Dans _compte_shell.py, remplacer le stub par :
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)
# 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)
avec un essai de 2 jours (
trial_interval) et collecte de la carte à la souscription. - Configurer un webhook vers
{APP_BASE_URL}/frisbii/webhookavec 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_subscriptionpour chaque statut (trialetactive→ vrai ;cancelledfutur → vrai, passé → faux ;pendingetexpired→ faux).plans.py:resolve_handle(connu / inconnu) ;trial_days(parsing dutrial_intervalrenvoyé par unget_planmocké, mise en cache,Nonesi é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, statutpendingcréé) ; cancel (appel client + statutcancelled).
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_subscriptionexiste 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).