docs(abonnement): anti-abus essai (trial_used + no_trial) (#90)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRGb8NAMwaTZxUszSbaj4N
This commit is contained in:
Colin Maudry
2026-06-25 18:41:31 +02:00
parent 421750957f
commit a18d43dae4
2 changed files with 93 additions and 9 deletions
@@ -39,7 +39,7 @@
- `class FrisbiiError(Exception)` avec attribut `.status_code: int` et `.body`.
- `get_or_create_customer(handle: str, email: str) -> dict`
- `create_subscription_session(plan_handle: str, customer_handle: str, accept_url: str, cancel_url: str) -> str` (renvoie l'URL hébergée)
- `create_subscription_session(plan_handle: str, customer_handle: str, accept_url: str, cancel_url: str, no_trial: bool = False) -> str` (renvoie l'URL hébergée ; `no_trial=True` désactive l'essai du plan pour cette souscription)
- `cancel_subscription(subscription_handle: str) -> dict`
- `get_subscription(subscription_handle: str) -> dict`
- `get_plan(plan_handle: str) -> dict`
@@ -135,6 +135,14 @@ def test_create_subscription_session_returns_url(fake_httpx):
assert body["cancel_url"] == "https://app/ko"
def test_create_subscription_session_no_trial(fake_httpx):
fake_httpx["queue"].append(fake_httpx["Response"](200, {"url": "https://pay.test/cs_2"}))
client.create_subscription_session(
"plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True
)
assert fake_httpx["calls"][0]["json"]["prepare_subscription"]["no_trial"] is True
def test_http_error_raises_frisbii_error(fake_httpx):
fake_httpx["queue"].append(fake_httpx["Response"](500, {"error": "boom"}))
with pytest.raises(client.FrisbiiError) as exc:
@@ -201,15 +209,22 @@ def get_or_create_customer(handle: str, email: str) -> dict:
def create_subscription_session(
plan_handle: str, customer_handle: str, accept_url: str, cancel_url: str
plan_handle: str,
customer_handle: str,
accept_url: str,
cancel_url: str,
no_trial: bool = False,
) -> str:
prepare = {"plan": plan_handle, "customer": customer_handle}
if no_trial:
prepare["no_trial"] = True
data = _call(
"POST",
"/v1/session/subscription",
json={
"accept_url": accept_url,
"cancel_url": cancel_url,
"prepare_subscription": {"plan": plan_handle, "customer": customer_handle},
"prepare_subscription": prepare,
},
)
return data["url"]
@@ -230,7 +245,7 @@ def get_plan(plan_handle: str) -> dict:
- [ ] **Step 5: Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/subscriptions/test_client.py -v`
Expected: PASS (5 tests).
Expected: PASS (6 tests).
- [ ] **Step 6: Commit**
@@ -260,6 +275,7 @@ git commit -m "feat(abonnement): client HTTP Frisbii (#90)"
- `update_from_webhook(customer_handle: str, subscription_handle: str | None, status: str, current_period_end: str | None) -> None`
- `set_cancelled(user_id: int, current_period_end: str | None) -> None`
- `has_active_subscription(user_id: int) -> bool`
- `has_used_trial(user_id: int) -> bool` (anti-abus : essai déjà consommé ?)
- [ ] **Step 1: Écrire les tests du module DB**
@@ -344,6 +360,20 @@ def test_set_cancelled(users_db_path):
row = db.get_by_user(uid)
assert row["status"] == "cancelled"
assert row["current_period_end"] == end
def test_trial_used_is_sticky_across_resubscribe(users_db_path):
db.init_schema()
uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple")
assert db.has_used_trial(uid) is False
# l'abonnement entre en essai → trial_used positionné
db.update_from_webhook("decpinfo-1", "sub_42", "trial", _future())
assert db.has_used_trial(uid) is True
# essai abandonné, puis nouvelle souscription : trial_used reste vrai
db.update_from_webhook("decpinfo-1", "sub_42", "expired", _past())
db.create_pending(uid, "decpinfo-1", "soutien")
assert db.has_used_trial(uid) is True
```
- [ ] **Step 2: Lancer les tests pour vérifier l'échec**
@@ -369,6 +399,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
plan TEXT,
status TEXT,
current_period_end TEXT,
trial_used INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -426,12 +457,21 @@ def update_from_webhook(
status: str,
current_period_end: str | None,
) -> None:
trial_flag = 1 if status in _ACCESS_STATUSES else 0
get_conn().execute(
"UPDATE subscriptions SET "
"frisbii_subscription_handle = COALESCE(?, frisbii_subscription_handle), "
"status = ?, current_period_end = ?, updated_at = ? "
"status = ?, current_period_end = ?, "
"trial_used = max(trial_used, ?), updated_at = ? "
"WHERE frisbii_customer_handle = ?",
(subscription_handle, status, current_period_end, _now(), customer_handle),
(
subscription_handle,
status,
current_period_end,
trial_flag,
_now(),
customer_handle,
),
)
@@ -452,12 +492,17 @@ def has_active_subscription(user_id: int) -> bool:
if row["status"] == "cancelled" and row["current_period_end"]:
return row["current_period_end"] > _now()
return False
def has_used_trial(user_id: int) -> bool:
row = get_by_user(user_id)
return bool(row and row["trial_used"])
```
- [ ] **Step 4: Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/subscriptions/test_db.py -v`
Expected: PASS (5 tests).
Expected: PASS (6 tests).
- [ ] **Step 5: Commit**
@@ -875,7 +920,7 @@ def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch):
monkeypatch.setattr(
frisbii_client,
"create_subscription_session",
lambda plan, cust, ok, ko: "https://pay.test/cs_1",
lambda plan, cust, ok, ko, no_trial=False: "https://pay.test/cs_1",
)
resp = client.post("/subscriptions/subscribe", data={"plan": "simple"})
assert resp.status_code == 303
@@ -883,6 +928,26 @@ def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch):
assert db.get_by_user(uid)["status"] == "pending"
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é.
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")
captured = {}
monkeypatch.setattr(
frisbii_client, "get_or_create_customer", lambda h, e: {"handle": h}
)
def fake_session(plan, cust, ok, ko, no_trial=False):
captured["no_trial"] = no_trial
return "https://pay.test/cs_9"
monkeypatch.setattr(frisbii_client, "create_subscription_session", fake_session)
resp = client.post("/subscriptions/subscribe", data={"plan": "soutien"})
assert resp.status_code == 303
assert captured["no_trial"] is True
def test_subscribe_unknown_plan(logged_in_client):
client, _ = logged_in_client
resp = client.post("/subscriptions/subscribe", data={"plan": "bidon"})
@@ -992,11 +1057,14 @@ def subscribe():
try:
client.get_or_create_customer(cust, current_user.email)
db.create_pending(current_user.id, cust, plan_key)
# Anti-abus : pas de nouvel essai si l'utilisateur en a déjà consommé un.
no_trial = db.has_used_trial(current_user.id)
url = client.create_subscription_session(
handle,
cust,
f"{base}/compte/abonnement?paiement=succes",
f"{base}/compte/abonnement?paiement=annule",
no_trial=no_trial,
)
except client.FrisbiiError:
logger.exception("Échec de création de session d'abonnement Frisbii")
@@ -1110,7 +1178,7 @@ FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
- [ ] **Step 8: Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/subscriptions/test_routes.py -v`
Expected: PASS (7 tests).
Expected: PASS (8 tests).
- [ ] **Step 9: Commit**
@@ -43,6 +43,17 @@ les branche sur Frisbii.
automatiquement de `trial` à `active`. Si le paiement échoue → `expired`. Une
résiliation pendant l'essai expire en **fin d'essai** (pas de débit). Pendant
l'essai, l'utilisateur a **accès aux fonctions premium**.
**Anti-abus (un seul essai par compte).** Frisbii ne restreint pas l'essai par
client : sans garde-fou, un utilisateur pourrait s'abonner, résilier avant la fin
de l'essai (sans débit) et recommencer indéfiniment. On mémorise donc côté app un
indicateur `trial_used` (positionné dès que l'abonnement entre en `trial` ou
`active`). À toute souscription ultérieure d'un utilisateur dont `trial_used` est
vrai, la session est créée avec **`no_trial=true`** → passage direct en `active`,
débit immédiat, sans nouvel essai. _Limite connue, non traitée :_ un utilisateur
pourrait créer plusieurs comptes decp.info (emails différents) pour refarmer des
essais — acceptable vu l'essai de 2 jours et le contexte d'intérêt public.
5. **Résiliation en fin de période courante.** `POST` cancel Frisbii avec le
comportement **par défaut** (expiration en fin de période courante — ou fin
d'essai si en essai). L'accès est maintenu jusqu'à `current_period_end` renvoyé
@@ -104,6 +115,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
plan TEXT, -- 'simple' | 'soutien'
status TEXT, -- 'pending' | 'trial' | 'active' | 'cancelled' | 'expired'
current_period_end TEXT, -- ISO 8601, nullable
trial_used INTEGER NOT NULL DEFAULT 0, -- 1 dès qu'un essai a été consommé
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -144,6 +156,8 @@ Fonctions :
- Form `plan=simple|soutien`. Résout le handle via `plans.py` (400 si inconnu).
- `get_or_create_customer("decpinfo-{user_id}", email)`.
- `upsert_subscription(..., status='pending')`.
- **Anti-abus** : si `trial_used` est déjà vrai pour cet utilisateur, la session est
créée avec `no_trial=true` (pas de nouvel essai gratuit).
- `create_subscription_session(...)` avec
`accept_url={APP_BASE_URL}/compte/abonnement?paiement=succes` et
`cancel_url={APP_BASE_URL}/compte/abonnement?paiement=annule`.
@@ -169,6 +183,8 @@ Fonctions :
- `invoice_settled` / renouvellement → `current_period_end` maj.
- `subscription_cancelled``status='cancelled'`, `current_period_end` maj.
- `subscription_expired` / échec de paiement terminal → `status='expired'`.
- Dès que le statut calculé est `trial` ou `active`, positionne `trial_used=1`
(anti-abus, valeur collante).
- Événement inconnu → **200** (ignoré). Erreur de traitement → **5xx** pour que
Frisbii réessaie. Les noms exacts d'événements seront confirmés depuis la doc
Frisbii ; le dispatch est piloté par une table `EVENT_HANDLERS` facile à étendre.