From b5f529c78909114cf189671ba5a711a5fb20e672 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 29 Jun 2026 13:21:10 +0200 Subject: [PATCH] Formulaire d'infos de facturation avant paiement (#abonnement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouvelle page /compte/abonnement/mes-infos avec formulaire deux colonnes (prénom, nom, SIRET, entreprise, adresse, CP, ville, pays) - Lookup SIRET via l'annuaire des entreprises pour pré-remplissage - Cases à cocher obligatoires : renonciation rétractation + CGU (modale) - Le bouton Valider est désactivé tant que les deux cases ne sont pas cochées - Pays : liste déroulante des 20 pays de la zone euro, France par défaut - Le SIRET est stocké dans users.siret (migration 0002) pour pré-remplissage - Pré-remplissage des infos Frisbii (GET customer) si déjà client - Côté Frisbii : update_customer si client existant, create_customer sinon - Les URLs accept/cancel sont désormais transmises dans le body Frisbii - Mise à jour des CGU : distinction données decp.info vs Frisbii Co-Authored-By: Claude Sonnet 4.6 --- src/auth/db.py | 16 + src/migrations.py | 4 + src/pages/a_propos/abonnement.py | 28 +- src/pages/compte_abonnement.py | 36 ++- src/pages/compte_abonnement_mes_infos.py | 368 +++++++++++++++++++++++ src/subscriptions/client.py | 24 +- src/subscriptions/routes.py | 55 +++- 7 files changed, 479 insertions(+), 52 deletions(-) create mode 100644 src/pages/compte_abonnement_mes_infos.py diff --git a/src/auth/db.py b/src/auth/db.py index e9b24a5..54a10df 100644 --- a/src/auth/db.py +++ b/src/auth/db.py @@ -162,6 +162,22 @@ def update_password_hash(user_id: int, password_hash: str) -> None: ) +def get_siret(user_id: int) -> str | None: + row = ( + get_conn() + .execute("SELECT siret FROM users WHERE id = ?", (user_id,)) + .fetchone() + ) + return row["siret"] if row else None + + +def set_siret(user_id: int, siret: str) -> None: + get_conn().execute( + "UPDATE users SET siret = ?, updated_at = ? WHERE id = ?", + (siret, _now(), user_id), + ) + + def set_pending_email(user_id: int, email: str) -> None: get_conn().execute( "UPDATE users SET pending_email = ?, updated_at = ? WHERE id = ?", diff --git a/src/migrations.py b/src/migrations.py index e023bf5..7603538 100644 --- a/src/migrations.py +++ b/src/migrations.py @@ -16,6 +16,10 @@ _MIGRATIONS: list[tuple[str, str]] = [ "0001_add_prix_ht_to_subscriptions", "ALTER TABLE subscriptions ADD COLUMN prix_ht REAL", ), + ( + "0002_add_siret_to_users", + "ALTER TABLE users ADD COLUMN siret TEXT", + ), ] diff --git a/src/pages/a_propos/abonnement.py b/src/pages/a_propos/abonnement.py index 8925bbe..a4de599 100644 --- a/src/pages/a_propos/abonnement.py +++ b/src/pages/a_propos/abonnement.py @@ -54,7 +54,7 @@ La période d'essai est accordée une seule fois par utilisateur. dcc.Markdown( """ L'abonnement est facturé mensuellement, à la date anniversaire de la souscription. -Le paiement est traité par [Frisbii](https://www.frisbii.com), prestataire de paiement en ligne. +Le paiement est traité par [Frisbii](https://www.frisbii.com), prestataire européen de paiement en ligne. Les coordonnées bancaires sont conservées exclusivement par Frisbii et ne sont pas transmises à decp.info. """ ), @@ -63,28 +63,26 @@ Les coordonnées bancaires sont conservées exclusivement par Frisbii et ne sont """ L'abonnement peut être résilié à tout moment depuis l'espace [Mon compte](/compte/abonnement). La résiliation prend effet à la fin de la période mensuelle en cours : l'accès aux fonctionnalités payantes est maintenu jusqu'à cette date, sans remboursement au prorata. -""" - ), - html.H4("Droit de rétractation"), - dcc.Markdown( - """ -Conformément à l'article L221-18 du Code de la consommation, vous disposez d'un délai de 14 jours à compter de la souscription pour exercer votre droit de rétractation, sans motif à fournir. - -Si vous avez demandé l'accès immédiat au service et que vous l'avez effectivement utilisé, une partie proportionnelle à l'utilisation peut être déduite du remboursement. - -Pour exercer ce droit, [contactez-nous](/a-propos/contact). """ ), html.H4("Données personnelles"), dcc.Markdown( """ -La gestion de l'abonnement nécessite le traitement des données suivantes : +La gestion de l'abonnement implique le traitement de données personnelles, réparties entre decp.info et Frisbii (prestataire de paiement). -- adresse e-mail (identification du compte) -- historique de facturation (fourni par Frisbii) +**Données stockées par decp.info :** + +- Adresse e-mail (identification du compte) +- Numéro SIRET (si renseigné, conservé pour pré-remplir les futures souscriptions) + +**Données stockées par Frisbii :** + +- Informations de facturation : prénom, nom, adresse postale, code postal, ville, pays, nom de l'entreprise +- Informations de paiement : coordonnées bancaires (accessibles uniquement par Frisbii, jamais transmises à decp.info) +- Historique des factures Ces données sont utilisées uniquement pour la gestion de votre abonnement et ne sont pas transmises à des tiers à des fins commerciales. -Conformément au RGPD, vous pouvez demander l'accès, la rectification ou la suppression de vos données via la [page de contact](/a-propos/contact). +Conformément au RGPD, vous pouvez demander l'accès, la rectification ou la suppression de vos données en [me contactant](/a-propos/contact). """ ), html.H4("Contact"), diff --git a/src/pages/compte_abonnement.py b/src/pages/compte_abonnement.py index 81b8447..1bb6903 100644 --- a/src/pages/compte_abonnement.py +++ b/src/pages/compte_abonnement.py @@ -31,7 +31,7 @@ def _plan_card(meta: dict, trial: int | None, trial_used: bool): className="text-muted mb-2", ) elif trial: - badge = html.Div(f"{trial} jours d'essai gratuit", className="mb-2") + badge = html.Div(f"{trial} jours d'essai gratuit", className="mb-3") else: badge = None return dbc.Card( @@ -41,23 +41,18 @@ def _plan_card(meta: dict, trial: int | None, trial_used: bool): 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(), - dcc.Input(type="hidden", name="plan", value=meta["key"]), - html.Button( - "S'abonner", - type="submit", - className=( - "btn btn-secondary disabled" - if TOUS_ABONNES - else "btn btn-primary" - ), - disabled=TOUS_ABONNES, - ), - ], + html.A( + "S'abonner", + href=( + "#" + if TOUS_ABONNES + else f"/compte/abonnement/mes-infos?plan={meta['key']}" + ), + className=( + "btn btn-secondary disabled" + if TOUS_ABONNES + else "btn btn-primary" + ), ), ], className="p-4", @@ -166,7 +161,10 @@ def _active_view(row): def _feedback(query): msgs = { - "succes": ("Merci, votre abonnement est en cours d'activation.", "success"), + "succes": ( + "Merci, votre abonnement est activé. Bonne exploration !", + "success", + ), "annule": ("Paiement annulé.", "secondary"), } out = [] diff --git a/src/pages/compte_abonnement_mes_infos.py b/src/pages/compte_abonnement_mes_infos.py new file mode 100644 index 0000000..5bb988e --- /dev/null +++ b/src/pages/compte_abonnement_mes_infos.py @@ -0,0 +1,368 @@ +import dash_bootstrap_components as dbc +from dash import Input, Output, State, callback, dcc, html, register_page +from flask_login import current_user + +from src.auth import db as auth_db +from src.pages._compte_shell import account_guard, account_shell +from src.subscriptions import client as frisbii_client +from src.utils.data import get_annuaire_data + +register_page( + __name__, + path="/compte/abonnement/mes-infos", + title="Mes informations | Abonnement | decp.info", + name="Mes informations de facturation", + description="Informations de facturation pour votre abonnement decp.info.", +) + +_CGU_MARKDOWN = """ +L'accès aux fonctionnalités de base de decp.info est gratuit et sans inscription. +Un abonnement payant donne accès à des fonctionnalités supplémentaires qui nécessitent la création d'un compte. + +#### Fonctionnalités incluses + +Les fonctionnalités accessibles aux abonnés évoluent au fil du développement du service. +La liste à jour est visible depuis la [page d'abonnement](/compte/abonnement). + +#### Tarifs + +Deux formules sont proposées : + +- **Abonnement** — 20 € HT / mois (soit 24 € TTC) +- **Abonnement de soutien** — 50 € HT / mois (soit 60 € TTC) — mêmes fonctionnalités, contribution renforcée au projet + +La TVA applicable est de 20 %. Les prix TTC sont affichés lors de la souscription. + +#### Période d'essai + +Une période d'essai gratuite peut être proposée lors de la souscription. +Sa durée est indiquée avant validation. Aucun prélèvement n'est effectué pendant cette période. +À son terme, l'abonnement est activé et facturé automatiquement. + +La période d'essai est accordée une seule fois par utilisateur. + +#### Facturation et paiement + +L'abonnement est facturé mensuellement, à la date anniversaire de la souscription. +Le paiement est traité par [Frisbii](https://www.frisbii.com), prestataire européen de paiement en ligne. +Les coordonnées bancaires sont conservées exclusivement par Frisbii et ne sont pas transmises à decp.info. + +#### Résiliation + +L'abonnement peut être résilié à tout moment depuis l'espace [Mon compte](/compte/abonnement). +La résiliation prend effet à la fin de la période mensuelle en cours : l'accès aux fonctionnalités payantes est maintenu jusqu'à cette date, sans remboursement au prorata. + +#### Données stockées + +La gestion de l'abonnement implique le traitement de données personnelles, réparties entre decp.info et Frisbii (prestataire de paiement). + +**Données stockées par decp.info :** + +- Adresse e-mail (identification du compte) +- Numéro SIRET (si renseigné, conservé pour pré-remplir les futures souscriptions et alimenter des fonctionnalités) + +**Données stockées par Frisbii :** + +- Informations de facturation : prénom, nom, adresse postale, code postal, ville, pays, nom de l'entreprise +- Informations de paiement : coordonnées bancaires (accessibles uniquement par Frisbii, jamais transmises à decp.info) +- Historique des factures + +Ces données sont utilisées uniquement pour la gestion de votre abonnement et ne sont pas transmises à des tiers à des fins commerciales. +Conformément au RGPD, vous pouvez demander l'accès, la rectification ou la suppression de vos données en [me contactant](/a-propos/contact). + +#### Contact + +Pour toute question relative à votre abonnement : [page de contact](/a-propos/contact). +""" + + +def _csrf_input(): + from flask_wtf.csrf import generate_csrf + + return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf()) + + +def _cgu_modal(): + return dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Conditions générales d'utilisation")), + dbc.ModalBody( + dcc.Markdown(_CGU_MARKDOWN), + style={"maxHeight": "60vh", "overflowY": "auto"}, + ), + dbc.ModalFooter( + dbc.Button( + "Fermer", id="inf-cgu-close", className="ms-auto", color="secondary" + ) + ), + ], + id="inf-cgu-modal", + size="lg", + is_open=False, + ) + + +def layout(**query): + guard = account_guard("/compte/abonnement/mes-infos", require_subscription=False) + if guard is not None: + return guard + + plan = query.get("plan", "") + if not plan: + return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect") + + prefill: dict = {} + try: + prefill = frisbii_client.get_customer(f"decpinfo-{current_user.id}") + except frisbii_client.FrisbiiError: + prefill = {} + stored_siret = auth_db.get_siret(current_user.id) or "" + + col1 = dbc.Col( + [ + dbc.Label("Prénom *"), + dbc.Input( + id="inf-prenom", + name="first_name", + type="text", + required=True, + value=prefill.get("first_name", ""), + className="mb-3", + ), + dbc.Label("Nom *"), + dbc.Input( + id="inf-nom", + name="last_name", + type="text", + required=True, + value=prefill.get("last_name", ""), + className="mb-3", + ), + dbc.Label("SIRET"), + dbc.InputGroup( + [ + dbc.Input( + id="inf-siret", + name="siret", + type="text", + maxLength=14, + placeholder="14 chiffres", + value=stored_siret, + ), + dbc.Button( + "Récupérer les autres infos", + id="inf-siret-btn", + color="secondary", + type="button", + n_clicks=0, + ), + ], + className="mb-1", + ), + html.Div(id="inf-siret-msg", className="small mb-3"), + dbc.Label("Nom de l'entreprise"), + dbc.Input( + id="inf-entreprise", + name="company", + type="text", + value=prefill.get("company", ""), + className="mb-3", + ), + ], + md=6, + ) + + col2 = dbc.Col( + [ + dbc.Label("Adresse ligne 1 *"), + dbc.Input( + id="inf-adresse1", + name="address", + type="text", + required=True, + value=prefill.get("address", ""), + className="mb-3", + ), + dbc.Label("Adresse ligne 2"), + dbc.Input( + id="inf-adresse2", + name="address2", + type="text", + value=prefill.get("address2", ""), + className="mb-3", + ), + dbc.Label("Code postal *"), + dbc.Input( + id="inf-cp", + name="postal_code", + type="text", + required=True, + value=prefill.get("postal_code", ""), + className="mb-3", + ), + dbc.Label("Ville *"), + dbc.Input( + id="inf-ville", + name="city", + type="text", + required=True, + value=prefill.get("city", ""), + className="mb-3", + ), + dbc.Label("Pays *"), + dbc.Select( + id="inf-pays", + name="country", + value=prefill.get("country", "FR"), + options=[ + {"label": "France", "value": "FR"}, + {"label": "Allemagne", "value": "DE"}, + {"label": "Autriche", "value": "AT"}, + {"label": "Belgique", "value": "BE"}, + {"label": "Chypre", "value": "CY"}, + {"label": "Croatie", "value": "HR"}, + {"label": "Espagne", "value": "ES"}, + {"label": "Estonie", "value": "EE"}, + {"label": "Finlande", "value": "FI"}, + {"label": "Grèce", "value": "GR"}, + {"label": "Irlande", "value": "IE"}, + {"label": "Italie", "value": "IT"}, + {"label": "Lettonie", "value": "LV"}, + {"label": "Lituanie", "value": "LT"}, + {"label": "Luxembourg", "value": "LU"}, + {"label": "Malte", "value": "MT"}, + {"label": "Pays-Bas", "value": "NL"}, + {"label": "Portugal", "value": "PT"}, + {"label": "Slovaquie", "value": "SK"}, + {"label": "Slovénie", "value": "SI"}, + ], + className="mb-3", + ), + ], + md=6, + ) + + checkboxes = html.Div( + [ + dcc.Checklist( + id="inf-cb-retractation", + options=[ + { + "label": "Je renonce à mon droit de rétractation légal de 14 jours.", + "value": "ok", + } + ], + value=[], + className="mb-2", + ), + dcc.Checklist( + id="inf-cb-cgu", + options=[ + { + "label": [ + "J'ai lu et accepte les ", + html.A( + "conditions générales d'utilisation du service", + href="#", + id="inf-cgu-link", + style={"cursor": "pointer"}, + ), + ".", + ], + "value": "ok", + } + ], + value=[], + className="mb-4", + ), + ] + ) + + form = html.Form( + method="POST", + action="/subscriptions/subscribe", + children=[ + _csrf_input(), + dcc.Input(type="hidden", name="plan", value=plan), + dbc.Row([col1, col2], className="g-4 mb-4"), + checkboxes, + html.Button( + "Suivant", + id="inf-submit", + type="submit", + className="btn btn-primary", + disabled=True, + ), + ], + ) + + return account_shell( + "abonnement", + html.Div( + [ + html.H2("Mes informations de facturation", className="mb-4"), + form, + _cgu_modal(), + ] + ), + ) + + +@callback( + Output("inf-entreprise", "value"), + Output("inf-adresse1", "value"), + Output("inf-cp", "value"), + Output("inf-ville", "value"), + Output("inf-siret-msg", "children"), + Output("inf-siret-msg", "className"), + Input("inf-siret-btn", "n_clicks"), + State("inf-siret", "value"), + prevent_initial_call=True, +) +def _lookup_siret(_, siret): + empty = ("", "", "", "") + if not siret or not siret.strip(): + return *empty, "Veuillez saisir un SIRET.", "small text-danger mb-3" + data = get_annuaire_data(siret.strip()) + if data is None: + return ( + *empty, + "SIRET introuvable dans l'annuaire des entreprises.", + "small text-danger mb-3", + ) + etablissement = data.get("matching_etablissements") + if not etablissement: + return ( + *empty, + "Aucun établissement trouvé pour ce SIRET.", + "small text-danger mb-3", + ) + etab = etablissement[0] + return ( + data.get("nom_raison_sociale", ""), + etab.get("adresse", ""), + etab.get("code_postal", ""), + etab.get("libelle_commune", ""), + "Informations récupérées.", + "small text-success mb-3", + ) + + +@callback( + Output("inf-submit", "disabled"), + Input("inf-cb-retractation", "value"), + Input("inf-cb-cgu", "value"), +) +def _toggle_submit(retractation, cgu): + return not (retractation and cgu) + + +@callback( + Output("inf-cgu-modal", "is_open"), + Input("inf-cgu-link", "n_clicks"), + Input("inf-cgu-close", "n_clicks"), + State("inf-cgu-modal", "is_open"), + prevent_initial_call=True, +) +def _toggle_cgu(_, __, is_open): + return not is_open diff --git a/src/subscriptions/client.py b/src/subscriptions/client.py index c36b05f..45a2af1 100644 --- a/src/subscriptions/client.py +++ b/src/subscriptions/client.py @@ -36,32 +36,38 @@ def _call(method: str, path: str, json: dict | None = None) -> dict: except httpx.RequestError as exc: raise FrisbiiError(0, str(exc)) from exc if resp.status_code >= 400: + print(resp.text) raise FrisbiiError(resp.status_code, resp.text) return resp.json() -def get_or_create_customer(handle: str, email: str) -> dict: - try: - return _call("GET", f"/v1/customer/{handle}") - except FrisbiiError as exc: - if exc.status_code != 404: - raise - return _call("POST", "/v1/customer", json={"handle": handle, "email": email}) +def get_customer(handle: str) -> dict: + return _call("GET", f"/v1/customer/{handle}") + + +def update_customer(handle: str, data: dict) -> dict: + return _call("PUT", f"/v1/customer/{handle}", json=data) def create_subscription_session( plan_handle: str, - customer_handle: str, accept_url: str, cancel_url: str, no_trial: bool = False, + customer_handle: str | None = None, + create_customer: dict | None = None, ) -> str: body: dict = { "plan": plan_handle, - "customer": customer_handle, "signup_method": "link", "generate_handle": True, + "accept_url": accept_url, + "cancel_url": cancel_url, } + if customer_handle: + body["customer"] = customer_handle + elif create_customer: + body["create_customer"] = create_customer if no_trial: body["no_trial"] = True data = _call("POST", "/v1/subscription", json=body) diff --git a/src/subscriptions/routes.py b/src/subscriptions/routes.py index b63bd8e..3e4bb0e 100644 --- a/src/subscriptions/routes.py +++ b/src/subscriptions/routes.py @@ -3,6 +3,7 @@ import os from flask import Blueprint, redirect, request from flask_login import current_user, login_required +from src.auth import db as auth_db from src.subscriptions import client, db, plans, webhooks from src.utils import logger @@ -28,19 +29,55 @@ def subscribe(): cust = _customer_handle(current_user.id) try: meta = plans.plan_meta(plan_key) - client.get_or_create_customer(cust, current_user.email) db.create_pending( current_user.id, cust, plan_key, meta["prix_ht"] if meta else None ) - # Anti-abus : pas de nouvel essai si l'utilisateur en a déjà consommé un. no_trial = db.has_used_trial(current_user.id) - url = client.create_subscription_session( - handle, - cust, - f"{base}/compte/abonnement?paiement=succes", - f"{base}/compte/abonnement?paiement=annule", - no_trial=no_trial, - ) + 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"] + + if siret: + auth_db.set_siret(current_user.id, siret) + + customer_exists = True + try: + client.update_customer(cust, billing) + except client.FrisbiiError as exc: + if exc.status_code != 404: + raise + customer_exists = False + + if customer_exists: + url = client.create_subscription_session( + handle, + f"{base}/compte/abonnement?paiement=succes", + f"{base}/compte/abonnement?paiement=annule", + customer_handle=cust, + no_trial=no_trial, + ) + else: + create_customer = {"handle": cust, **billing} + if siret: + create_customer["metadata"] = {"siret": siret} + url = client.create_subscription_session( + handle, + f"{base}/compte/abonnement?paiement=succes", + f"{base}/compte/abonnement?paiement=annule", + create_customer=create_customer, + no_trial=no_trial, + ) except client.FrisbiiError: logger.exception("Échec de création de session d'abonnement Frisbii") return redirect("/compte/abonnement?error=frisbii")