fix(abonnement): garde active→pending, datetime parse, resolve_handle vide (#90)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
This commit is contained in:
Colin Maudry
2026-06-25 19:26:15 +02:00
parent d3025e323b
commit 5f500a1aa8
6 changed files with 96 additions and 4 deletions
+7 -1
View File
@@ -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
+6 -2
View File
@@ -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"],
+3
View File
@@ -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)
+28
View File
@@ -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()
+14
View File
@@ -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
+38 -1
View File
@@ -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"