Files
colibre/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md
T

18 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

  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) :

    • simple20 € HT / mois
    • soutien50 € 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. 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, 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 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é 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

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.pycurrent_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).
  • 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 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
    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
);
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) -> boolTrue 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
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 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.
    • 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/webhookCSRF-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_createdstatus='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_cancelledstatus='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.

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

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 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 €.
    • 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 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, 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/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 (trial et active → vrai ; cancelled futur → vrai, passé → faux ; pending et expired → faux).
  • 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éé) ; 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).