From 4f90184ddd1b515b1cf351fa157e8be655f69dca Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:53:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(abonnement):=20catalogue=20de=20plans=20+?= =?UTF-8?q?=20dur=C3=A9e=20d'essai=20dynamique=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/subscriptions/plans.py | 75 +++++++++++++++++++++++++++++++ tests/subscriptions/test_plans.py | 41 +++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/subscriptions/plans.py create mode 100644 tests/subscriptions/test_plans.py diff --git a/src/subscriptions/plans.py b/src/subscriptions/plans.py new file mode 100644 index 0000000..3e5a9c0 --- /dev/null +++ b/src/subscriptions/plans.py @@ -0,0 +1,75 @@ +import os +import time + +from src.subscriptions import client +from src.utils import logger + +_TTL_SECONDS = 3600 +_trial_cache: dict[str, tuple[float, int | None]] = {} + + +def _handle(env_name: str) -> str: + return os.getenv(env_name, "") + + +PLANS = { + "simple": { + "env": "FRISBII_PLAN_SIMPLE", + "label": "Abonnement simple", + "prix_ht": 20, + "description": "Accès aux fonctionnalités premium de decp.info.", + }, + "soutien": { + "env": "FRISBII_PLAN_SOUTIEN", + "label": "Abonnement de soutien", + "prix_ht": 50, + "description": "Mêmes fonctionnalités, contribution renforcée au projet.", + }, +} + + +def resolve_handle(key: str) -> str | None: + meta = PLANS.get(key) + return _handle(meta["env"]) if meta else None + + +def plan_meta(key: str) -> dict | None: + meta = PLANS.get(key) + if meta is None: + return None + return { + "key": key, + "handle": _handle(meta["env"]), + "label": meta["label"], + "prix_ht": meta["prix_ht"], + "description": meta["description"], + } + + +def _parse_trial_interval(value) -> int | None: + # Reepay/Frisbii renvoie une durée type "2d" (jours), "1m" (mois), etc. + # On ne sait afficher que les jours pour l'instant ; format à confirmer. + if not value or not isinstance(value, str): + return None + value = value.strip().lower() + if value.endswith("d") and value[:-1].isdigit(): + return int(value[:-1]) + return None + + +def trial_days(key: str) -> int | None: + handle = resolve_handle(key) + if not handle: + return None + now = time.monotonic() + cached = _trial_cache.get(handle) + if cached and now - cached[0] < _TTL_SECONDS: + return cached[1] + try: + plan = client.get_plan(handle) + except client.FrisbiiError: + logger.warning("Impossible de lire le plan Frisbii %s", handle) + return cached[1] if cached else None + days = _parse_trial_interval(plan.get("trial_interval")) + _trial_cache[handle] = (now, days) + return days diff --git a/tests/subscriptions/test_plans.py b/tests/subscriptions/test_plans.py new file mode 100644 index 0000000..91b4d5d --- /dev/null +++ b/tests/subscriptions/test_plans.py @@ -0,0 +1,41 @@ +import pytest + +from src.subscriptions import client, plans + + +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + plans._trial_cache.clear() + + +def test_resolve_handle(monkeypatch): + assert plans.resolve_handle("simple") == "plan_simple" + assert plans.resolve_handle("inconnu") is None + + +def test_trial_days_parses_and_caches(monkeypatch): + calls = [] + + def fake_get_plan(handle): + calls.append(handle) + return {"trial_interval": "2d"} + + monkeypatch.setattr(client, "get_plan", fake_get_plan) + assert plans.trial_days("simple") == 2 + assert plans.trial_days("simple") == 2 # depuis le cache + assert calls == ["plan_simple"] # un seul appel API + + +def test_trial_days_none_on_error(monkeypatch): + def fake_get_plan(handle): + raise client.FrisbiiError(500, "boom") + + monkeypatch.setattr(client, "get_plan", fake_get_plan) + assert plans.trial_days("simple") is None + + +def test_trial_days_none_when_no_trial(monkeypatch): + monkeypatch.setattr(client, "get_plan", lambda h: {"trial_interval": None}) + assert plans.trial_days("simple") is None