diff --git a/src/pages/compte_abonnement.py b/src/pages/compte_abonnement.py index 1bb6903..1a6d26c 100644 --- a/src/pages/compte_abonnement.py +++ b/src/pages/compte_abonnement.py @@ -1,5 +1,5 @@ import dash_bootstrap_components as dbc -from dash import Input, Output, callback, dcc, html, register_page +from dash import Input, Output, State, callback, dcc, html, register_page from flask_login import current_user from src.pages._compte_shell import account_guard, account_shell @@ -130,7 +130,33 @@ def _explainer(): def _active_view(row): meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]} end = row["current_period_end"] - blocks = [html.H3(meta["label"]), html.P(f"Statut : {row['status']}")] + blocks = [html.H3(meta["label"], className="mb-3")] + + if row["status"] == "pending": + blocks.extend( + [ + dbc.Alert( + "Sans méthode de paiement enregistrée, votre abonnement sera " + "résilié à la fin de la période d'essai.", + color="warning", + className="mb-3", + ), + html.Form( + method="POST", + action="/subscriptions/add-payment", + children=[ + _csrf_input(), + html.Button( + "Ajouter une méthode de paiement", + type="submit", + className="btn btn-primary", + ), + ], + ), + ] + ) + return html.Div(blocks) + if row["status"] == "trial": blocks.append( dbc.Alert( @@ -143,22 +169,62 @@ def _active_view(row): ) else: blocks.append(html.P(f"Prochain renouvellement : {end}")) + if row["status"] in ("trial", "active"): blocks.append( - html.Form( - method="POST", - action="/subscriptions/cancel", - children=[ - _csrf_input(), - html.Button( - "Résilier", type="submit", className="btn btn-outline-danger" - ), - ], + html.Button( + "Me désabonner", + id="resiliation-trigger", + n_clicks=0, + className="btn btn-outline-danger mt-3", ) ) + return html.Div(blocks) +def _resiliation_modal(end): + if end: + body_text = ( + f"Êtes-vous sûr de vouloir mettre fin à votre abonnement ? " + f"Vous resterez abonné jusqu'à la date anniversaire, le {end}." + ) + else: + body_text = "Êtes-vous sûr de vouloir mettre fin à votre abonnement ?" + return dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Résilier l'abonnement")), + dbc.ModalBody(body_text), + dbc.ModalFooter( + [ + dbc.Button( + "Annuler", + id="resiliation-annuler", + color="secondary", + className="me-2", + n_clicks=0, + ), + html.Form( + method="POST", + action="/subscriptions/cancel", + children=[ + _csrf_input(), + html.Button( + "Je suis sûr", + type="submit", + className="btn btn-danger", + ), + ], + style={"display": "inline"}, + ), + ] + ), + ], + id="resiliation-modal", + is_open=False, + ) + + def _feedback(query): msgs = { "succes": ( @@ -202,6 +268,17 @@ def _open_salaire_modal(_): return True +@callback( + Output("resiliation-modal", "is_open"), + Input("resiliation-trigger", "n_clicks"), + Input("resiliation-annuler", "n_clicks"), + State("resiliation-modal", "is_open"), + prevent_initial_call=True, +) +def _toggle_resiliation(_, __, is_open): + return not is_open + + def _tous_abonnes_banner(): from src.utils import TOUS_ABONNES @@ -221,12 +298,6 @@ def layout(**query): return guard row = db.get_by_user(current_user.id) if current_user.is_authenticated else None - has_access = ( - db.has_active_subscription(current_user.id) - if current_user.is_authenticated - else False - ) - trial_used = ( db.has_used_trial(current_user.id) if current_user.is_authenticated else False ) @@ -236,8 +307,10 @@ def layout(**query): if banner is not None: body.append(banner) body.extend(_feedback(query)) - if has_access and row is not None: + + 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()]) diff --git a/src/subscriptions/client.py b/src/subscriptions/client.py index 45a2af1..64bc051 100644 --- a/src/subscriptions/client.py +++ b/src/subscriptions/client.py @@ -6,6 +6,7 @@ import httpx # première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur # l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin. _DEFAULT_BASE_URL = "https://api.frisbii.com" +_DEFAULT_CHECKOUT_URL = "https://checkout-api.frisbii.com" _TIMEOUT = 15.0 @@ -24,13 +25,24 @@ def _base_url() -> str: return os.getenv("FRISBII_API_BASE_URL") or _DEFAULT_BASE_URL -def _call(method: str, path: str, json: dict | None = None) -> dict: +def _checkout_url() -> str: + return os.getenv("FRISBII_CHECKOUT_URL") or _DEFAULT_CHECKOUT_URL + + +def _call( + method: str, + path: str, + json: dict | None = None, + base_url: str | None = None, + params: dict | None = None, +) -> dict: try: resp = httpx.request( method, - f"{_base_url()}{path}", + f"{base_url or _base_url()}{path}", auth=(_api_key(), ""), json=json, + params=params, timeout=_TIMEOUT, ) except httpx.RequestError as exc: @@ -74,6 +86,37 @@ def create_subscription_session( return data["hosted_page_links"]["payment_info"] +def create_recurring_session( + customer_handle: str, accept_url: str, cancel_url: str +) -> str: + data = _call( + "POST", + "/v1/session/recurring", + json={ + "customer": customer_handle, + "accept_url": accept_url, + "cancel_url": cancel_url, + "show_terms": False, + }, + base_url=_checkout_url(), + ) + return data["url"] + + +def get_customer_payment_methods( + customer_handle: str, reference: str | None = None +) -> list[dict]: + p: dict = {"customer": customer_handle, "state": "active", "size": 10} + if reference: + p["reference"] = reference + data = _call("GET", "/v1/list/payment_method", params=p) + return data.get("content", []) + + +def set_subscription_payment_method(sub_handle: str, pm_id: str) -> None: + _call("POST", f"/v1/subscription/{sub_handle}/pm", json={"source": pm_id}) + + def cancel_subscription(subscription_handle: str) -> dict: return _call("POST", f"/v1/subscription/{subscription_handle}/cancel") diff --git a/src/subscriptions/routes.py b/src/subscriptions/routes.py index 3e4bb0e..35bf1be 100644 --- a/src/subscriptions/routes.py +++ b/src/subscriptions/routes.py @@ -84,6 +84,48 @@ def subscribe(): return redirect(url, code=303) +@subscriptions_bp.route("/subscriptions/add-payment", methods=["POST"]) +@login_required +def add_payment(): + base = os.getenv("APP_BASE_URL", "") + cust = _customer_handle(current_user.id) + try: + url = client.create_recurring_session( + cust, + f"{base}/subscriptions/add-payment/callback", + f"{base}/compte/abonnement?paiement=annule", + ) + except client.FrisbiiError: + logger.exception("Échec de création de session de paiement Frisbii") + return redirect("/compte/abonnement?error=frisbii") + return redirect(url, code=303) + + +@subscriptions_bp.route("/subscriptions/add-payment/callback") +@login_required +def add_payment_callback(): + session_id = request.args.get("id", "") + cust = _customer_handle(current_user.id) + row = db.get_by_user(current_user.id) + sub_handle = row["frisbii_subscription_handle"] if row else None + if not sub_handle: + return redirect("/compte/abonnement?paiement=succes") + try: + pms = client.get_customer_payment_methods(cust, reference=session_id or None) + if not pms: + pms = client.get_customer_payment_methods(cust) + if not pms: + logger.warning( + "Aucune méthode de paiement trouvée pour le customer %s", cust + ) + return redirect("/compte/abonnement?paiement=succes") + client.set_subscription_payment_method(sub_handle, pms[0]["id"]) + except client.FrisbiiError: + logger.exception("Échec de l'association méthode de paiement / abonnement") + return redirect("/compte/abonnement?error=frisbii") + return redirect("/compte/abonnement?paiement=succes") + + @subscriptions_bp.route("/subscriptions/cancel", methods=["POST"]) @login_required def cancel():