feat(abonnement): routes subscribe/cancel/webhook + câblage app (#90)
This commit is contained in:
@@ -56,3 +56,10 @@ S3_SECRET_ACCESS_KEY=
|
|||||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
# À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables.
|
# À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables.
|
||||||
BACKUP_ENCRYPTION_KEY=
|
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
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready
|
|||||||
|
|
||||||
init_api(app.server)
|
init_api(app.server)
|
||||||
|
|
||||||
|
from src.subscriptions.setup import init_subscriptions # noqa: E402
|
||||||
|
|
||||||
|
init_subscriptions(app.server)
|
||||||
|
|
||||||
|
|
||||||
# robots.txt
|
# robots.txt
|
||||||
@app.server.route("/robots.txt")
|
@app.server.route("/robots.txt")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -45,3 +45,36 @@ def fake_httpx(monkeypatch):
|
|||||||
monkeypatch.setenv("FRISBII_API_BASE_URL", "https://api.test")
|
monkeypatch.setenv("FRISBII_API_BASE_URL", "https://api.test")
|
||||||
monkeypatch.setattr(client.httpx, "request", _fake_request)
|
monkeypatch.setattr(client.httpx, "request", _fake_request)
|
||||||
return {"calls": calls, "queue": queue, "Response": FakeResponse}
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user