diff --git a/.template.env b/.template.env index 31b147b..1c6a333 100644 --- a/.template.env +++ b/.template.env @@ -56,3 +56,10 @@ S3_SECRET_ACCESS_KEY= # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables. BACKUP_ENCRYPTION_KEY= + +# Frisbii — gestion des abonnements (https://docs.frisbii.com) +FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement +FRISBII_API_BASE_URL= # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer) +FRISBII_PLAN_SIMPLE= # handle du plan "abonnement simple" (20 € HT/mois) +FRISBII_PLAN_SOUTIEN= # handle du plan "abonnement de soutien" (50 € HT/mois) +FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks diff --git a/src/app.py b/src/app.py index a2b5f72..b3bc72f 100644 --- a/src/app.py +++ b/src/app.py @@ -90,6 +90,10 @@ from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready init_api(app.server) +from src.subscriptions.setup import init_subscriptions # noqa: E402 + +init_subscriptions(app.server) + # robots.txt @app.server.route("/robots.txt") diff --git a/src/subscriptions/routes.py b/src/subscriptions/routes.py new file mode 100644 index 0000000..df66a0b --- /dev/null +++ b/src/subscriptions/routes.py @@ -0,0 +1,78 @@ +import os + +from flask import Blueprint, redirect, request +from flask_login import current_user, login_required + +from src.subscriptions import client, db, plans, webhooks +from src.utils import logger + +subscriptions_bp = Blueprint("subscriptions", __name__) + + +def _customer_handle(user_id: int) -> str: + return f"decpinfo-{user_id}" + + +@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", "") + cust = _customer_handle(current_user.id) + 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") + return redirect("/compte/abonnement?error=frisbii") + return redirect(url, code=303) + + +@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 diff --git a/src/subscriptions/setup.py b/src/subscriptions/setup.py new file mode 100644 index 0000000..2f77e22 --- /dev/null +++ b/src/subscriptions/setup.py @@ -0,0 +1,35 @@ +import os + +from flask import Flask + +from src.subscriptions import db +from src.utils import logger + +_REQUIRED_ENV = ( + "FRISBII_API_KEY", + "FRISBII_WEBHOOK_SECRET", + "FRISBII_PLAN_SIMPLE", + "FRISBII_PLAN_SOUTIEN", +) + + +def init_subscriptions(app: Flask) -> None: + db.init_schema() + + from src.subscriptions.routes import subscriptions_bp, webhook + + app.register_blueprint(subscriptions_bp) + + # Le webhook reçoit des POST externes : pas de jeton CSRF possible. + from src.auth.setup import _csrf as _auth_csrf + + if _auth_csrf is not None: + _auth_csrf.exempt(webhook) # exempte la seule vue webhook + + missing = [name for name in _REQUIRED_ENV if not os.getenv(name)] + if missing: + logger.warning( + "Variables Frisbii manquantes (%s) : les abonnements échoueront. " + "Définissez-les dans .env (voir .template.env).", + ", ".join(missing), + ) diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py index 104c6d8..8b7ed2f 100644 --- a/tests/subscriptions/conftest.py +++ b/tests/subscriptions/conftest.py @@ -45,3 +45,36 @@ def fake_httpx(monkeypatch): monkeypatch.setenv("FRISBII_API_BASE_URL", "https://api.test") monkeypatch.setattr(client.httpx, "request", _fake_request) return {"calls": calls, "queue": queue, "Response": FakeResponse} + + +@pytest.fixture +def sub_app(users_db_path, monkeypatch): + from flask import Flask + + from src.auth.setup import init_auth + from src.subscriptions.setup import init_subscriptions + + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t") + app = Flask(__name__) + app.config["WTF_CSRF_ENABLED"] = False + init_auth(app) + init_subscriptions(app) + return app + + +@pytest.fixture +def logged_in_client(sub_app): + from src.auth import db as auth_db + + auth_db.init_schema() + uid = auth_db.create_user("sub@ex.fr", "hash") + auth_db.set_email_verified(uid) + client = sub_app.test_client() + with client.session_transaction() as sess: + sess["_user_id"] = str(uid) + sess["_fresh"] = True + return client, uid diff --git a/tests/subscriptions/test_routes.py b/tests/subscriptions/test_routes.py new file mode 100644 index 0000000..855dac0 --- /dev/null +++ b/tests/subscriptions/test_routes.py @@ -0,0 +1,126 @@ +import hashlib +import hmac + +from src.subscriptions import client as frisbii_client +from src.subscriptions import db + + +def _sign(secret, timestamp, event_id): + return hmac.new( + secret.encode(), (timestamp + event_id).encode(), hashlib.sha256 + ).hexdigest() + + +def test_subscribe_redirects_to_hosted_url(logged_in_client, monkeypatch): + client, uid = logged_in_client + monkeypatch.setattr( + frisbii_client, "get_or_create_customer", lambda h, e: {"handle": h} + ) + monkeypatch.setattr( + frisbii_client, + "create_subscription_session", + 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 + 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é. + 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"}) + assert resp.status_code == 400 + + +def test_subscribe_api_error_redirects_with_error(logged_in_client, monkeypatch): + client, _ = logged_in_client + + def boom(h, e): + raise frisbii_client.FrisbiiError(500, "boom") + + monkeypatch.setattr(frisbii_client, "get_or_create_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) + + +def test_cancel_calls_api_and_marks_cancelled(logged_in_client, monkeypatch): + client, uid = logged_in_client + db.create_pending(uid, "decpinfo-%d" % uid, "simple") + db.update_from_webhook( + "decpinfo-%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, "decpinfo-%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": "decpinfo-%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"