Files
colibre/docs/superpowers/specs/2026-07-05-configurer-abonnement-design.md
T
2026-07-05 20:13:48 +02:00

178 lines
9.4 KiB
Markdown

# 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 avec
`timing="immediate"` (aucune échéance à laquelle rattacher un `renewal`) 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 pour `pending`.
## 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).
```python
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`.
1. **Prix à côté de la formule** dans `_active_view`. Réutiliser le motif de
`src/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 le `html.H3(meta["label"])` un
paragraphe discret (`<small>`/muted) avec le prix.
2. **Bouton « Configurer mon abonnement »** : un lien
(`href="/compte/abonnement/mes-infos"`, classe `btn`) ajouté dans les
branches `active`/`trial`/`pending`, aux côtés de « Changer de méthode de
paiement » et « Me désabonner ».
3. **Message de succès** : ajouter dans `_feedback()` une entrée
`maj=succes`_« Votre abonnement a été mis à jour. »_ (couleur `success`).
## 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.Store` caché rempli dans le layout :
`{"current_plan": row["plan"], "echeance": format_date_french(row["current_period_end"])}`,
lu en `State`. En mode `subscribe`, le Store est absent → hint jamais affiché.
- Étendre le callback `_select_plan` (déjà déclenché au clic sur une card) pour
produire aussi `className` + `children` du 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
### 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** :
1. Charger `row = db.get_current(current_user.id)` ; si absent ou sans
`frisbii_subscription_handle``400`.
2. **Mettre à jour les infos de facturation** : construire le dict `billing`
depuis `request.form` (mêmes champs que `subscribe`), persister le SIRET via
`auth_db.set_siret`, appeler `client.update_customer(cust, billing)`.
(Pas de branche 404 : le customer existe déjà pour tout abo
active/trial/pending.)
3. **Changer la formule si différente** : lire `plan` dans le formulaire ; si
`plans.resolve_handle(plan)` diffère de la formule courante
(`row["plan"]`), appeler `client.change_subscription(...)` avec
`timing="immediate"` si `row["status"] == "pending"` sinon `"renewal"`.
4. Sur `client.FrisbiiError``redirect("/compte/abonnement?error=frisbii")`.
5. Sur succès → `redirect("/compte/abonnement?maj=succes", code=303)`.
Nouvelle fonction client dans `src/subscriptions/client.py` :
```python
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.parquet` ne sont pas concernés ; l'état d'abonnement vit dans
SQLite).
- Couvrir : dérivation du `mode` selon le statut ; rendu conditionnel
(cases à cocher présentes/absentes, libellé du bouton, formule
présélectionnée) ; route `update()` (mise à jour customer + appel
`change_subscription` avec le bon `timing`, 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).