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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user