diff --git a/.template.env b/.template.env index 1c6a333..79624f9 100644 --- a/.template.env +++ b/.template.env @@ -59,7 +59,7 @@ 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_API_BASE_URL=https://api.frisbii.com # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer) +FRISBII_PLAN_SIMPLE=abonnement-decp.info # handle du plan "abonnement simple" (20 € HT/mois) +FRISBII_PLAN_SOUTIEN=soutien-decp.info # handle du plan "abonnement de soutien" (50 € HT/mois) FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks diff --git a/src/assets/salaire.png b/src/assets/salaire.png new file mode 100644 index 0000000..6c74287 Binary files /dev/null and b/src/assets/salaire.png differ diff --git a/src/auth/routes.py b/src/auth/routes.py index ea1d217..55cdb07 100644 --- a/src/auth/routes.py +++ b/src/auth/routes.py @@ -13,6 +13,18 @@ from src.utils import logger auth_bp = Blueprint("auth", __name__, url_prefix="/auth") + +def _post_login_url(user_id: int) -> str: + try: + from src.subscriptions import db as sub_db + + if sub_db.has_active_subscription(user_id): + return "/compte/admin" + except Exception: + pass + return "/compte/abonnement" + + MIN_PASSWORD_LENGTH = 8 # Hash bidon pré-calculé pour uniformiser le timing login @@ -93,7 +105,7 @@ def verify_email(): def login(): email = (request.form.get("email") or "").strip().lower() password = request.form.get("password") or "" - next_url = safe_next(request.form.get("next"), fallback="/compte/admin") + next_url = request.form.get("next") row = db.get_user_by_email(email) if row is None: @@ -109,7 +121,7 @@ def login(): return _redirect_with_error("/connexion", "email_not_verified", email) login_user(User(row), remember=True) - return redirect(next_url) + return redirect(safe_next(next_url, fallback=_post_login_url(row["id"]))) @auth_bp.route("/logout", methods=["POST"]) @@ -259,7 +271,7 @@ def linkedin_login(): @auth_bp.route("/linkedin/callback", methods=["GET"]) def linkedin_callback(): - next_url = safe_next(session.pop("oauth_next", None), fallback="/compte/admin") + oauth_next = session.pop("oauth_next", None) if request.args.get("error"): # L'utilisateur a refusé / annulé l'autorisation côté LinkedIn. return _redirect_with_error("/connexion", "oauth_cancelled") @@ -290,4 +302,4 @@ def linkedin_callback(): "linkedin", subject, email, bool(userinfo.get("email_verified")) ) login_user(user, remember=True) - return redirect(next_url) + return redirect(safe_next(oauth_next, fallback=_post_login_url(user.id))) diff --git a/src/pages/_compte_shell.py b/src/pages/_compte_shell.py index 984949e..dec2cf9 100644 --- a/src/pages/_compte_shell.py +++ b/src/pages/_compte_shell.py @@ -5,18 +5,18 @@ from flask_login import current_user # Définition centralisée des sections de l'espace compte. # Ajouter une section future = ajouter une ligne ici (+ créer sa page). SECTIONS = [ - { - "key": "admin", - "label": "Compte", - "href": "/compte/admin", - "require_subscription": False, - }, { "key": "abonnement", "label": "Abonnement", "href": "/compte/abonnement", "require_subscription": False, }, + { + "key": "admin", + "label": "Compte", + "href": "/compte/admin", + "require_subscription": False, + }, { "key": "archives", "label": "Mes archives", diff --git a/src/pages/compte_abonnement.py b/src/pages/compte_abonnement.py index de047c1..a15bcc0 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 dcc, html, register_page +from dash import Input, Output, callback, dcc, html, register_page from flask_login import current_user from src.pages._compte_shell import account_guard, account_shell @@ -16,10 +16,10 @@ register_page( _PLAN_KEYS = ("simple", "soutien") -def _csrf_input(index: str): - return dcc.Input( - type="hidden", id={"type": "csrf-input", "index": index}, name="csrf_token" - ) +def _csrf_input(): + from flask_wtf.csrf import generate_csrf + + return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf()) def _plan_card(meta: dict, trial: int | None, trial_used: bool): @@ -37,24 +37,25 @@ def _plan_card(meta: dict, trial: int | None, trial_used: bool): return dbc.Card( dbc.CardBody( [ - html.H4(meta["label"]), - html.P(f"{meta['prix_ht']} € HT / mois"), - html.P(meta["description"]), + html.H4(meta["label"], className="mb-1"), + html.P(f"{meta['prix_ht']} € HT / mois", className="text-muted mb-3"), + html.P(meta["description"], className="mb-3"), badge, html.Form( method="POST", action="/subscriptions/subscribe", children=[ - _csrf_input(f"subscribe-{meta['key']}"), + _csrf_input(), dcc.Input(type="hidden", name="plan", value=meta["key"]), html.Button( "S'abonner", type="submit", className="btn btn-primary" ), ], ), - ] + ], + className="p-4", ), - className="mb-3", + className="h-100", ) @@ -64,22 +65,45 @@ def _plan_cards(trial_used=False, trial_for=plans.trial_days): 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]) + return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-5") def _explainer(): - return html.Div( + col_left = dbc.Col( [ - html.H4("À quoi servent les abonnements"), + html.H4("Ce que les abonnements financent"), 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.Li( + [ + "Salaire médian (", + html.Span( + "coût employeur", + id="salaire-modal-trigger", + style={ + "cursor": "pointer", + "textDecoration": "underline", + "color": "var(--bs-link-color)", + }, + ), + ") : 3 840 €", + ] + ), ] ), - html.H4("Ce que les abonnements de soutien permettraient"), + ], + md=6, + style={ + "borderRight": "1px solid var(--bs-border-color)", + "paddingRight": "2rem", + }, + ) + col_right = dbc.Col( + [ + html.H4("Ce que les abonnements de soutien permettent"), html.Ul( [ html.Li( @@ -88,14 +112,17 @@ def _explainer(): "cette non-publication." ), html.Li( - "Coordination des bonnes volontés souhaitant militer pour une " - "législation plus exigeante sur la transparence de la commande " + "Fédération des bonnes volontés souhaitant militer pour une " + "législation plus ambitieuse sur la transparence de la commande " "publique." ), ] ), - ] + ], + md=6, + style={"paddingLeft": "2rem"}, ) + return dbc.Row([col_left, col_right], className="align-items-start pt-2") def _active_view(row): @@ -120,7 +147,7 @@ def _active_view(row): method="POST", action="/subscriptions/cancel", children=[ - _csrf_input("cancel"), + _csrf_input(), html.Button( "Résilier", type="submit", className="btn btn-outline-danger" ), @@ -150,6 +177,26 @@ def _feedback(query): return out +_salaire_modal = dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Salaire médian — coût employeur")), + dbc.ModalBody(html.Img(src="/assets/salaire.png", style={"width": "100%"})), + ], + id="salaire-modal", + size="lg", + is_open=False, +) + + +@callback( + Output("salaire-modal", "is_open"), + Input("salaire-modal-trigger", "n_clicks"), + prevent_initial_call=True, +) +def _open_salaire_modal(_): + return True + + def layout(**query): guard = account_guard("/compte/abonnement", require_subscription=False) if guard is not None: @@ -166,10 +213,11 @@ def layout(**query): db.has_used_trial(current_user.id) if current_user.is_authenticated else False ) - body = [html.H2("Abonnement"), *_feedback(query)] + body = [html.H2("Abonnement", className="mb-4"), *_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()]) + body.extend([_plan_cards(trial_used=trial_used), _explainer()]) + body.append(_salaire_modal) return account_shell("abonnement", html.Div(body)) diff --git a/src/subscriptions/client.py b/src/subscriptions/client.py index 4ce2ad2..6e2b8a0 100644 --- a/src/subscriptions/client.py +++ b/src/subscriptions/client.py @@ -5,7 +5,7 @@ import httpx # NB : base URL et schémas d'endpoints à confirmer dans la doc Frisbii lors de la # 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.reepay.com" +_DEFAULT_BASE_URL = "https://api.frisbii.com" _TIMEOUT = 15.0 @@ -53,19 +53,16 @@ def create_subscription_session( cancel_url: str, no_trial: bool = False, ) -> str: - prepare = {"plan": plan_handle, "customer": customer_handle} + body: dict = { + "plan": plan_handle, + "customer": customer_handle, + "signup_method": "link", + "generate_handle": True, + } if no_trial: - prepare["no_trial"] = True - data = _call( - "POST", - "/v1/session/subscription", - json={ - "accept_url": accept_url, - "cancel_url": cancel_url, - "prepare_subscription": prepare, - }, - ) - return data["url"] + body["no_trial"] = True + data = _call("POST", "/v1/subscription", json=body) + return data["hosted_page_links"]["payment_info"] def cancel_subscription(subscription_handle: str) -> dict: diff --git a/src/subscriptions/plans.py b/src/subscriptions/plans.py index b0a13c1..7fc3bac 100644 --- a/src/subscriptions/plans.py +++ b/src/subscriptions/plans.py @@ -17,11 +17,11 @@ PLANS = { "env": "FRISBII_PLAN_SIMPLE", "label": "Abonnement simple", "prix_ht": 20, - "description": "Accès aux fonctionnalités premium de decp.info.", + "description": "Accès à des fonctionnalités supplémentaires de decp.info.", }, "soutien": { "env": "FRISBII_PLAN_SOUTIEN", - "label": "Abonnement de soutien", + "label": "Abonnement de soutien ✊", "prix_ht": 50, "description": "Mêmes fonctionnalités, contribution renforcée au projet.", }, @@ -74,6 +74,9 @@ def trial_days(key: str) -> int | None: except client.FrisbiiError: logger.warning("Impossible de lire le plan Frisbii %s", handle) return cached[1] if cached else None + # L'API Frisbii/Reepay renvoie une liste de versions ; on prend la dernière (la plus récente). + if isinstance(plan, list): + plan = plan[-1] if plan else {} days = _parse_trial_interval(plan.get("trial_interval")) _trial_cache[handle] = (now, days) return days diff --git a/tests/subscriptions/test_client.py b/tests/subscriptions/test_client.py index 18e52c2..011e727 100644 --- a/tests/subscriptions/test_client.py +++ b/tests/subscriptions/test_client.py @@ -29,29 +29,42 @@ def test_get_or_create_customer_creates_on_404(fake_httpx): def test_create_subscription_session_returns_url(fake_httpx): fake_httpx["queue"].append( - fake_httpx["Response"](200, {"id": "cs_1", "url": "https://pay.test/cs_1"}) + fake_httpx["Response"]( + 200, + { + "hosted_page_links": { + "payment_info": "https://checkout.reepay.com/#/sub-1" + } + }, + ) ) url = client.create_subscription_session( "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko" ) - assert url == "https://pay.test/cs_1" + assert url == "https://checkout.reepay.com/#/sub-1" body = fake_httpx["calls"][0]["json"] - assert body["prepare_subscription"] == { - "plan": "plan_simple", - "customer": "decpinfo-1", - } - assert body["accept_url"] == "https://app/ok" - assert body["cancel_url"] == "https://app/ko" + assert body["plan"] == "plan_simple" + assert body["customer"] == "decpinfo-1" + assert body["signup_method"] == "link" + assert "prepare_subscription" not in body + assert "accept_url" not in body def test_create_subscription_session_no_trial(fake_httpx): fake_httpx["queue"].append( - fake_httpx["Response"](200, {"url": "https://pay.test/cs_2"}) + fake_httpx["Response"]( + 200, + { + "hosted_page_links": { + "payment_info": "https://checkout.reepay.com/#/sub-2" + } + }, + ) ) client.create_subscription_session( "plan_simple", "decpinfo-1", "https://app/ok", "https://app/ko", no_trial=True ) - assert fake_httpx["calls"][0]["json"]["prepare_subscription"]["no_trial"] is True + assert fake_httpx["calls"][0]["json"]["no_trial"] is True def test_http_error_raises_frisbii_error(fake_httpx): diff --git a/tests/users.test.sqlite b/tests/users.test.sqlite index 16de761..6370d61 100644 Binary files a/tests/users.test.sqlite and b/tests/users.test.sqlite differ