Formulaire d'infos de facturation avant paiement (#abonnement)

- 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 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-29 13:21:10 +02:00
parent 5e5c5170a7
commit b5f529c789
7 changed files with 479 additions and 52 deletions
+16
View File
@@ -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 = ?",
+4
View File
@@ -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",
),
]
+13 -15
View File
@@ -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"),
+11 -13
View File
@@ -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(
html.A(
"S'abonner",
type="submit",
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"
),
disabled=TOUS_ABONNES,
),
],
),
],
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 = []
+368
View File
@@ -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
+14 -8
View File
@@ -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:
def get_customer(handle: str) -> dict:
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 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)
+40 -3
View File
@@ -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,17 +29,53 @@ 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)
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,
cust,
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: