457 lines
14 KiB
Python
457 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": html.Span(
|
|
[
|
|
"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):
|
|
from src.utils import TOUS_ABONNES
|
|
|
|
# Sous TOUS_ABONNES, la souscription payante est désactivée : cette page
|
|
# carte bancaire n'a plus de sens, on renvoie vers la page abonnement.
|
|
if TOUS_ABONNES:
|
|
return dcc.Location(
|
|
href="/compte/abonnement", id="mes-infos-tous-abonnes-redirect"
|
|
)
|
|
|
|
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
|