From b7a79d8d86286aa296392092307a48de36efda74 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 25 Jun 2026 18:50:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(abonnement):=20table=20subscriptions=20+?= =?UTF-8?q?=20=C3=A9tat=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/subscriptions/db.py | 111 ++++++++++++++++++++++++++++++++ tests/subscriptions/conftest.py | 11 ++++ tests/subscriptions/test_db.py | 92 ++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/subscriptions/db.py create mode 100644 tests/subscriptions/test_db.py diff --git a/src/subscriptions/db.py b/src/subscriptions/db.py new file mode 100644 index 0000000..d7611a6 --- /dev/null +++ b/src/subscriptions/db.py @@ -0,0 +1,111 @@ +import sqlite3 +from datetime import datetime, timezone + +from src.auth.db import get_conn + +SUBSCRIPTIONS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS subscriptions ( + user_id INTEGER PRIMARY KEY, + frisbii_customer_handle TEXT, + frisbii_subscription_handle TEXT, + 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 +); +CREATE INDEX IF NOT EXISTS idx_subscriptions_customer + ON subscriptions(frisbii_customer_handle); +""" + +_ACCESS_STATUSES = ("trial", "active") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def init_schema() -> None: + get_conn().executescript(SUBSCRIPTIONS_SCHEMA) + + +def create_pending(user_id: int, customer_handle: str, plan: str) -> None: + now = _now() + get_conn().execute( + "INSERT INTO subscriptions " + "(user_id, frisbii_customer_handle, plan, status, created_at, updated_at) " + "VALUES (?, ?, ?, 'pending', ?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET " + "frisbii_customer_handle=excluded.frisbii_customer_handle, " + "plan=excluded.plan, status='pending', updated_at=excluded.updated_at", + (user_id, customer_handle, plan, now, now), + ) + + +def get_by_user(user_id: int) -> sqlite3.Row | None: + return ( + get_conn() + .execute("SELECT * FROM subscriptions WHERE user_id = ?", (user_id,)) + .fetchone() + ) + + +def get_by_customer(customer_handle: str) -> sqlite3.Row | None: + return ( + get_conn() + .execute( + "SELECT * FROM subscriptions WHERE frisbii_customer_handle = ?", + (customer_handle,), + ) + .fetchone() + ) + + +def update_from_webhook( + customer_handle: str, + subscription_handle: str | None, + 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 = ?, " + "trial_used = max(trial_used, ?), updated_at = ? " + "WHERE frisbii_customer_handle = ?", + ( + subscription_handle, + status, + current_period_end, + trial_flag, + _now(), + customer_handle, + ), + ) + + +def set_cancelled(user_id: int, current_period_end: str | None) -> None: + get_conn().execute( + "UPDATE subscriptions SET status = 'cancelled', current_period_end = ?, " + "updated_at = ? WHERE user_id = ?", + (current_period_end, _now(), user_id), + ) + + +def has_active_subscription(user_id: int) -> bool: + row = get_by_user(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"]: + 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"]) diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py index a3d4ad1..104c6d8 100644 --- a/tests/subscriptions/conftest.py +++ b/tests/subscriptions/conftest.py @@ -16,6 +16,17 @@ class FakeResponse: return _json.dumps(self._payload) +@pytest.fixture +def users_db_path(monkeypatch, tmp_path): + from src.auth.db import reset_conn_for_tests + + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + reset_conn_for_tests() + yield db_path + reset_conn_for_tests() + + @pytest.fixture def fake_httpx(monkeypatch): """Capture les appels httpx.request du client Frisbii et renvoie des réponses programmées.""" diff --git a/tests/subscriptions/test_db.py b/tests/subscriptions/test_db.py new file mode 100644 index 0000000..e4e9a82 --- /dev/null +++ b/tests/subscriptions/test_db.py @@ -0,0 +1,92 @@ +from datetime import datetime, timedelta, timezone + +from src.auth import db as auth_db +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 test_init_schema_creates_table(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 + + +def test_create_pending_and_get_by_user(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + row = db.get_by_user(uid) + assert row["status"] == "pending" + assert row["plan"] == "simple" + assert row["frisbii_customer_handle"] == "decpinfo-1" + + +def test_update_from_webhook_sets_status_and_handle(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + db.update_from_webhook("decpinfo-1", "sub_42", "trial", _future()) + row = db.get_by_user(uid) + assert row["status"] == "trial" + assert row["frisbii_subscription_handle"] == "sub_42" + assert db.get_by_customer("decpinfo-1")["user_id"] == uid + + +def test_has_active_subscription_by_status(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + # pending → faux + assert db.has_active_subscription(uid) is False + for status in ("trial", "active"): + db.update_from_webhook("decpinfo-1", "sub_42", status, _future()) + assert db.has_active_subscription(uid) is True + # cancelled futur → vrai, cancelled passé → faux + db.update_from_webhook("decpinfo-1", "sub_42", "cancelled", _future()) + assert db.has_active_subscription(uid) is True + db.update_from_webhook("decpinfo-1", "sub_42", "cancelled", _past()) + assert db.has_active_subscription(uid) is False + db.update_from_webhook("decpinfo-1", "sub_42", "expired", None) + assert db.has_active_subscription(uid) is False + + +def test_set_cancelled(users_db_path): + db.init_schema() + uid = _make_user() + db.create_pending(uid, "decpinfo-1", "simple") + db.update_from_webhook("decpinfo-1", "sub_42", "active", _future()) + end = _future() + db.set_cancelled(uid, end) + 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