# Handle d'abonnement personnalisé + historique des abonnements — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Remplacer `generate_handle: true` par un handle choisi par colibre (`abo-{user_id}-{N}`, unique), en faisant évoluer `subscriptions` vers un historique multi-lignes et en introduisant `subscriber_state` pour l'état cumulatif (votes, essai) de l'utilisateur. **Architecture:** `subscriptions/db.py` est réécrit intégralement (schéma, migration par reconstruction de table, toute l'API) car ses fonctions sont étroitement couplées ; `client.py`, `routes.py` et `compte_abonnement.py` sont ajustés en tâches séparées une fois la nouvelle API de `db.py` disponible. **Tech Stack:** Python, Flask, SQLite (via `src.auth.db.get_conn`), pytest. ## Global Constraints - Le préfixe du handle est `abo` : format exact `abo-{user_id}-{N}`. - Le handle est écrit par colibre dans `create_pending`, **avant** l'appel à l'API Frisbii — jamais par le webhook. - Chaque tentative de souscription crée une nouvelle ligne dans `subscriptions` (pas de réutilisation d'une ligne `pending` existante). - Une ligne dont l'appel Frisbii a échoué passe au statut `failed` (jamais supprimée), pour ne jamais réutiliser son handle. - `trial_used`, `votes_balance`, `votes_last_credited_at` vivent dans la nouvelle table `subscriber_state` (1 ligne par utilisateur), pas dans `subscriptions`. - Design de référence : `docs/superpowers/specs/2026-07-01-subscriptions-handle-history-design.md`. - Lancer les tests avec `uv run pytest` (l'activation du venv via le Bash tool ne met pas PATH à jour de façon fiable dans cet environnement). --- ## Task 1: `subscriptions/db.py` — schéma, migration, API complète **Files:** - Modify: `src/subscriptions/db.py` (réécriture complète) - Modify: `tests/subscriptions/test_db.py` (réécriture complète) **Interfaces:** - Produces (utilisées par les tâches suivantes) : - `init_schema() -> None` - `get_current(user_id: int) -> sqlite3.Row | None` - `get_by_handle(subscription_handle: str) -> sqlite3.Row | None` - `customer_known(customer_handle: str) -> bool` - `create_pending(user_id: int, customer_handle: str, plan: str, prix_ht: float | None = None) -> tuple[str, int]` (renvoie `(handle, subscription_id)`) - `mark_failed(subscription_id: int) -> None` - `update_from_webhook(subscription_handle: str, status: str, current_period_end: str | None) -> None` - `set_cancelled(subscription_id: int, current_period_end: str | None) -> None` - `has_active_subscription(user_id: int) -> bool` - `has_used_trial(user_id: int) -> bool` - `credit_pending(user_id: int) -> int` - `spend_vote(user_id: int) -> bool` - `next_recharge_at(user_id: int) -> datetime | None` - `freeze_votes_cursor(user_id: int) -> None` - `INITIAL_VOTES`, `VOTES_PER_WEEK` (constantes, inchangées) - [ ] **Step 1: Écrire le nouveau `src/subscriptions/db.py` (schéma + migration + API)** Remplacer l'intégralité du fichier `src/subscriptions/db.py` par : ```python import sqlite3 from datetime import datetime, timedelta, timezone from src.auth.db import get_conn SUBSCRIPTIONS_SCHEMA = """ CREATE TABLE IF NOT EXISTS subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, frisbii_customer_handle TEXT, frisbii_subscription_handle TEXT, plan TEXT, prix_ht REAL, status TEXT, current_period_end TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_handle ON subscriptions(frisbii_subscription_handle); CREATE TABLE IF NOT EXISTS subscriber_state ( user_id INTEGER PRIMARY KEY, trial_used INTEGER NOT NULL DEFAULT 0, votes_balance INTEGER NOT NULL DEFAULT 0, votes_last_credited_at TEXT, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); """ _ACCESS_STATUSES = ("trial", "active") INITIAL_VOTES = 3 VOTES_PER_WEEK = 3 WEEK_SECONDS = 7 * 24 * 3600 def _now() -> str: return datetime.now(timezone.utc).isoformat() def init_schema() -> None: conn = get_conn() conn.executescript(SUBSCRIPTIONS_SCHEMA) _migrate(conn) def _migrate(conn: sqlite3.Connection) -> None: cols = {row["name"] for row in conn.execute("PRAGMA table_info(subscriptions)")} if "id" not in cols: _rebuild_subscriptions_history(conn) def _rebuild_subscriptions_history(conn: sqlite3.Connection) -> None: # L'ancien schéma avait user_id en PRIMARY KEY (1 ligne par utilisateur) et # portait aussi l'état cumulatif (votes, essai). SQLite ne peut ni retirer # une PRIMARY KEY ni répartir des colonnes vers une autre table via ALTER : # on reconstruit la table. foreign_keys OFF pour éviter le cascade-delete # pendant le DROP. conn.execute("PRAGMA foreign_keys = OFF") try: conn.execute("BEGIN") conn.execute( """ CREATE TABLE subscriptions_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, frisbii_customer_handle TEXT, frisbii_subscription_handle TEXT, plan TEXT, prix_ht REAL, status TEXT, current_period_end TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) """ ) conn.execute( "INSERT INTO subscriptions_new (user_id, frisbii_customer_handle, " "frisbii_subscription_handle, plan, prix_ht, status, " "current_period_end, created_at, updated_at) " "SELECT user_id, frisbii_customer_handle, frisbii_subscription_handle, " "plan, prix_ht, status, current_period_end, created_at, updated_at " "FROM subscriptions" ) conn.execute( "INSERT INTO subscriber_state (user_id, trial_used, votes_balance, " "votes_last_credited_at, updated_at) " "SELECT user_id, trial_used, votes_balance, votes_last_credited_at, " "updated_at FROM subscriptions" ) conn.execute("DROP TABLE subscriptions") conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions") conn.execute( "CREATE INDEX IF NOT EXISTS idx_subscriptions_user " "ON subscriptions(user_id)" ) conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_handle " "ON subscriptions(frisbii_subscription_handle)" ) conn.execute("COMMIT") except Exception: conn.execute("ROLLBACK") raise finally: conn.execute("PRAGMA foreign_keys = ON") def get_current(user_id: int) -> sqlite3.Row | None: return ( get_conn() .execute( "SELECT * FROM subscriptions WHERE user_id = ? ORDER BY id DESC LIMIT 1", (user_id,), ) .fetchone() ) def get_by_handle(subscription_handle: str) -> sqlite3.Row | None: return ( get_conn() .execute( "SELECT * FROM subscriptions WHERE frisbii_subscription_handle = ?", (subscription_handle,), ) .fetchone() ) def customer_known(customer_handle: str) -> bool: row = ( get_conn() .execute( "SELECT 1 FROM subscriptions WHERE frisbii_customer_handle = ? LIMIT 1", (customer_handle,), ) .fetchone() ) return row is not None def _next_handle(user_id: int) -> str: prefix = f"abo-{user_id}-" rows = ( get_conn() .execute( "SELECT frisbii_subscription_handle FROM subscriptions " "WHERE user_id = ? AND frisbii_subscription_handle LIKE ?", (user_id, f"{prefix}%"), ) .fetchall() ) n = 0 for row in rows: suffix = row["frisbii_subscription_handle"][len(prefix) :] if suffix.isdigit(): n = max(n, int(suffix)) return f"{prefix}{n + 1}" def create_pending( user_id: int, customer_handle: str, plan: str, prix_ht: float | None = None ) -> tuple[str, int]: """Crée une nouvelle ligne d'historique en statut 'pending'. Renvoie (handle, subscription_id). Le handle est choisi et enregistré ici, avant tout appel à l'API Frisbii. """ now = _now() handle = _next_handle(user_id) conn = get_conn() conn.execute( "INSERT OR IGNORE INTO subscriber_state (user_id, updated_at) VALUES (?, ?)", (user_id, now), ) cur = conn.execute( "INSERT INTO subscriptions " "(user_id, frisbii_customer_handle, frisbii_subscription_handle, plan, " "prix_ht, status, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)", (user_id, customer_handle, handle, plan, prix_ht, now, now), ) return handle, cur.lastrowid def mark_failed(subscription_id: int) -> None: """Marque une ligne comme échouée (échec de l'appel Frisbii après create_pending). Le handle reste enregistré (pas de suppression de la ligne) pour ne jamais être réutilisé par _next_handle. """ get_conn().execute( "UPDATE subscriptions SET status = 'failed', updated_at = ? WHERE id = ?", (_now(), subscription_id), ) def _get_state(user_id: int) -> sqlite3.Row | None: return ( get_conn() .execute("SELECT * FROM subscriber_state WHERE user_id = ?", (user_id,)) .fetchone() ) def freeze_votes_cursor(user_id: int) -> None: """Réactivation après une période sans abonnement : repart de maintenant. Ne fait rien si le curseur est NULL (première activation jamais atteinte) : les +2 initiaux restent gérés par credit_pending. Ne re-crédite jamais. """ row = _get_state(user_id) if row is None or row["votes_last_credited_at"] is None: return now = _now() get_conn().execute( "UPDATE subscriber_state SET votes_last_credited_at = ?, updated_at = ? " "WHERE user_id = ?", (now, now, user_id), ) def update_from_webhook( subscription_handle: str, status: str, current_period_end: str | None, ) -> None: prev = get_by_handle(subscription_handle) if prev is None: return if prev["status"] == "active" and status != "active": credit_pending(prev["user_id"]) # banque les semaines acquises avant gel get_conn().execute( "UPDATE subscriptions SET status = ?, current_period_end = ?, " "updated_at = ? WHERE id = ?", (status, current_period_end, _now(), prev["id"]), ) if status in _ACCESS_STATUSES: get_conn().execute( "UPDATE subscriber_state SET trial_used = 1, updated_at = ? " "WHERE user_id = ?", (_now(), prev["user_id"]), ) if prev["status"] != "active" and status == "active": freeze_votes_cursor(prev["user_id"]) def set_cancelled(subscription_id: int, current_period_end: str | None) -> None: row = ( get_conn() .execute("SELECT user_id FROM subscriptions WHERE id = ?", (subscription_id,)) .fetchone() ) if row is None: return credit_pending(row["user_id"]) # banque les semaines pleines acquises get_conn().execute( "UPDATE subscriptions SET status = 'cancelled', current_period_end = ?, " "updated_at = ? WHERE id = ?", (current_period_end, _now(), subscription_id), ) def has_active_subscription(user_id: int) -> bool: row = get_current(user_id) if row is None: return False if row["status"] in _ACCESS_STATUSES: return True if row["status"] == "cancelled" and row["current_period_end"]: try: end = datetime.fromisoformat( row["current_period_end"].replace("Z", "+00:00") ) return end > datetime.now(timezone.utc) except ValueError: return False return False def has_used_trial(user_id: int) -> bool: row = _get_state(user_id) return bool(row and row["trial_used"]) def _set_votes(user_id: int, balance: int, cursor_iso: str) -> None: get_conn().execute( "UPDATE subscriber_state SET votes_balance = ?, votes_last_credited_at = ?, " "updated_at = ? WHERE user_id = ?", (balance, cursor_iso, _now(), user_id), ) def credit_pending(user_id: int) -> int: """Crédite paresseusement les votes acquis et renvoie le solde courant. +VOTES_PER_WEEK à la première activation, puis +VOTES_PER_WEEK par semaine pleine. Le solde est cappé à VOTES_PER_WEEK (pas d'accumulation). Idempotent : ne crédite que des semaines pleines. """ state = _get_state(user_id) if state is None: return 0 balance = state["votes_balance"] or 0 current = get_current(user_id) if current is None or current["status"] != "active": return balance now = datetime.now(timezone.utc) cursor = state["votes_last_credited_at"] if cursor is None: balance = min(balance + INITIAL_VOTES, VOTES_PER_WEEK) _set_votes(user_id, balance, now.isoformat()) return balance cur = datetime.fromisoformat(cursor) weeks = int((now - cur).total_seconds() // WEEK_SECONDS) if weeks > 0: balance = min(balance + weeks * VOTES_PER_WEEK, VOTES_PER_WEEK) new_cursor = cur + timedelta(seconds=weeks * WEEK_SECONDS) _set_votes(user_id, balance, new_cursor.isoformat()) return balance def spend_vote(user_id: int) -> bool: """Débite 1 vote si le solde le permet. Renvoie True si un vote a été débité.""" cur = get_conn().execute( "UPDATE subscriber_state SET votes_balance = votes_balance - 1, " "updated_at = ? WHERE user_id = ? AND votes_balance > 0", (_now(), user_id), ) return cur.rowcount > 0 def next_recharge_at(user_id: int) -> datetime | None: """Retourne la date du prochain rechargement de votes, ou None si non applicable.""" row = _get_state(user_id) if not row or not row["votes_last_credited_at"]: return None cursor = datetime.fromisoformat(row["votes_last_credited_at"]) return cursor + timedelta(seconds=WEEK_SECONDS) ``` - [ ] **Step 2: Écrire le nouveau `tests/subscriptions/test_db.py`** Remplacer l'intégralité du fichier `tests/subscriptions/test_db.py` par : ```python from datetime import datetime, timedelta, timezone from src.auth import db as auth_db from src.auth.db import get_conn from src.subscriptions import db def _future(): return (datetime.now(timezone.utc) + timedelta(days=2)).isoformat() def _past(): return (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() def _make_user(email="u@ex.fr"): auth_db.init_schema() return auth_db.create_user(email, "hash") def _activate(uid, cursor_iso=None): """Met l'abonnement en statut actif avec un curseur d'accumulation donné.""" row = db.get_current(uid) get_conn().execute( "UPDATE subscriptions SET status = 'active' WHERE id = ?", (row["id"],) ) get_conn().execute( "UPDATE subscriber_state SET votes_last_credited_at = ? WHERE user_id = ?", (cursor_iso, uid), ) def test_init_schema_creates_tables(users_db_path): db.init_schema() conn = auth_db.get_conn() tables = { row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") } assert "subscriptions" in tables assert "subscriber_state" in tables def test_init_schema_migrates_old_single_row_subscriptions(users_db_path): auth_db.init_schema() uid = auth_db.create_user("legacy@ex.fr", "hash") conn = get_conn() conn.execute( """ CREATE TABLE subscriptions ( user_id INTEGER PRIMARY KEY, frisbii_customer_handle TEXT, frisbii_subscription_handle TEXT, plan TEXT, prix_ht REAL, status TEXT, current_period_end TEXT, trial_used INTEGER NOT NULL DEFAULT 0, votes_balance INTEGER NOT NULL DEFAULT 0, votes_last_credited_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) """ ) conn.execute( "INSERT INTO subscriptions (user_id, frisbii_customer_handle, " "frisbii_subscription_handle, plan, status, trial_used, votes_balance, " "votes_last_credited_at, created_at, updated_at) " "VALUES (?, 'colibre-legacy', 'sub_legacy', 'simple', 'active', 1, 2, " "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00', " "'2026-01-01T00:00:00+00:00')", (uid,), ) db.init_schema() cols = {row["name"] for row in conn.execute("PRAGMA table_info(subscriptions)")} assert "id" in cols row = db.get_current(uid) assert row["frisbii_subscription_handle"] == "sub_legacy" assert row["status"] == "active" state = conn.execute( "SELECT * FROM subscriber_state WHERE user_id = ?", (uid,) ).fetchone() assert state["trial_used"] == 1 assert state["votes_balance"] == 2 assert state["votes_last_credited_at"] == "2026-01-01T00:00:00+00:00" def test_create_pending_and_get_current(users_db_path): db.init_schema() uid = _make_user() handle, subscription_id = db.create_pending(uid, "colibre-1", "simple") assert handle == f"abo-{uid}-1" row = db.get_current(uid) assert row["id"] == subscription_id assert row["status"] == "pending" assert row["plan"] == "simple" assert row["frisbii_customer_handle"] == "colibre-1" assert row["frisbii_subscription_handle"] == handle def test_create_pending_generates_incrementing_handle_per_user(users_db_path): db.init_schema() uid = _make_user() first_handle, first_id = db.create_pending(uid, "colibre-1", "simple") db.mark_failed(first_id) second_handle, _ = db.create_pending(uid, "colibre-1", "simple") assert first_handle == f"abo-{uid}-1" assert second_handle == f"abo-{uid}-2" def test_mark_failed_sets_status_and_keeps_handle(users_db_path): db.init_schema() uid = _make_user() handle, subscription_id = db.create_pending(uid, "colibre-1", "simple") db.mark_failed(subscription_id) row = db.get_current(uid) assert row["status"] == "failed" assert row["frisbii_subscription_handle"] == handle def test_update_from_webhook_sets_status(users_db_path): db.init_schema() uid = _make_user() handle, _ = db.create_pending(uid, "colibre-1", "simple") db.update_from_webhook(handle, "trial", _future()) row = db.get_current(uid) assert row["status"] == "trial" assert row["frisbii_subscription_handle"] == handle assert db.customer_known("colibre-1") is True def test_customer_known_false_for_unknown_customer(users_db_path): db.init_schema() assert db.customer_known("colibre-inconnu") is False def test_has_active_subscription_by_status(users_db_path): db.init_schema() uid = _make_user() handle, _ = db.create_pending(uid, "colibre-1", "simple") # pending → faux assert db.has_active_subscription(uid) is False for status in ("trial", "active"): db.update_from_webhook(handle, status, _future()) assert db.has_active_subscription(uid) is True # cancelled futur → vrai, cancelled passé → faux db.update_from_webhook(handle, "cancelled", _future()) assert db.has_active_subscription(uid) is True db.update_from_webhook(handle, "cancelled", _past()) assert db.has_active_subscription(uid) is False db.update_from_webhook(handle, "expired", None) assert db.has_active_subscription(uid) is False def test_set_cancelled(users_db_path): db.init_schema() uid = _make_user() handle, subscription_id = db.create_pending(uid, "colibre-1", "simple") db.update_from_webhook(handle, "active", _future()) end = _future() db.set_cancelled(subscription_id, end) row = db.get_current(uid) assert row["status"] == "cancelled" 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() handle, _ = db.create_pending(uid, "colibre-1", "simple") db.update_from_webhook(handle, "cancelled", "2099-12-31T23:59:59Z") assert db.has_active_subscription(uid) is True db.update_from_webhook(handle, "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() handle, subscription_id = db.create_pending(uid, "colibre-1", "simple") # Injection directe d'une valeur invalide via update bas-niveau. get_conn().execute( "UPDATE subscriptions SET status='cancelled', current_period_end='not-a-date' " "WHERE id=?", (subscription_id,), ) 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() handle, _ = db.create_pending(uid, "colibre-1", "simple") assert db.has_used_trial(uid) is False # l'abonnement entre en essai → trial_used positionné db.update_from_webhook(handle, "trial", _future()) assert db.has_used_trial(uid) is True # essai abandonné, puis nouvelle souscription : trial_used reste vrai db.update_from_webhook(handle, "expired", _past()) db.create_pending(uid, "colibre-1", "soutien") assert db.has_used_trial(uid) is True def test_create_pending_initializes_subscriber_state(users_db_path): db.init_schema() uid = _make_user() db.create_pending(uid, "colibre-1", "simple") state = get_conn().execute( "SELECT * FROM subscriber_state WHERE user_id = ?", (uid,) ).fetchone() assert state["votes_balance"] == 0 assert state["votes_last_credited_at"] is None def test_credit_pending_grants_initial_two_on_first_active(users_db_path): db.init_schema() uid = _make_user() db.create_pending(uid, "colibre-1", "simple") _activate(uid, cursor_iso=None) balance = db.credit_pending(uid) assert balance == db.INITIAL_VOTES state = get_conn().execute( "SELECT votes_last_credited_at FROM subscriber_state WHERE user_id = ?", (uid,), ).fetchone() assert state["votes_last_credited_at"] is not None def test_credit_pending_is_idempotent_same_day(users_db_path): db.init_schema() uid = _make_user() db.create_pending(uid, "colibre-1", "simple") _activate(uid, cursor_iso=None) db.credit_pending(uid) assert db.credit_pending(uid) == db.INITIAL_VOTES # aucun crédit supplémentaire def test_credit_pending_capped_at_votes_per_week(users_db_path): db.init_schema() uid = _make_user() db.create_pending(uid, "colibre-1", "simple") fifteen_days_ago = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat() _activate(uid, cursor_iso=fifteen_days_ago) # 15 jours = 2 semaines pleines, mais le solde est cappé à VOTES_PER_WEEK assert db.credit_pending(uid) == db.VOTES_PER_WEEK def test_credit_pending_no_credit_when_not_active(users_db_path): db.init_schema() uid = _make_user() db.create_pending(uid, "colibre-1", "simple") # statut 'pending' assert db.credit_pending(uid) == 0 def test_spend_vote_decrements_when_balance_positive(users_db_path): db.init_schema() uid = _make_user() db.create_pending(uid, "colibre-1", "simple") _activate(uid, cursor_iso=None) db.credit_pending(uid) # solde = INITIAL_VOTES assert db.spend_vote(uid) is True state = get_conn().execute( "SELECT votes_balance FROM subscriber_state WHERE user_id = ?", (uid,) ).fetchone() assert state["votes_balance"] == db.INITIAL_VOTES - 1 def test_spend_vote_refused_when_balance_zero(users_db_path): db.init_schema() uid = _make_user() db.create_pending(uid, "colibre-1", "simple") _activate(uid, cursor_iso=None) # solde reste 0 tant que credit_pending n'est pas appelé assert db.spend_vote(uid) is False def test_reactivation_resets_cursor_without_regranting(users_db_path): db.init_schema() uid = _make_user() handle, _ = db.create_pending(uid, "colibre-1", "simple") _activate(uid, cursor_iso=None) db.credit_pending(uid) # +3, curseur posé # désabonnement db.update_from_webhook(handle, "cancelled", _future()) # période sans abonnement simulée : on recule artificiellement le curseur old_cursor = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() get_conn().execute( "UPDATE subscriber_state SET votes_last_credited_at = ? WHERE user_id = ?", (old_cursor, uid), ) # réabonnement db.update_from_webhook(handle, "active", _future()) state = get_conn().execute( "SELECT votes_balance FROM subscriber_state WHERE user_id = ?", (uid,) ).fetchone() assert state["votes_balance"] == db.INITIAL_VOTES # pas de re-crédit des +3 # le curseur a été remis ~à maintenant → pas de crédit du gap de 30 jours assert db.credit_pending(uid) == db.INITIAL_VOTES def test_trial_to_active_does_not_reset_then_grants_two(users_db_path): db.init_schema() uid = _make_user() handle, _ = db.create_pending(uid, "colibre-1", "simple") db.update_from_webhook(handle, "trial", _future()) db.update_from_webhook(handle, "active", _future()) # fin d'essai : credit_pending accorde les +INITIAL_VOTES initiaux assert db.credit_pending(uid) == db.INITIAL_VOTES ``` - [ ] **Step 3: Lancer les tests et vérifier qu'ils passent** Run: `uv run pytest tests/subscriptions/test_db.py -v` Expected: tous les tests passent (24 tests). - [ ] **Step 4: Commit** ```bash git add src/subscriptions/db.py tests/subscriptions/test_db.py git commit -m "feat(subscriptions): historique multi-lignes + subscriber_state + handle abo-{user_id}-N" ``` --- ## Task 2: `client.py` — paramètre `handle` **Files:** - Modify: `src/subscriptions/client.py:64-86` - Test: `tests/subscriptions/test_client.py:28-71` **Interfaces:** - Consumes: rien de nouveau. - Produces: `create_subscription_session(plan_handle: str, handle: str, accept_url: str, cancel_url: str, no_trial: bool = False, customer_handle: str | None = None, create_customer: dict | None = None) -> str` — nouveau paramètre positionnel `handle` en 2ᵉ position. - [ ] **Step 1: Mettre à jour les tests existants (ils doivent échouer avec la signature actuelle)** Dans `tests/subscriptions/test_client.py`, remplacer : ```python def test_create_subscription_session_with_customer_handle(fake_httpx): fake_httpx["queue"].append( fake_httpx["Response"]( 200, { "hosted_page_links": { "payment_info": "https://checkout.reepay.com/#/sub-1" } }, ) ) url = client.create_subscription_session( "plan_simple", "https://app/ok", "https://app/ko", customer_handle="colibre-1", ) assert url == "https://checkout.reepay.com/#/sub-1" body = fake_httpx["calls"][0]["json"] assert body["plan"] == "plan_simple" assert body["customer"] == "colibre-1" assert body["signup_method"] == "link" assert "prepare_subscription" not in body def test_create_subscription_session_no_trial(fake_httpx): fake_httpx["queue"].append( fake_httpx["Response"]( 200, { "hosted_page_links": { "payment_info": "https://checkout.reepay.com/#/sub-2" } }, ) ) client.create_subscription_session( "plan_simple", "https://app/ok", "https://app/ko", no_trial=True, customer_handle="colibre-1", ) assert fake_httpx["calls"][0]["json"]["no_trial"] is True ``` par : ```python def test_create_subscription_session_with_customer_handle(fake_httpx): fake_httpx["queue"].append( fake_httpx["Response"]( 200, { "hosted_page_links": { "payment_info": "https://checkout.reepay.com/#/sub-1" } }, ) ) url = client.create_subscription_session( "plan_simple", "abo-1-1", "https://app/ok", "https://app/ko", customer_handle="colibre-1", ) assert url == "https://checkout.reepay.com/#/sub-1" body = fake_httpx["calls"][0]["json"] assert body["plan"] == "plan_simple" assert body["handle"] == "abo-1-1" assert body["customer"] == "colibre-1" assert body["signup_method"] == "link" assert "generate_handle" not in body assert "prepare_subscription" not in body def test_create_subscription_session_no_trial(fake_httpx): fake_httpx["queue"].append( fake_httpx["Response"]( 200, { "hosted_page_links": { "payment_info": "https://checkout.reepay.com/#/sub-2" } }, ) ) client.create_subscription_session( "plan_simple", "abo-1-2", "https://app/ok", "https://app/ko", no_trial=True, customer_handle="colibre-1", ) assert fake_httpx["calls"][0]["json"]["no_trial"] is True ``` - [ ] **Step 2: Lancer les tests, vérifier qu'ils échouent** Run: `uv run pytest tests/subscriptions/test_client.py -v` Expected: FAIL (`TypeError: create_subscription_session() takes from 3 to 6 positional arguments but 4 were given` ou équivalent). - [ ] **Step 3: Modifier `create_subscription_session`** Dans `src/subscriptions/client.py`, remplacer : ```python def create_subscription_session( plan_handle: str, accept_url: str, cancel_url: str, no_trial: bool = False, customer_handle: str | None = None, create_customer: dict | None = None, ) -> str: body: dict = { "plan": plan_handle, "signup_method": "link", "generate_handle": True, "accept_url": accept_url, "cancel_url": cancel_url, } ``` par : ```python def create_subscription_session( plan_handle: str, handle: str, accept_url: str, cancel_url: str, no_trial: bool = False, customer_handle: str | None = None, create_customer: dict | None = None, ) -> str: body: dict = { "plan": plan_handle, "signup_method": "link", "handle": handle, "accept_url": accept_url, "cancel_url": cancel_url, } ``` - [ ] **Step 4: Lancer les tests, vérifier qu'ils passent** Run: `uv run pytest tests/subscriptions/test_client.py -v` Expected: PASS (7 tests). - [ ] **Step 5: Commit** ```bash git add src/subscriptions/client.py tests/subscriptions/test_client.py git commit -m "feat(subscriptions): create_subscription_session prend le handle en paramètre" ``` --- ## Task 3: `routes.py` — `subscribe()` **Files:** - Modify: `src/subscriptions/routes.py:17-84` - Test: `tests/subscriptions/test_routes.py` **Interfaces:** - Consumes : `db.create_pending` → `(handle, subscription_id)` (Task 1), `db.mark_failed(subscription_id)` (Task 1), `client.create_subscription_session(plan_handle, handle, ...)` (Task 2). - [ ] **Step 1: Mettre à jour les tests de `subscribe()` dans `tests/subscriptions/test_routes.py`** Remplacer les fonctions suivantes (en tête du fichier, après les imports et `_sign`) : ```python def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): client, uid = logged_in_client monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {}) monkeypatch.setattr( frisbii_client, "create_subscription_session", lambda plan, ok, ko, no_trial=False, customer_handle=None, create_customer=None: "https://pay.test/cs_1", ) resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) assert resp.status_code == 303 assert resp.headers["Location"] == "https://pay.test/cs_1" 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é (abonnement maintenant expiré). db.create_pending(uid, "colibre-%d" % uid, "simple") db.update_from_webhook( "colibre-%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( "colibre-%d" % uid, "sub_42", "expired", "2020-01-01T00:00:00+00:00" ) captured = {} monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {}) def fake_session( plan, ok, ko, no_trial=False, customer_handle=None, create_customer=None ): 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"}) assert resp.status_code == 400 def test_subscribe_api_error_redirects_with_error(logged_in_client, monkeypatch): client, _ = logged_in_client def boom(h, d): raise frisbii_client.FrisbiiError(500, "boom") monkeypatch.setattr(frisbii_client, "update_customer", boom) resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) assert resp.status_code == 302 assert "error=frisbii" in resp.headers["Location"] def test_subscribe_requires_login(sub_app): resp = sub_app.test_client().post( "/subscriptions/subscribe", data={"plan": "simple"} ) assert resp.status_code in (302, 401) ``` par : ```python def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): client, uid = logged_in_client monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {}) monkeypatch.setattr( frisbii_client, "create_subscription_session", lambda plan, handle, ok, ko, no_trial=False, customer_handle=None, create_customer=None: "https://pay.test/cs_1", ) resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) assert resp.status_code == 303 assert resp.headers["Location"] == "https://pay.test/cs_1" row = db.get_current(uid) assert row["status"] == "pending" assert row["frisbii_subscription_handle"] == f"abo-{uid}-1" 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é (abonnement maintenant expiré). handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple") db.update_from_webhook(handle, "trial", "2099-01-01T00:00:00+00:00") # L'essai est terminé : abonnement expiré, trial_used reste à 1. db.update_from_webhook(handle, "expired", "2020-01-01T00:00:00+00:00") captured = {} monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {}) def fake_session( plan, handle, ok, ko, no_trial=False, customer_handle=None, create_customer=None ): 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"}) assert resp.status_code == 400 def test_subscribe_api_error_marks_failed_and_redirects(logged_in_client, monkeypatch): client, uid = logged_in_client def boom(h, d): raise frisbii_client.FrisbiiError(500, "boom") monkeypatch.setattr(frisbii_client, "update_customer", boom) resp = client.post("/subscriptions/subscribe", data={"plan": "simple"}) assert resp.status_code == 302 assert "error=frisbii" in resp.headers["Location"] row = db.get_current(uid) assert row["status"] == "failed" def test_subscribe_requires_login(sub_app): resp = sub_app.test_client().post( "/subscriptions/subscribe", data={"plan": "simple"} ) assert resp.status_code in (302, 401) ``` Aussi, plus bas dans le fichier, remplacer : ```python 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, "colibre-%d" % uid, "simple") db.update_from_webhook( "colibre-%d" % uid, "sub_42", "active", "2099-01-01T00:00:00+00:00" ) called = [] monkeypatch.setattr( frisbii_client, "update_customer", lambda h, d: 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, "colibre-%d" % uid, "simple") db.update_from_webhook( "colibre-%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" ``` par : ```python 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 handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple") db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00") called = [] monkeypatch.setattr( frisbii_client, "update_customer", lambda h, d: 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_current(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 handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple") db.update_from_webhook(handle, "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_current(uid)["status"] == "trial" ``` - [ ] **Step 2: Lancer les tests concernés, vérifier qu'ils échouent** Run: `uv run pytest tests/subscriptions/test_routes.py -k subscribe -v` Expected: FAIL (signatures incompatibles / `db.get_by_user` n'existe plus). - [ ] **Step 3: Réécrire `subscribe()` dans `src/subscriptions/routes.py`** Remplacer : ```python @subscriptions_bp.route("/subscriptions/subscribe", methods=["POST"]) @login_required def subscribe(): plan_key = request.form.get("plan") or "" handle = plans.resolve_handle(plan_key) if handle is None: 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: meta = plans.plan_meta(plan_key) db.create_pending( current_user.id, cust, plan_key, meta["prix_ht"] if meta else None ) no_trial = db.has_used_trial(current_user.id) siret = (request.form.get("siret") or "").strip() billing: dict = { "email": current_user.email, "first_name": request.form.get("first_name", ""), "last_name": request.form.get("last_name", ""), "address": request.form.get("address", ""), "city": request.form.get("city", ""), "postal_code": request.form.get("postal_code", ""), "country": request.form.get("country", "FR"), } if request.form.get("address2"): billing["address2"] = request.form["address2"] if request.form.get("company"): billing["company"] = request.form["company"] if siret: auth_db.set_siret(current_user.id, siret) customer_exists = True try: client.update_customer(cust, billing) except client.FrisbiiError as exc: if exc.status_code != 404: raise customer_exists = False if customer_exists: url = client.create_subscription_session( handle, f"{base}/compte/abonnement?paiement=succes", f"{base}/compte/abonnement?paiement=annule", customer_handle=cust, no_trial=no_trial, ) else: create_customer = {"handle": cust, **billing} if siret: create_customer["metadata"] = {"siret": siret} url = client.create_subscription_session( handle, f"{base}/compte/abonnement?paiement=succes", f"{base}/compte/abonnement?paiement=annule", create_customer=create_customer, no_trial=no_trial, ) except client.FrisbiiError: logger.exception("Échec de création de session d'abonnement Frisbii") return redirect("/compte/abonnement?error=frisbii") return redirect(url, code=303) ``` par : ```python @subscriptions_bp.route("/subscriptions/subscribe", methods=["POST"]) @login_required def subscribe(): plan_key = request.form.get("plan") or "" plan_handle = plans.resolve_handle(plan_key) if plan_handle is None: 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) meta = plans.plan_meta(plan_key) sub_handle, subscription_id = db.create_pending( current_user.id, cust, plan_key, meta["prix_ht"] if meta else None ) try: no_trial = db.has_used_trial(current_user.id) siret = (request.form.get("siret") or "").strip() billing: dict = { "email": current_user.email, "first_name": request.form.get("first_name", ""), "last_name": request.form.get("last_name", ""), "address": request.form.get("address", ""), "city": request.form.get("city", ""), "postal_code": request.form.get("postal_code", ""), "country": request.form.get("country", "FR"), } if request.form.get("address2"): billing["address2"] = request.form["address2"] if request.form.get("company"): billing["company"] = request.form["company"] if siret: auth_db.set_siret(current_user.id, siret) customer_exists = True try: client.update_customer(cust, billing) except client.FrisbiiError as exc: if exc.status_code != 404: raise customer_exists = False if customer_exists: url = client.create_subscription_session( plan_handle, sub_handle, f"{base}/compte/abonnement?paiement=succes", f"{base}/compte/abonnement?paiement=annule", customer_handle=cust, no_trial=no_trial, ) else: create_customer = {"handle": cust, **billing} if siret: create_customer["metadata"] = {"siret": siret} url = client.create_subscription_session( plan_handle, sub_handle, f"{base}/compte/abonnement?paiement=succes", f"{base}/compte/abonnement?paiement=annule", create_customer=create_customer, no_trial=no_trial, ) except client.FrisbiiError: logger.exception("Échec de création de session d'abonnement Frisbii") db.mark_failed(subscription_id) return redirect("/compte/abonnement?error=frisbii") return redirect(url, code=303) ``` - [ ] **Step 4: Lancer les tests, vérifier qu'ils passent** Run: `uv run pytest tests/subscriptions/test_routes.py -k subscribe -v` Expected: PASS (7 tests). - [ ] **Step 5: Commit** ```bash git add src/subscriptions/routes.py tests/subscriptions/test_routes.py git commit -m "feat(subscriptions): subscribe() génère et transmet le handle, mark_failed en cas d'échec" ``` --- ## Task 4: `routes.py` — `cancel()`, `webhook()`, `add_payment_callback()` **Files:** - Modify: `src/subscriptions/routes.py:104-163` - Test: `tests/subscriptions/test_routes.py` **Interfaces:** - Consumes : `db.get_current` (Task 1), `db.set_cancelled(subscription_id, ...)` (Task 1), `db.customer_known` (Task 1), `db.update_from_webhook(subscription_handle, status, current_period_end)` (Task 1). - [ ] **Step 1: Mettre à jour les tests concernés dans `tests/subscriptions/test_routes.py`** Remplacer : ```python def test_cancel_calls_api_and_marks_cancelled(logged_in_client, monkeypatch): client, uid = logged_in_client db.create_pending(uid, "colibre-%d" % uid, "simple") db.update_from_webhook( "colibre-%d" % uid, "sub_42", "active", "2099-01-01T00:00:00+00:00" ) monkeypatch.setattr( frisbii_client, "cancel_subscription", lambda handle: {"expires": "2099-02-01T00:00:00+00:00"}, ) resp = client.post("/subscriptions/cancel") assert resp.status_code == 302 assert "resiliation=ok" in resp.headers["Location"] row = db.get_by_user(uid) assert row["status"] == "cancelled" assert row["current_period_end"] == "2099-02-01T00:00:00+00:00" def test_webhook_invalid_signature(sub_app): resp = sub_app.test_client().post( "/frisbii/webhook", json={"id": "e", "signature": "x"} ) assert resp.status_code == 403 def test_webhook_updates_subscription(sub_app, monkeypatch): from src.auth import db as auth_db auth_db.init_schema() uid = auth_db.create_user("wh@ex.fr", "hash") db.create_pending(uid, "colibre-%d" % uid, "simple") monkeypatch.setattr( frisbii_client, "get_subscription", lambda h: {"state": "active", "next_period_start": "2099-01-01T00:00:00+00:00"}, ) payload = { "id": "evt_1", "timestamp": "2026-06-25T10:00:00Z", "event_type": "subscription_created", "customer": "colibre-%d" % uid, "subscription": "sub_42", } payload["signature"] = _sign("s3cr3t", payload["timestamp"], payload["id"]) resp = sub_app.test_client().post("/frisbii/webhook", json=payload) assert resp.status_code == 200 row = db.get_by_user(uid) assert row["status"] == "active" assert row["frisbii_subscription_handle"] == "sub_42" ``` par : ```python def test_cancel_calls_api_and_marks_cancelled(logged_in_client, monkeypatch): client, uid = logged_in_client handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple") db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00") monkeypatch.setattr( frisbii_client, "cancel_subscription", lambda handle: {"expires": "2099-02-01T00:00:00+00:00"}, ) resp = client.post("/subscriptions/cancel") assert resp.status_code == 302 assert "resiliation=ok" in resp.headers["Location"] row = db.get_current(uid) assert row["status"] == "cancelled" assert row["current_period_end"] == "2099-02-01T00:00:00+00:00" def test_webhook_invalid_signature(sub_app): resp = sub_app.test_client().post( "/frisbii/webhook", json={"id": "e", "signature": "x"} ) assert resp.status_code == 403 def test_webhook_updates_subscription(sub_app, monkeypatch): from src.auth import db as auth_db auth_db.init_schema() uid = auth_db.create_user("wh@ex.fr", "hash") handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple") monkeypatch.setattr( frisbii_client, "get_subscription", lambda h: {"state": "active", "next_period_start": "2099-01-01T00:00:00+00:00"}, ) payload = { "id": "evt_1", "timestamp": "2026-06-25T10:00:00Z", "event_type": "subscription_created", "customer": "colibre-%d" % uid, "subscription": handle, } payload["signature"] = _sign("s3cr3t", payload["timestamp"], payload["id"]) resp = sub_app.test_client().post("/frisbii/webhook", json=payload) assert resp.status_code == 200 row = db.get_current(uid) assert row["status"] == "active" assert row["frisbii_subscription_handle"] == handle ``` - [ ] **Step 2: Lancer les tests concernés, vérifier qu'ils échouent** Run: `uv run pytest tests/subscriptions/test_routes.py -k "cancel or webhook" -v` Expected: FAIL. - [ ] **Step 3: Réécrire `add_payment_callback()`, `cancel()` et `webhook()` dans `src/subscriptions/routes.py`** Dans `add_payment_callback()`, remplacer : ```python row = db.get_by_user(current_user.id) ``` par : ```python row = db.get_current(current_user.id) ``` Remplacer : ```python @subscriptions_bp.route("/subscriptions/cancel", methods=["POST"]) @login_required def cancel(): row = db.get_by_user(current_user.id) if row is None or not row["frisbii_subscription_handle"]: return "Aucun abonnement à résilier", 400 try: sub = client.cancel_subscription(row["frisbii_subscription_handle"]) except client.FrisbiiError: logger.exception("Échec de résiliation Frisbii") return redirect("/compte/abonnement?error=frisbii") db.set_cancelled(current_user.id, sub.get("expires")) return redirect("/compte/abonnement?resiliation=ok") @subscriptions_bp.route("/frisbii/webhook", methods=["POST"]) def webhook(): payload = request.get_json(silent=True) or {} if not webhooks.verify_signature(payload, os.getenv("FRISBII_WEBHOOK_SECRET", "")): return "", 403 customer = payload.get("customer") sub_handle = payload.get("subscription") if not customer or db.get_by_customer(customer) is None: return "", 200 # rien à rapprocher try: sub = client.get_subscription(sub_handle) if sub_handle else None except client.FrisbiiError: logger.exception("Webhook : lecture de l'abonnement Frisbii impossible") return "", 502 # Frisbii réessaiera if sub is None: return "", 200 status, current_period_end = webhooks.map_subscription(sub) db.update_from_webhook(customer, sub_handle, status, current_period_end) return "", 200 ``` par : ```python @subscriptions_bp.route("/subscriptions/cancel", methods=["POST"]) @login_required def cancel(): row = db.get_current(current_user.id) if row is None or not row["frisbii_subscription_handle"]: return "Aucun abonnement à résilier", 400 try: sub = client.cancel_subscription(row["frisbii_subscription_handle"]) except client.FrisbiiError: logger.exception("Échec de résiliation Frisbii") return redirect("/compte/abonnement?error=frisbii") db.set_cancelled(row["id"], sub.get("expires")) return redirect("/compte/abonnement?resiliation=ok") @subscriptions_bp.route("/frisbii/webhook", methods=["POST"]) def webhook(): payload = request.get_json(silent=True) or {} if not webhooks.verify_signature(payload, os.getenv("FRISBII_WEBHOOK_SECRET", "")): return "", 403 customer = payload.get("customer") sub_handle = payload.get("subscription") if not customer or not db.customer_known(customer): return "", 200 # rien à rapprocher try: sub = client.get_subscription(sub_handle) if sub_handle else None except client.FrisbiiError: logger.exception("Webhook : lecture de l'abonnement Frisbii impossible") return "", 502 # Frisbii réessaiera if sub is None: return "", 200 status, current_period_end = webhooks.map_subscription(sub) db.update_from_webhook(sub_handle, status, current_period_end) return "", 200 ``` - [ ] **Step 4: Lancer les tests, vérifier qu'ils passent** Run: `uv run pytest tests/subscriptions/test_routes.py -v` Expected: PASS (tous les tests du fichier, 11 tests). - [ ] **Step 5: Commit** ```bash git add src/subscriptions/routes.py tests/subscriptions/test_routes.py git commit -m "feat(subscriptions): cancel/webhook/add_payment_callback sur get_current et customer_known" ``` --- ## Task 5: `compte_abonnement.py` — statut `failed` ne bloque plus la souscription **Files:** - Modify: `src/pages/compte_abonnement.py:297-321` - Test: `tests/subscriptions/test_compte_abonnement.py` **Interfaces:** - Consumes : `db.get_current` (Task 1). - Produces : `_show_active_view(row) -> bool` (utilisée par `layout()`). - [ ] **Step 1: Écrire les tests (ils échoueront : `_show_active_view` n'existe pas encore)** Ajouter à la fin de `tests/subscriptions/test_compte_abonnement.py` : ```python def test_show_active_view_true_for_live_statuses(monkeypatch): from src.pages import compte_abonnement for status in ("pending", "trial", "active", "cancelled", "expired"): assert compte_abonnement._show_active_view({"status": status}) is True def test_show_active_view_false_for_failed_or_none(monkeypatch): from src.pages import compte_abonnement assert compte_abonnement._show_active_view({"status": "failed"}) is False assert compte_abonnement._show_active_view(None) is False ``` - [ ] **Step 2: Lancer les tests, vérifier qu'ils échouent** Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v` Expected: FAIL (`AttributeError: module 'src.pages.compte_abonnement' has no attribute '_show_active_view'`). - [ ] **Step 3: Ajouter `_show_active_view` et l'utiliser dans `layout()`** Dans `src/pages/compte_abonnement.py`, juste avant `def layout(**query):`, ajouter : ```python def _show_active_view(row) -> bool: return row is not None and row["status"] != "failed" ``` Puis remplacer, dans `layout()` : ```python row = db.get_by_user(current_user.id) if current_user.is_authenticated else None trial_used = ( db.has_used_trial(current_user.id) if current_user.is_authenticated else False ) body = [html.H2("Abonnement", className="mb-4")] banner = _tous_abonnes_banner() if banner is not None: body.append(banner) body.extend(_feedback(query)) if row is not None: body.append(_active_view(row)) body.append(_resiliation_modal(row["current_period_end"])) else: body.extend([_plan_cards(trial_used=trial_used), _explainer()]) ``` par : ```python row = db.get_current(current_user.id) if current_user.is_authenticated else None trial_used = ( db.has_used_trial(current_user.id) if current_user.is_authenticated else False ) body = [html.H2("Abonnement", className="mb-4")] banner = _tous_abonnes_banner() if banner is not None: body.append(banner) body.extend(_feedback(query)) if _show_active_view(row): body.append(_active_view(row)) body.append(_resiliation_modal(row["current_period_end"])) else: body.extend([_plan_cards(trial_used=trial_used), _explainer()]) ``` - [ ] **Step 4: Lancer les tests, vérifier qu'ils passent** Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v` Expected: PASS (10 tests). - [ ] **Step 5: Commit** ```bash git add src/pages/compte_abonnement.py tests/subscriptions/test_compte_abonnement.py git commit -m "fix(subscriptions): un abonnement en échec (failed) ne bloque plus le réabonnement" ``` --- ## Task 6: Vérification finale **Files:** aucun changement, exécution seule. - [ ] **Step 1: Lancer toute la suite de tests** Run: `uv run pytest` Expected: tous les tests passent (aucune régression sur les autres modules — `compte_roadmap.py`, `_compte_shell.py`, `auth/routes.py` n'appellent que `credit_pending`/`next_recharge_at`/`spend_vote`/`has_active_subscription`, dont les signatures n'ont pas changé). - [ ] **Step 2: Vérifier qu'aucune référence à l'ancienne API ne subsiste** Run: `grep -rn "get_by_user\|get_by_customer" src/ tests/ --include=*.py` Expected: aucune occurrence (en dehors, éventuellement, de code non lié à `subscriptions` qui définirait une fonction homonyme dans un autre module — vérifier au cas par cas si le grep remonte autre chose que `src/subscriptions/` ou `tests/subscriptions/`). - [ ] **Step 3: Commit final si des ajustements ont été nécessaires** ```bash git add -A git status # Si des fichiers sont en attente suite à des corrections des steps # précédents : git commit -m "chore(subscriptions): vérification finale historique + handle abo" ```