Gestion abonnement existant : ajout PM et résiliation avec confirmation #90
- Page /compte/abonnement affiche la vue de gestion si une subscription existe (pending, trial, active, cancelled), pas les offres
- Status pending : alerte + bouton "Ajouter une méthode de paiement"
- Status trial/active : bouton "Me désabonner" ouvrant une modale de confirmation
- Route add-payment crée une recurring session Frisbii (checkout-api.frisbii.com)
- Callback add-payment/callback récupère l'id du PM créé et l'associe à la subscription via POST /v1/subscription/{handle}/pm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import dash_bootstrap_components as dbc
|
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 flask_login import current_user
|
||||||
|
|
||||||
from src.pages._compte_shell import account_guard, account_shell
|
from src.pages._compte_shell import account_guard, account_shell
|
||||||
@@ -130,7 +130,33 @@ def _explainer():
|
|||||||
def _active_view(row):
|
def _active_view(row):
|
||||||
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
||||||
end = row["current_period_end"]
|
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":
|
if row["status"] == "trial":
|
||||||
blocks.append(
|
blocks.append(
|
||||||
dbc.Alert(
|
dbc.Alert(
|
||||||
@@ -143,22 +169,62 @@ def _active_view(row):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
blocks.append(html.P(f"Prochain renouvellement : {end}"))
|
blocks.append(html.P(f"Prochain renouvellement : {end}"))
|
||||||
|
|
||||||
if row["status"] in ("trial", "active"):
|
if row["status"] in ("trial", "active"):
|
||||||
blocks.append(
|
blocks.append(
|
||||||
html.Form(
|
html.Button(
|
||||||
method="POST",
|
"Me désabonner",
|
||||||
action="/subscriptions/cancel",
|
id="resiliation-trigger",
|
||||||
children=[
|
n_clicks=0,
|
||||||
_csrf_input(),
|
className="btn btn-outline-danger mt-3",
|
||||||
html.Button(
|
|
||||||
"Résilier", type="submit", className="btn btn-outline-danger"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return html.Div(blocks)
|
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):
|
def _feedback(query):
|
||||||
msgs = {
|
msgs = {
|
||||||
"succes": (
|
"succes": (
|
||||||
@@ -202,6 +268,17 @@ def _open_salaire_modal(_):
|
|||||||
return True
|
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():
|
def _tous_abonnes_banner():
|
||||||
from src.utils import TOUS_ABONNES
|
from src.utils import TOUS_ABONNES
|
||||||
|
|
||||||
@@ -221,12 +298,6 @@ def layout(**query):
|
|||||||
return guard
|
return guard
|
||||||
|
|
||||||
row = db.get_by_user(current_user.id) if current_user.is_authenticated else None
|
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 = (
|
trial_used = (
|
||||||
db.has_used_trial(current_user.id) if current_user.is_authenticated else False
|
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:
|
if banner is not None:
|
||||||
body.append(banner)
|
body.append(banner)
|
||||||
body.extend(_feedback(query))
|
body.extend(_feedback(query))
|
||||||
if has_access and row is not None:
|
|
||||||
|
if row is not None:
|
||||||
body.append(_active_view(row))
|
body.append(_active_view(row))
|
||||||
|
body.append(_resiliation_modal(row["current_period_end"]))
|
||||||
else:
|
else:
|
||||||
body.extend([_plan_cards(trial_used=trial_used), _explainer()])
|
body.extend([_plan_cards(trial_used=trial_used), _explainer()])
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import httpx
|
|||||||
# première intégration en environnement de test. Frisbii Billing/Pay s'appuie sur
|
# 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.
|
# l'API Reepay (api.reepay.com) ; ajuster FRISBII_API_BASE_URL si besoin.
|
||||||
_DEFAULT_BASE_URL = "https://api.frisbii.com"
|
_DEFAULT_BASE_URL = "https://api.frisbii.com"
|
||||||
|
_DEFAULT_CHECKOUT_URL = "https://checkout-api.frisbii.com"
|
||||||
_TIMEOUT = 15.0
|
_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
@@ -24,13 +25,24 @@ def _base_url() -> str:
|
|||||||
return os.getenv("FRISBII_API_BASE_URL") or _DEFAULT_BASE_URL
|
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:
|
try:
|
||||||
resp = httpx.request(
|
resp = httpx.request(
|
||||||
method,
|
method,
|
||||||
f"{_base_url()}{path}",
|
f"{base_url or _base_url()}{path}",
|
||||||
auth=(_api_key(), ""),
|
auth=(_api_key(), ""),
|
||||||
json=json,
|
json=json,
|
||||||
|
params=params,
|
||||||
timeout=_TIMEOUT,
|
timeout=_TIMEOUT,
|
||||||
)
|
)
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
@@ -74,6 +86,37 @@ def create_subscription_session(
|
|||||||
return data["hosted_page_links"]["payment_info"]
|
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:
|
def cancel_subscription(subscription_handle: str) -> dict:
|
||||||
return _call("POST", f"/v1/subscription/{subscription_handle}/cancel")
|
return _call("POST", f"/v1/subscription/{subscription_handle}/cancel")
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,48 @@ def subscribe():
|
|||||||
return redirect(url, code=303)
|
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"])
|
@subscriptions_bp.route("/subscriptions/cancel", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def cancel():
|
def cancel():
|
||||||
|
|||||||
Reference in New Issue
Block a user