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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user