diff --git a/src/subscriptions/db.py b/src/subscriptions/db.py index d7611a6..9935eba 100644 --- a/src/subscriptions/db.py +++ b/src/subscriptions/db.py @@ -102,7 +102,13 @@ def has_active_subscription(user_id: int) -> bool: if row["status"] in _ACCESS_STATUSES: return True if row["status"] == "cancelled" and row["current_period_end"]: - return row["current_period_end"] > _now() + try: + end = datetime.fromisoformat( + row["current_period_end"].replace("Z", "+00:00") + ) + return end > datetime.now(timezone.utc) + except ValueError: + return False return False diff --git a/src/subscriptions/plans.py b/src/subscriptions/plans.py index 3e5a9c0..b0a13c1 100644 --- a/src/subscriptions/plans.py +++ b/src/subscriptions/plans.py @@ -30,16 +30,20 @@ PLANS = { def resolve_handle(key: str) -> str | None: meta = PLANS.get(key) - return _handle(meta["env"]) if meta else None + 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": _handle(meta["env"]), + "handle": h if h else None, "label": meta["label"], "prix_ht": meta["prix_ht"], "description": meta["description"], diff --git a/src/subscriptions/routes.py b/src/subscriptions/routes.py index df66a0b..3ba142e 100644 --- a/src/subscriptions/routes.py +++ b/src/subscriptions/routes.py @@ -22,6 +22,9 @@ def subscribe(): 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) diff --git a/tests/subscriptions/test_db.py b/tests/subscriptions/test_db.py index e4e9a82..3f9aa61 100644 --- a/tests/subscriptions/test_db.py +++ b/tests/subscriptions/test_db.py @@ -78,6 +78,34 @@ def test_set_cancelled(users_db_path): 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() diff --git a/tests/subscriptions/test_plans.py b/tests/subscriptions/test_plans.py index 91b4d5d..031ed89 100644 --- a/tests/subscriptions/test_plans.py +++ b/tests/subscriptions/test_plans.py @@ -39,3 +39,17 @@ def test_trial_days_none_on_error(monkeypatch): 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 index 855dac0..f2ba6a8 100644 --- a/tests/subscriptions/test_routes.py +++ b/tests/subscriptions/test_routes.py @@ -29,11 +29,15 @@ def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): 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é. + # 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} @@ -124,3 +128,36 @@ def test_webhook_updates_subscription(sub_app, monkeypatch): 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"