Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.4 KiB
Configurer son abonnement (#109)
Contexte
Sur /compte/abonnement, un·e abonné·e active, trial ou pending ne
peut aujourd'hui ni changer de formule (simple ↔ soutien) ni mettre à jour
ses informations de facturation. Cette fonctionnalité l'ajoute, en réutilisant
la page /compte/abonnement/mes-infos déjà utilisée pour l'abonnement initial.
On en profite pour afficher le prix (HT + TTC) à côté de la formule courante
sur /compte/abonnement.
Décisions produit
- Effet du changement de formule : à la prochaine échéance
(
timing="renewal"), sans proratisation ni remboursement. Simple à expliquer, aucun mouvement d'argent immédiat. - Cas
pending(abonnement créé mais sans méthode de paiement, billing non démarré) : mise à jour API directe. La formule change avectiming="immediate"(aucune échéance à laquelle rattacher unrenewal) et les infos de facturation sont mises à jour. L'ajout de carte reste un bouton séparé sur/compte/abonnement. - Hint « prochaine échéance » sous les cards : affiché uniquement pour
active/trial. Rien pourpending.
Architecture : flexibiliser mes-infos (pas de nouvelle page)
/compte/abonnement/mes-infos sert déjà ~80 % de ce dont on a besoin : le
formulaire de facturation à deux colonnes (10 champs), la recherche SIRET et
son callback, les cards de formule sélectionnables et _select_plan, le
préremplissage Frisbii, la modale CGU.
Les deux scénarios ne diffèrent que par : présence des cases à cocher, libellé du bouton, cible/logique du formulaire, formule présélectionnée.
Décision : flexibiliser mes-infos avec un mode dérivé de l'état de
l'abonnement, plutôt qu'une nouvelle page. En Dash, deux pages ne peuvent pas
partager les mêmes id de composants : une page dupliquée forcerait à
renommer chaque id et à dupliquer les quatre callbacks (_select_plan,
_lookup_siret, _toggle_submit, _toggle_cgu) — soit l'essentiel du
fichier dupliqué pour 20 % de différence. suppress_callback_exceptions=True
(déjà activé, src/app.py:88) rend sûrs les composants rendus
conditionnellement.
Le mode est dérivé de l'état, pas d'un paramètre d'URL. Le bouton
« Configurer mon abonnement » et le bouton « M'abonner » pointent tous deux
vers /compte/abonnement/mes-infos ; la page s'adapte. Cela reprend la logique
du garde déjà présent dans subscribe() (refus d'un second abonnement quand
un est actif).
row = db.get_current(current_user.id)
mode = "configure" if row and row["status"] in ("active", "trial", "pending") else "subscribe"
Section 1 — page /compte/abonnement
Fichier : src/pages/compte/abonnement.py.
- Prix à côté de la formule dans
_active_view. Réutiliser le motif desrc/pages/a_propos/abonnement.py:34-35:{prix_ht} € HT / mois ({prix_ht * 1.2:g} € TTC).plans.plan_meta()retourne déjàprix_ht. Ajouter sous lehtml.H3(meta["label"])un paragraphe discret (<small>/muted) avec le prix. - Bouton « Configurer mon abonnement » : un lien
(
href="/compte/abonnement/mes-infos", classebtn) ajouté dans les branchesactive/trial/pending, aux côtés de « Changer de méthode de paiement » et « Me désabonner ». - Message de succès : ajouter dans
_feedback()une entréemaj=succes→ « Votre abonnement a été mis à jour. » (couleursuccess).
Section 2 — page /compte/abonnement/mes-infos
Fichier : src/pages/compte/abonnement_mes_infos.py.
layout() calcule row = db.get_current(current_user.id) et en dérive mode.
Rendu conditionnel piloté par mode :
| Élément | subscribe (actuel) |
configure (nouveau) |
|---|---|---|
| Cards de formule | aucune présélectionnée | formule courante (row["plan"]) présélectionnée via _selection_state |
| Cases à cocher (rétractation + CGU) | affichées | omises |
| Modale CGU | affichée | omise |
| Libellé du bouton | « Ajouter une carte de paiement » | « Mettre à jour mon abonnement » |
disabled initial du bouton |
True |
False (une formule est déjà sélectionnée) |
action du formulaire |
/subscriptions/subscribe |
/subscriptions/update (nouvelle route) |
| Texte d'intro | « Choisissez votre formule : » | « Votre formule : » |
Les colonnes de facturation (col1/col2), la recherche SIRET et le
préremplissage Frisbii sont identiques dans les deux modes.
Hint « prochaine échéance »
- Élément message masqué sous les cards :
id="inf-change-hint",className="d-none"au départ (texte discret / info). - La formule courante et la date d'échéance sont transmises au callback via un
dcc.Storecaché rempli dans le layout :{"current_plan": row["plan"], "echeance": format_date_french(row["current_period_end"])}, lu enState. En modesubscribe, le Store est absent → hint jamais affiché. - Étendre le callback
_select_plan(déjà déclenché au clic sur une card) pour produire aussiclassName+childrendu hint :- formule sélectionnée == formule courante → masqué (
d-none) - formule sélectionnée != formule courante et statut ∈ {active, trial} → affiché : « Le changement d'abonnement sera appliqué à la prochaine échéance : {echeance}. »
- statut
pending→ rien (masqué) quelle que soit la sélection
- formule sélectionnée == formule courante → masqué (
Callbacks
Les callbacks partagés (_select_plan, _lookup_siret, _toggle_cgu)
référencent des id qui existent toujours en mode configure (sauf
_toggle_cgu, dont les composants sont absents — toléré par
suppress_callback_exceptions).
_toggle_submit dépend aujourd'hui des deux cases à cocher, absentes en mode
configure. En mode configure le bouton démarre activé et n'est gardé par
aucune case ; le callback dégrade gracieusement (ses Input de cases ne sont
pas rendus). Forme exacte à confirmer à l'écriture du plan (p. ex. retour
False/no_update quand une formule est présente).
Section 3 — route /subscriptions/update + client
Fichier : src/subscriptions/routes.py.
Nouvelle route update() (@login_required), de forme proche de
subscribe() mais sans redirection vers un checkout :
- Charger
row = db.get_current(current_user.id); si absent ou sansfrisbii_subscription_handle→400. - Mettre à jour les infos de facturation : construire le dict
billingdepuisrequest.form(mêmes champs quesubscribe), persister le SIRET viaauth_db.set_siret, appelerclient.update_customer(cust, billing). (Pas de branche 404 : le customer existe déjà pour tout abo active/trial/pending.) - Changer la formule si différente : lire
plandans le formulaire ; siplans.resolve_handle(plan)diffère de la formule courante (row["plan"]), appelerclient.change_subscription(...)avectiming="immediate"sirow["status"] == "pending"sinon"renewal". - Sur
client.FrisbiiError→redirect("/compte/abonnement?error=frisbii"). - Sur succès →
redirect("/compte/abonnement?maj=succes", code=303).
Nouvelle fonction client dans src/subscriptions/client.py :
def change_subscription(sub_handle: str, plan_handle: str, timing: str = "renewal") -> dict:
return _call(
"PUT",
f"/v1/subscription/{sub_handle}",
json={"timing": timing, "plan": plan_handle},
)
Corps validé contre le schéma OpenAPI ChangeSubscription (endpoint
PUT /v1/subscription/{handle}, « Change subscription ») : timing est le
seul champ requis ; plan déclenche le changement de formule. Aucun paramètre
de proratisation puisque le changement est à la prochaine échéance.
Note sur le timing des infos de facturation : update_customer s'applique
immédiatement au customer, donc un changement d'adresse/SIRET prend effet tout
de suite même si le changement de formule est différé à l'échéance. C'est
normal (adresse/SIRET ne sont pas liés à une période) ; le message de succès
reste générique (« mis à jour ») plutôt que d'impliquer que tout attend
l'échéance.
Tests
- Fichier de test dédié à cette fonctionnalité (les champs d'abonnement de
tests/test.parquetne sont pas concernés ; l'état d'abonnement vit dans SQLite). - Couvrir : dérivation du
modeselon le statut ; rendu conditionnel (cases à cocher présentes/absentes, libellé du bouton, formule présélectionnée) ; routeupdate()(mise à jour customer + appelchange_subscriptionavec le bontiming, court-circuit si formule inchangée, garde 400 sans abonnement) ; affichage du hint selon statut/sélection. client.change_subscription: vérifier le corps JSON envoyé (mock httpx).