From b04f97cb8cc892749bb11a1e11ac438a6531741f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sun, 5 Jul 2026 20:31:42 +0200 Subject: [PATCH] feat(abonnement): route /subscriptions/update (maj formule + facturation) (#109) Co-Authored-By: Claude Sonnet 5 --- src/subscriptions/routes.py | 43 +++++++++++++++ tests/subscriptions/test_routes.py | 89 ++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/src/subscriptions/routes.py b/src/subscriptions/routes.py index 4b0a9fe..dae74d3 100644 --- a/src/subscriptions/routes.py +++ b/src/subscriptions/routes.py @@ -148,6 +148,49 @@ def change_payment_method(): return redirect(url, code=303) +@subscriptions_bp.route("/subscriptions/update", methods=["POST"]) +@login_required +def update(): + new_plan_key = request.form.get("plan") or "" + new_handle = plans.resolve_handle(new_plan_key) + if new_handle is None: + return "Plan inconnu", 400 + + row = db.get_current(current_user.id) + if row is None or not row["frisbii_subscription_handle"]: + return "Aucun abonnement à mettre à jour", 400 + + cust = _customer_handle(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"] + + try: + if siret: + auth_db.set_siret(current_user.id, siret) + client.update_customer(cust, billing) + if new_handle != plans.resolve_handle(row["plan"]): + timing = "immediate" if row["status"] == "pending" else "renewal" + client.change_subscription( + row["frisbii_subscription_handle"], new_handle, timing + ) + except client.FrisbiiError: + logger.exception("Échec de mise à jour de l'abonnement Frisbii") + return redirect("/compte/abonnement?error=frisbii") + return redirect("/compte/abonnement?maj=succes", code=303) + + @subscriptions_bp.route("/subscriptions/cancel", methods=["POST"]) @login_required def cancel(): diff --git a/tests/subscriptions/test_routes.py b/tests/subscriptions/test_routes.py index dca30f8..37e2057 100644 --- a/tests/subscriptions/test_routes.py +++ b/tests/subscriptions/test_routes.py @@ -208,3 +208,92 @@ def test_change_payment_method_api_error_redirects(logged_in_client, monkeypatch def test_change_payment_method_requires_login(sub_app): resp = sub_app.test_client().post("/subscriptions/change-payment-method") assert resp.status_code in (302, 401) + + +def test_update_changes_plan_and_redirects(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, "update_customer", lambda h, d: {}) + captured = {} + + def fake_change(sub_handle, plan_handle, timing="renewal"): + captured.update(sub_handle=sub_handle, plan_handle=plan_handle, timing=timing) + return {} + + monkeypatch.setattr(frisbii_client, "change_subscription", fake_change) + resp = client.post("/subscriptions/update", data={"plan": "soutien"}) + assert resp.status_code == 303 + assert "maj=succes" in resp.headers["Location"] + assert captured["sub_handle"] == handle + assert captured["plan_handle"] == "plan_soutien" + assert captured["timing"] == "renewal" + + +def test_update_pending_uses_immediate_timing(logged_in_client, monkeypatch): + client, uid = logged_in_client + handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple") + monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {}) + captured = {} + monkeypatch.setattr( + frisbii_client, + "change_subscription", + lambda sub_handle, plan_handle, timing="renewal": captured.update( + timing=timing + ), + ) + resp = client.post("/subscriptions/update", data={"plan": "soutien"}) + assert resp.status_code == 303 + assert captured["timing"] == "immediate" + + +def test_update_same_plan_skips_change_subscription(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") + called = [] + monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {}) + monkeypatch.setattr( + frisbii_client, + "change_subscription", + lambda *a, **k: called.append(a), + ) + resp = client.post("/subscriptions/update", data={"plan": "simple"}) + assert resp.status_code == 303 + assert "maj=succes" in resp.headers["Location"] + assert called == [], ( + "formule inchangée : change_subscription ne doit pas être appelé" + ) + + +def test_update_without_subscription_returns_400(logged_in_client): + client, _ = logged_in_client + resp = client.post("/subscriptions/update", data={"plan": "simple"}) + assert resp.status_code == 400 + + +def test_update_unknown_plan_returns_400(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") + resp = client.post("/subscriptions/update", data={"plan": "bidon"}) + assert resp.status_code == 400 + + +def test_update_api_error_redirects(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") + + def boom(h, d): + raise frisbii_client.FrisbiiError(500, "boom") + + monkeypatch.setattr(frisbii_client, "update_customer", boom) + resp = client.post("/subscriptions/update", data={"plan": "soutien"}) + assert resp.status_code == 302 + assert "error=frisbii" in resp.headers["Location"] + + +def test_update_requires_login(sub_app): + resp = sub_app.test_client().post("/subscriptions/update", data={"plan": "simple"}) + assert resp.status_code in (302, 401)