diff --git a/.template.env b/.template.env index 31b147b..79624f9 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=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/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..db814a4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md @@ -0,0 +1,1483 @@ +# 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, 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` + +- [ ] **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_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 +``` + +- [ ] **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, + 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}") +``` + +- [ ] **Step 5: Lancer les tests pour vérifier le succès** + +Run: `uv run pytest tests/subscriptions/test_client.py -v` +Expected: PASS (6 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` + - `has_used_trial(user_id: int) -> bool` (anti-abus : essai déjà consommé ?) + +- [ ] **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 + + +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** + +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, + 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"]) +``` + +- [ ] **Step 4: Lancer les tests pour vérifier le succès** + +Run: `uv run pytest tests/subscriptions/test_db.py -v` +Expected: PASS (6 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, 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" +``` + +- [ ] **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) + # 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 +``` + +- [ ] **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 (8 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_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)) +``` + +- [ ] **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, 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 + + 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(trial_used=trial_used), 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 (4 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. 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..4e7023d --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md @@ -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). 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/assets/salaire.png b/src/assets/salaire.png new file mode 100644 index 0000000..6c74287 Binary files /dev/null and b/src/assets/salaire.png differ diff --git a/src/auth/routes.py b/src/auth/routes.py index ea1d217..55cdb07 100644 --- a/src/auth/routes.py +++ b/src/auth/routes.py @@ -13,6 +13,18 @@ from src.utils import logger 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 # 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 7f4149b..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", @@ -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..a15bcc0 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 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.subscriptions import db, plans register_page( __name__, @@ -10,16 +13,211 @@ register_page( 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) 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", 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)) 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..6e2b8a0 --- /dev/null +++ b/src/subscriptions/client.py @@ -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}") diff --git a/src/subscriptions/db.py b/src/subscriptions/db.py new file mode 100644 index 0000000..9935eba --- /dev/null +++ b/src/subscriptions/db.py @@ -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"]) diff --git a/src/subscriptions/plans.py b/src/subscriptions/plans.py new file mode 100644 index 0000000..7fc3bac --- /dev/null +++ b/src/subscriptions/plans.py @@ -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 diff --git a/src/subscriptions/routes.py b/src/subscriptions/routes.py new file mode 100644 index 0000000..3ba142e --- /dev/null +++ b/src/subscriptions/routes.py @@ -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 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/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/__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..86f0b4e --- /dev/null +++ b/tests/subscriptions/conftest.py @@ -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 diff --git a/tests/subscriptions/test_client.py b/tests/subscriptions/test_client.py new file mode 100644 index 0000000..011e727 --- /dev/null +++ b/tests/subscriptions/test_client.py @@ -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 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)) diff --git a/tests/subscriptions/test_db.py b/tests/subscriptions/test_db.py new file mode 100644 index 0000000..3f9aa61 --- /dev/null +++ b/tests/subscriptions/test_db.py @@ -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 diff --git a/tests/subscriptions/test_plans.py b/tests/subscriptions/test_plans.py new file mode 100644 index 0000000..031ed89 --- /dev/null +++ b/tests/subscriptions/test_plans.py @@ -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 diff --git a/tests/subscriptions/test_routes.py b/tests/subscriptions/test_routes.py new file mode 100644 index 0000000..f2ba6a8 --- /dev/null +++ b/tests/subscriptions/test_routes.py @@ -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" 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" diff --git a/tests/users.test.sqlite b/tests/users.test.sqlite index 16de761..6370d61 100644 Binary files a/tests/users.test.sqlite and b/tests/users.test.sqlite differ