Merge branch 'feature/90_subscriptions' into dev

This commit is contained in:
Colin Maudry
2026-06-25 21:14:47 +02:00
24 changed files with 3111 additions and 20 deletions
+7
View File
@@ -56,3 +56,10 @@ S3_SECRET_ACCESS_KEY=
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables. # À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables.
BACKUP_ENCRYPTION_KEY= BACKUP_ENCRYPTION_KEY=
# Frisbii — gestion des abonnements (https://docs.frisbii.com)
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,336 @@
# 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. **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.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).
- `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) :
```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' | '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) -> bool`
`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 |
| `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/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``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'`.
- 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
```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.
`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 :
```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)
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).
+4
View File
@@ -90,6 +90,10 @@ from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready
init_api(app.server) init_api(app.server)
from src.subscriptions.setup import init_subscriptions # noqa: E402
init_subscriptions(app.server)
# robots.txt # robots.txt
@app.server.route("/robots.txt") @app.server.route("/robots.txt")
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+16 -4
View File
@@ -13,6 +13,18 @@ from src.utils import logger
auth_bp = Blueprint("auth", __name__, url_prefix="/auth") auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
def _post_login_url(user_id: int) -> 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 MIN_PASSWORD_LENGTH = 8
# Hash bidon pré-calculé pour uniformiser le timing login # Hash bidon pré-calculé pour uniformiser le timing login
@@ -93,7 +105,7 @@ def verify_email():
def login(): def login():
email = (request.form.get("email") or "").strip().lower() email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or "" 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) row = db.get_user_by_email(email)
if row is None: if row is None:
@@ -109,7 +121,7 @@ def login():
return _redirect_with_error("/connexion", "email_not_verified", email) return _redirect_with_error("/connexion", "email_not_verified", email)
login_user(User(row), remember=True) 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"]) @auth_bp.route("/logout", methods=["POST"])
@@ -259,7 +271,7 @@ def linkedin_login():
@auth_bp.route("/linkedin/callback", methods=["GET"]) @auth_bp.route("/linkedin/callback", methods=["GET"])
def linkedin_callback(): 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"): if request.args.get("error"):
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn. # L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
return _redirect_with_error("/connexion", "oauth_cancelled") return _redirect_with_error("/connexion", "oauth_cancelled")
@@ -290,4 +302,4 @@ def linkedin_callback():
"linkedin", subject, email, bool(userinfo.get("email_verified")) "linkedin", subject, email, bool(userinfo.get("email_verified"))
) )
login_user(user, remember=True) login_user(user, remember=True)
return redirect(next_url) return redirect(safe_next(oauth_next, fallback=_post_login_url(user.id)))
+10 -7
View File
@@ -5,18 +5,18 @@ from flask_login import current_user
# Définition centralisée des sections de l'espace compte. # Définition centralisée des sections de l'espace compte.
# Ajouter une section future = ajouter une ligne ici (+ créer sa page). # Ajouter une section future = ajouter une ligne ici (+ créer sa page).
SECTIONS = [ SECTIONS = [
{
"key": "admin",
"label": "Compte",
"href": "/compte/admin",
"require_subscription": False,
},
{ {
"key": "abonnement", "key": "abonnement",
"label": "Abonnement", "label": "Abonnement",
"href": "/compte/abonnement", "href": "/compte/abonnement",
"require_subscription": False, "require_subscription": False,
}, },
{
"key": "admin",
"label": "Compte",
"href": "/compte/admin",
"require_subscription": False,
},
{ {
"key": "archives", "key": "archives",
"label": "Mes archives", "label": "Mes archives",
@@ -39,8 +39,11 @@ SECTIONS = [
def current_user_has_subscription() -> bool: def current_user_has_subscription() -> bool:
"""Stub : à brancher sur la facturation (issue #73).""" from src.subscriptions import db
if not current_user.is_authenticated:
return False return False
return db.has_active_subscription(current_user.id)
def visible_sections(has_subscription: bool) -> list[dict]: def visible_sections(has_subscription: bool) -> list[dict]:
+206 -8
View File
@@ -1,6 +1,9 @@
from dash import html, register_page import dash_bootstrap_components as dbc
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 from src.pages._compte_shell import account_guard, account_shell
from src.subscriptions import db, plans
register_page( register_page(
__name__, __name__,
@@ -10,16 +13,211 @@ register_page(
description="Gestion de votre abonnement decp.info.", description="Gestion de votre abonnement decp.info.",
) )
_PLAN_KEYS = ("simple", "soutien")
def layout(**_):
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):
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"], 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(),
dcc.Input(type="hidden", name="plan", value=meta["key"]),
html.Button(
"S'abonner", type="submit", className="btn btn-primary"
),
],
),
],
className="p-4",
),
className="h-100",
)
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], className="g-4 mb-5")
def _explainer():
col_left = dbc.Col(
[
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 (",
html.Span(
"coût employeur",
id="salaire-modal-trigger",
style={
"cursor": "pointer",
"textDecoration": "underline",
"color": "var(--bs-link-color)",
},
),
") : 3 840 €",
]
),
]
),
],
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(
"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(
"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):
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(),
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
_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) guard = account_guard("/compte/abonnement", require_subscription=False)
if guard is not None: if guard is not None:
return guard return guard
contenu = html.Div( row = db.get_by_user(current_user.id) if current_user.is_authenticated else None
[ has_access = (
html.H2("Abonnement"), db.has_active_subscription(current_user.id)
html.P("La gestion de l'abonnement arrive bientôt."), 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", 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), _explainer()])
body.append(_salaire_modal)
return account_shell("abonnement", html.Div(body))
View File
+77
View File
@@ -0,0 +1,77 @@
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.frisbii.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:
body: dict = {
"plan": plan_handle,
"customer": customer_handle,
"signup_method": "link",
"generate_handle": True,
}
if no_trial:
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:
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}")
+117
View File
@@ -0,0 +1,117 @@
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"]:
try:
end = datetime.fromisoformat(
row["current_period_end"].replace("Z", "+00:00")
)
return end > datetime.now(timezone.utc)
except ValueError:
return False
return False
def has_used_trial(user_id: int) -> bool:
row = get_by_user(user_id)
return bool(row and row["trial_used"])
+82
View File
@@ -0,0 +1,82 @@
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 à des fonctionnalités supplémentaires 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)
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": h if h else None,
"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
# 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
+81
View File
@@ -0,0 +1,81 @@
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", "")
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)
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
+35
View File
@@ -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),
)
+48
View File
@@ -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")
View File
+86
View File
@@ -0,0 +1,86 @@
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):
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 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."""
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}
@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
+74
View File
@@ -0,0 +1,74 @@
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,
{
"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://checkout.reepay.com/#/sub-1"
body = fake_httpx["calls"][0]["json"]
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,
{
"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"]["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
@@ -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))
+120
View File
@@ -0,0 +1,120 @@
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_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()
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
+55
View File
@@ -0,0 +1,55 @@
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
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
+163
View File
@@ -0,0 +1,163 @@
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é (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}
)
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"
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"
+67
View File
@@ -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"
Binary file not shown.