diff --git a/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md index 8082bd2..ed139ff 100644 --- a/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md +++ b/docs/superpowers/plans/2026-06-25-frisbii-abonnements.md @@ -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** diff --git a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md index 55f65b4..c268ea2 100644 --- a/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md +++ b/docs/superpowers/specs/2026-06-25-frisbii-abonnements-design.md @@ -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.