diff --git a/src/pages/_compte_shell.py b/src/pages/_compte_shell.py index 7f4149b..984949e 100644 --- a/src/pages/_compte_shell.py +++ b/src/pages/_compte_shell.py @@ -39,8 +39,11 @@ SECTIONS = [ def current_user_has_subscription() -> bool: - """Stub : à brancher sur la facturation (issue #73).""" - return False + from src.subscriptions import db + + if not current_user.is_authenticated: + return False + return db.has_active_subscription(current_user.id) def visible_sections(has_subscription: bool) -> list[dict]: diff --git a/src/pages/compte_abonnement.py b/src/pages/compte_abonnement.py index bb5637e..de047c1 100644 --- a/src/pages/compte_abonnement.py +++ b/src/pages/compte_abonnement.py @@ -1,6 +1,9 @@ -from dash import html, register_page +import dash_bootstrap_components as dbc +from dash import dcc, html, register_page +from flask_login import current_user from src.pages._compte_shell import account_guard, account_shell +from src.subscriptions import db, plans register_page( __name__, @@ -10,16 +13,163 @@ register_page( description="Gestion de votre abonnement decp.info.", ) +_PLAN_KEYS = ("simple", "soutien") -def layout(**_): + +def _csrf_input(index: str): + return dcc.Input( + type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token" + ) + + +def _plan_card(meta: dict, trial: int | None, trial_used: bool): + if trial_used: + badge = html.Div( + "Sans période d'essai (déjà utilisée) — débit immédiat", + className="text-muted mb-2", + ) + elif trial: + badge = html.Div( + f"{trial} jours d'essai gratuit", className="text-success mb-2" + ) + else: + badge = None + return dbc.Card( + dbc.CardBody( + [ + html.H4(meta["label"]), + html.P(f"{meta['prix_ht']} € HT / mois"), + html.P(meta["description"]), + badge, + html.Form( + method="POST", + action="/subscriptions/subscribe", + children=[ + _csrf_input(f"subscribe-{meta['key']}"), + dcc.Input(type="hidden", name="plan", value=meta["key"]), + html.Button( + "S'abonner", type="submit", className="btn btn-primary" + ), + ], + ), + ] + ), + className="mb-3", + ) + + +def _plan_cards(trial_used=False, trial_for=plans.trial_days): + cards = [] + for key in _PLAN_KEYS: + meta = plans.plan_meta(key) + if meta: + cards.append(_plan_card(meta, trial_for(key), trial_used)) + return dbc.Row([dbc.Col(c, md=6) for c in cards]) + + +def _explainer(): + return html.Div( + [ + html.H4("À quoi servent les abonnements"), + html.Ul( + [ + html.Li("Abonnement Frisbii (solution de paiement) : 50 €"), + html.Li("Serveur Scaleway : 40 €"), + html.Li("Espace de coworking : 250 €"), + html.Li("Salaire médian : 3 840 €"), + ] + ), + html.H4("Ce que les abonnements de soutien permettraient"), + html.Ul( + [ + html.Li( + "Rédaction d'études à partir des données, par exemple sur les " + "acheteurs dont les données sont introuvables et les raisons de " + "cette non-publication." + ), + html.Li( + "Coordination des bonnes volontés souhaitant militer pour une " + "législation plus exigeante sur la transparence de la commande " + "publique." + ), + ] + ), + ] + ) + + +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']}")] + if row["status"] == "trial": + blocks.append( + dbc.Alert( + f"Essai gratuit jusqu'au {end}, puis débit automatique.", color="info" + ) + ) + elif row["status"] == "cancelled": + blocks.append( + dbc.Alert(f"Abonnement résilié, actif jusqu'au {end}.", color="warning") + ) + 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("cancel"), + html.Button( + "Résilier", type="submit", className="btn btn-outline-danger" + ), + ], + ) + ) + return html.Div(blocks) + + +def _feedback(query): + msgs = { + "succes": ("Merci, votre abonnement est en cours d'activation.", "success"), + "annule": ("Paiement annulé.", "secondary"), + } + out = [] + if query.get("paiement") in msgs: + text, color = msgs[query["paiement"]] + out.append(dbc.Alert(text, color=color)) + if query.get("resiliation") == "ok": + out.append(dbc.Alert("Votre abonnement a été résilié.", color="info")) + if query.get("error") == "frisbii": + out.append( + dbc.Alert( + "Une erreur est survenue avec le service de paiement.", color="danger" + ) + ) + return out + + +def layout(**query): guard = account_guard("/compte/abonnement", require_subscription=False) if guard is not None: return guard - contenu = html.Div( - [ - html.H2("Abonnement"), - html.P("La gestion de l'abonnement arrive bientôt."), - ] + 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 ) - return account_shell("abonnement", contenu) + + trial_used = ( + db.has_used_trial(current_user.id) if current_user.is_authenticated else False + ) + + body = [html.H2("Abonnement"), *_feedback(query)] + if has_access and row is not None: + body.append(_active_view(row)) + else: + body.extend([_plan_cards(trial_used=trial_used), html.Hr(), _explainer()]) + + return account_shell("abonnement", html.Div(body)) diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py index 8b7ed2f..86f0b4e 100644 --- a/tests/subscriptions/conftest.py +++ b/tests/subscriptions/conftest.py @@ -2,6 +2,12 @@ import json as _json import pytest +# Initialise un Dash minimal pour que register_page() fonctionne dans les tests +# unitaires de pages (pas besoin du serveur complet — juste que CONFIG soit peuplé). +from dash import Dash as _Dash + +_Dash(__name__, assets_folder="assets") + class FakeResponse: def __init__(self, status_code: int, payload=None): diff --git a/tests/subscriptions/test_compte_abonnement.py b/tests/subscriptions/test_compte_abonnement.py new file mode 100644 index 0000000..93f191b --- /dev/null +++ b/tests/subscriptions/test_compte_abonnement.py @@ -0,0 +1,43 @@ +def test_plan_cards_present_when_no_subscription(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + from src.pages import compte_abonnement + + cards = compte_abonnement._plan_cards(trial_for=lambda key: 2) + text = str(cards) + assert "Abonnement simple" in text + assert "Abonnement de soutien" in text + assert "2 jours d'essai gratuit" in text + + +def test_plan_cards_no_trial_when_trial_used(monkeypatch): + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + from src.pages import compte_abonnement + + text = str(compte_abonnement._plan_cards(trial_used=True, trial_for=lambda key: 2)) + assert "Sans période d'essai" in text + assert "2 jours d'essai gratuit" not in text + + +def test_active_view_shows_cancel(monkeypatch): + from src.pages import compte_abonnement + + row = { + "plan": "simple", + "status": "active", + "current_period_end": "2099-01-01T00:00:00+00:00", + } + view = compte_abonnement._active_view(row) + assert "Résilier" in str(view) + + +def test_active_view_trial_banner(monkeypatch): + from src.pages import compte_abonnement + + row = { + "plan": "simple", + "status": "trial", + "current_period_end": "2099-01-01T00:00:00+00:00", + } + assert "Essai gratuit" in str(compte_abonnement._active_view(row))