Files
colibre/src/pages/compte/abonnement_mes_infos.py
T
2026-07-13 23:24:27 +02:00

446 lines
14 KiB
Python

import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback, ctx, 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.pages.a_propos.abonnement import _plan_card, subscription_terms
from src.subscriptions import client as frisbii_client
from src.subscriptions import db as sub_db
from src.subscriptions import plans
from src.utils.data import get_annuaire_data
from src.utils.frontend import format_date_french
register_page(
__name__,
path="/compte/abonnement/mes-infos",
title="Mes informations | Abonnement | colibre",
name="Mes informations de facturation",
description="Informations de facturation pour votre abonnement colibre.",
)
_CGU_MARKDOWN = subscription_terms
def _csrf_input():
from flask_wtf.csrf import generate_csrf
return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf())
def _trial_for(user_id):
used = sub_db.has_used_trial(user_id)
return lambda key: None if used else plans.trial_days(key)
def _mode_for(row) -> str:
if row is not None and row["status"] in ("active", "trial", "pending"):
return "configure"
return "subscribe"
def _submit_button(mode: str):
label = (
"Mettre à jour mon abonnement"
if mode == "configure"
else "Ajouter une carte de paiement"
)
return html.Button(
label,
id="inf-submit",
type="submit",
className="btn btn-secondary",
disabled=(mode == "subscribe"),
)
def _selectable_cards(trial_for, selected=None):
cols = []
for key in ("simple", "soutien"):
meta = plans.plan_meta(key)
if not meta:
continue
base = "plan-selectable selected" if key == selected else "plan-selectable"
cols.append(
dbc.Col(
html.Div(
_plan_card(meta, trial_for(key)),
id=f"plan-card-{key}",
n_clicks=0,
className=base,
),
md=6,
)
)
return dbc.Row(cols, className="g-4 mb-2")
def _selection_state(selected):
base = "plan-selectable"
return (
selected,
f"{base} selected" if selected == "simple" else base,
f"{base} selected" if selected == "soutien" else base,
"d-none",
)
def _change_hint(selected: str, sub_info: dict | None) -> tuple[str, str]:
sub_info = sub_info or {}
current = sub_info.get("current_plan")
if (
not current
or sub_info.get("status") not in ("active", "trial")
or selected == current
):
return "d-none", ""
echeance = sub_info.get("echeance")
return (
"text-muted mt-2",
f"Le changement d'abonnement sera appliqué à la prochaine échéance : {echeance}.",
)
@callback(
Output("inf-plan-hidden", "value"),
Output("plan-card-simple", "className"),
Output("plan-card-soutien", "className"),
Output("inf-plan-invite", "className"),
Output("inf-change-hint", "className"),
Output("inf-change-hint", "children"),
Input("plan-card-simple", "n_clicks"),
Input("plan-card-soutien", "n_clicks"),
State("inf-sub-info", "data"),
prevent_initial_call=True,
)
def _select_plan(_n_simple, _n_soutien, sub_info):
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
value, cls_simple, cls_soutien, cls_invite = _selection_state(selected)
hint_cls, hint_txt = _change_hint(selected, sub_info)
return value, cls_simple, cls_soutien, cls_invite, hint_cls, hint_txt
def _legal_note():
return dcc.Markdown(
"""\\* Champ obligatoire
Si vous préférez régler par virement bancaire et une facturation annuelle plutôt qu'un réglement mensuel automatique par carte bancaire, [contactez-moi](/a-propos/contact)."""
)
def _consent_checklists(hidden: bool = False):
default_value = ["ok"] if hidden else []
return 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=default_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=default_value,
className="mb-4",
),
],
className="d-none" if hidden else None,
)
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
row = sub_db.get_current(current_user.id)
mode = _mode_for(row)
selected = row["plan"] if mode == "configure" else None
echeance = (
format_date_french(row["current_period_end"])
if mode == "configure" and row["current_period_end"]
else None
)
sub_info = (
{"current_plan": selected, "status": row["status"], "echeance": echeance}
if mode == "configure"
else {}
)
prefill: dict = {}
try:
prefill = frisbii_client.get_customer(f"colibre-{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'organisme"),
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,
)
form = html.Form(
method="POST",
action="/subscriptions/subscribe"
if mode == "subscribe"
else "/subscriptions/update",
children=[
_csrf_input(),
html.Div(
"Choisissez votre formule :"
if mode == "subscribe"
else "Votre formule :",
id="inf-plan-invite",
className="fw-bold mb-2",
),
_selectable_cards(_trial_for(current_user.id), selected=selected),
html.Div(id="inf-change-hint", className="d-none"),
dcc.Store(id="inf-sub-info", data=sub_info),
dcc.Input(
type="hidden", id="inf-plan-hidden", name="plan", value=selected or ""
),
dbc.Row([col1, col2], className="g-4 mb-4"),
_legal_note(),
_consent_checklists(hidden=(mode == "configure")),
_submit_button(mode),
],
)
return account_shell(
"abonnement",
html.Div(
[
html.H2("Mes informations de facturation", className="mb-4"),
dbc.Alert(
"Informations récupérées depuis le prestataire de paiement, vous pouvez les modifier si besoin.",
color="info",
className="mb-4",
)
if prefill
else None,
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"),
Input("inf-plan-hidden", "value"),
)
def _toggle_submit(retractation, cgu, plan):
return not (retractation and cgu and plan)
@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