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:
|
if row["status"] in _ACCESS_STATUSES:
|
||||||
return True
|
return True
|
||||||
if row["status"] == "cancelled" and row["current_period_end"]:
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,16 +30,20 @@ PLANS = {
|
|||||||
|
|
||||||
def resolve_handle(key: str) -> str | None:
|
def resolve_handle(key: str) -> str | None:
|
||||||
meta = PLANS.get(key)
|
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:
|
def plan_meta(key: str) -> dict | None:
|
||||||
meta = PLANS.get(key)
|
meta = PLANS.get(key)
|
||||||
if meta is None:
|
if meta is None:
|
||||||
return None
|
return None
|
||||||
|
h = _handle(meta["env"])
|
||||||
return {
|
return {
|
||||||
"key": key,
|
"key": key,
|
||||||
"handle": _handle(meta["env"]),
|
"handle": h if h else None,
|
||||||
"label": meta["label"],
|
"label": meta["label"],
|
||||||
"prix_ht": meta["prix_ht"],
|
"prix_ht": meta["prix_ht"],
|
||||||
"description": meta["description"],
|
"description": meta["description"],
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ def subscribe():
|
|||||||
return "Plan inconnu", 400
|
return "Plan inconnu", 400
|
||||||
|
|
||||||
base = os.getenv("APP_BASE_URL", "")
|
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)
|
cust = _customer_handle(current_user.id)
|
||||||
try:
|
try:
|
||||||
client.get_or_create_customer(cust, current_user.email)
|
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
|
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):
|
def test_trial_used_is_sticky_across_resubscribe(users_db_path):
|
||||||
db.init_schema()
|
db.init_schema()
|
||||||
uid = _make_user()
|
uid = _make_user()
|
||||||
|
|||||||
@@ -39,3 +39,17 @@ def test_trial_days_none_on_error(monkeypatch):
|
|||||||
def test_trial_days_none_when_no_trial(monkeypatch):
|
def test_trial_days_none_when_no_trial(monkeypatch):
|
||||||
monkeypatch.setattr(client, "get_plan", lambda h: {"trial_interval": None})
|
monkeypatch.setattr(client, "get_plan", lambda h: {"trial_interval": None})
|
||||||
assert plans.trial_days("simple") is 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):
|
def test_subscribe_disables_trial_after_first_use(logged_in_client, monkeypatch):
|
||||||
client, uid = logged_in_client
|
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.create_pending(uid, "decpinfo-%d" % uid, "simple")
|
||||||
db.update_from_webhook(
|
db.update_from_webhook(
|
||||||
"decpinfo-%d" % uid, "sub_42", "trial", "2099-01-01T00:00:00+00:00"
|
"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 = {}
|
captured = {}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
frisbii_client, "get_or_create_customer", lambda h, e: {"handle": h}
|
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)
|
row = db.get_by_user(uid)
|
||||||
assert row["status"] == "active"
|
assert row["status"] == "active"
|
||||||
assert row["frisbii_subscription_handle"] == "sub_42"
|
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