docs(abonnement): plan d'implémentation changement d'abonnement (#109)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,827 @@
|
||||
# Configurer son abonnement (#109) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Permettre à un·e abonné·e `active`, `trial` ou `pending` de changer de formule (simple ↔ soutien) et de mettre à jour ses infos de facturation depuis `/compte/abonnement`, et afficher le prix (HT + TTC) de la formule courante.
|
||||
|
||||
**Architecture:** On réutilise la page `/compte/abonnement/mes-infos` en la « flexibilisant » via un `mode` dérivé de l'état de l'abonnement (`configure` vs `subscribe`), plutôt que de créer une page dupliquée (impossible de partager les `id` de composants Dash entre deux pages). Un nouveau bouton « Configurer mon abonnement » sur `/compte/abonnement` y renvoie. Le changement de formule passe par une nouvelle route `/subscriptions/update` qui appelle `update_customer` puis, si la formule change, un nouvel endpoint client `change_subscription` (`PUT /v1/subscription/{handle}`), sans redirection vers un checkout.
|
||||
|
||||
**Tech Stack:** Dash 3.4, Dash Bootstrap Components, Flask (blueprint `subscriptions`), httpx (client Frisbii/Reepay), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `src.subscriptions.client`), jamais `subscriptions.client`.
|
||||
- `suppress_callback_exceptions=True` est déjà activé (`src/app.py:88`) : les composants rendus conditionnellement (cases à cocher absentes en mode `configure`) ne cassent pas les callbacks — un callback dont un `Input` n'existe pas ne se déclenche simplement pas.
|
||||
- TVA France = 20 % : prix TTC = `round(prix_ht * 1.2, 2)`, formaté avec `:g` pour éviter les artefacts flottants (ex. `28.8`, pas `28.799999999999997`).
|
||||
- Effet du changement de formule : `timing="renewal"` (prochaine échéance) pour `active`/`trial` ; `timing="immediate"` pour `pending` (pas d'échéance à laquelle rattacher un renouvellement).
|
||||
- Avant `git add`/`git commit`, exécuter `pre-commit` (ruff formate ; prettier reformate le markdown). Terminer chaque message de commit par `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
- Lancer les tests avec `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici). Chaque tâche ne lance QUE son fichier de test ; la suite complète (`uv run pytest`, sans chemin) uniquement à la toute fin.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Endpoint client `change_subscription`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/client.py` (ajouter la fonction en fin de fichier)
|
||||
- Test: `tests/subscriptions/test_client.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `client.change_subscription(sub_handle: str, plan_handle: str, timing: str = "renewal") -> dict` — appelle `PUT /v1/subscription/{sub_handle}` avec le corps `{"timing": timing, "plan": plan_handle}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_client.py` :
|
||||
|
||||
```python
|
||||
def test_change_subscription_sends_timing_and_plan(fake_httpx):
|
||||
fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "abo-1-1"}))
|
||||
client.change_subscription("abo-1-1", "plan_soutien", timing="renewal")
|
||||
call = fake_httpx["calls"][0]
|
||||
assert call["method"] == "PUT"
|
||||
assert call["url"] == "https://api.test/v1/subscription/abo-1-1"
|
||||
assert call["json"] == {"timing": "renewal", "plan": "plan_soutien"}
|
||||
|
||||
|
||||
def test_change_subscription_immediate_timing(fake_httpx):
|
||||
fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "abo-1-2"}))
|
||||
client.change_subscription("abo-1-2", "plan_simple", timing="immediate")
|
||||
assert fake_httpx["calls"][0]["json"]["timing"] == "immediate"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py::test_change_subscription_sends_timing_and_plan -v`
|
||||
Expected: FAIL with `AttributeError: module 'src.subscriptions.client' has no attribute 'change_subscription'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Ajouter en fin de `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},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -v`
|
||||
Expected: PASS (tous, dont les deux nouveaux)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/subscriptions/client.py tests/subscriptions/test_client.py
|
||||
git add src/subscriptions/client.py tests/subscriptions/test_client.py
|
||||
git commit -m "feat(abonnement): client.change_subscription (PUT /v1/subscription) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Route `/subscriptions/update`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/routes.py` (ajouter la route après `change_payment_method`, avant `cancel`)
|
||||
- Test: `tests/subscriptions/test_routes.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `client.change_subscription(...)` (Task 1), `client.update_customer`, `db.get_current`, `plans.resolve_handle`, `auth_db.set_siret`.
|
||||
- Produces: route `POST /subscriptions/update` — met à jour le customer Frisbii et, si la formule diffère, appelle `change_subscription` ; redirige `303` vers `/compte/abonnement?maj=succes`.
|
||||
|
||||
Comportement : valide d'abord le plan (400 si inconnu), puis vérifie l'abonnement (400 si aucun), met à jour les infos de facturation, change la formule seulement si le handle diffère. `timing="immediate"` si `status == "pending"`, sinon `"renewal"`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_routes.py` :
|
||||
|
||||
```python
|
||||
def test_update_changes_plan_and_redirects(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
captured = {}
|
||||
|
||||
def fake_change(sub_handle, plan_handle, timing="renewal"):
|
||||
captured.update(sub_handle=sub_handle, plan_handle=plan_handle, timing=timing)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(frisbii_client, "change_subscription", fake_change)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 303
|
||||
assert "maj=succes" in resp.headers["Location"]
|
||||
assert captured["sub_handle"] == handle
|
||||
assert captured["plan_handle"] == "plan_soutien"
|
||||
assert captured["timing"] == "renewal"
|
||||
|
||||
|
||||
def test_update_pending_uses_immediate_timing(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
frisbii_client,
|
||||
"change_subscription",
|
||||
lambda sub_handle, plan_handle, timing="renewal": captured.update(timing=timing),
|
||||
)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 303
|
||||
assert captured["timing"] == "immediate"
|
||||
|
||||
|
||||
def test_update_same_plan_skips_change_subscription(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
called = []
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
monkeypatch.setattr(
|
||||
frisbii_client,
|
||||
"change_subscription",
|
||||
lambda *a, **k: called.append(a),
|
||||
)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code == 303
|
||||
assert "maj=succes" in resp.headers["Location"]
|
||||
assert called == [], "formule inchangée : change_subscription ne doit pas être appelé"
|
||||
|
||||
|
||||
def test_update_without_subscription_returns_400(logged_in_client):
|
||||
client, _ = logged_in_client
|
||||
resp = client.post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_update_unknown_plan_returns_400(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
resp = client.post("/subscriptions/update", data={"plan": "bidon"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_update_api_error_redirects(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
|
||||
def boom(h, d):
|
||||
raise frisbii_client.FrisbiiError(500, "boom")
|
||||
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", boom)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 302
|
||||
assert "error=frisbii" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_update_requires_login(sub_app):
|
||||
resp = sub_app.test_client().post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code in (302, 401)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py::test_update_changes_plan_and_redirects -v`
|
||||
Expected: FAIL avec `404` (route inexistante)
|
||||
|
||||
- [ ] **Step 3: Write the route**
|
||||
|
||||
Dans `src/subscriptions/routes.py`, insérer après la fonction `change_payment_method` (avant `cancel`) :
|
||||
|
||||
```python
|
||||
@subscriptions_bp.route("/subscriptions/update", methods=["POST"])
|
||||
@login_required
|
||||
def update():
|
||||
new_plan_key = request.form.get("plan") or ""
|
||||
new_handle = plans.resolve_handle(new_plan_key)
|
||||
if new_handle is None:
|
||||
return "Plan inconnu", 400
|
||||
|
||||
row = db.get_current(current_user.id)
|
||||
if row is None or not row["frisbii_subscription_handle"]:
|
||||
return "Aucun abonnement à mettre à jour", 400
|
||||
|
||||
cust = _customer_handle(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"]
|
||||
|
||||
try:
|
||||
if siret:
|
||||
auth_db.set_siret(current_user.id, siret)
|
||||
client.update_customer(cust, billing)
|
||||
if new_handle != plans.resolve_handle(row["plan"]):
|
||||
timing = "immediate" if row["status"] == "pending" else "renewal"
|
||||
client.change_subscription(
|
||||
row["frisbii_subscription_handle"], new_handle, timing
|
||||
)
|
||||
except client.FrisbiiError:
|
||||
logger.exception("Échec de mise à jour de l'abonnement Frisbii")
|
||||
return redirect("/compte/abonnement?error=frisbii")
|
||||
return redirect("/compte/abonnement?maj=succes", code=303)
|
||||
```
|
||||
|
||||
(Aucun import à ajouter : `client`, `db`, `plans`, `auth_db`, `logger`, `current_user`, `request`, `redirect` sont déjà importés dans ce fichier.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -v`
|
||||
Expected: PASS (tous)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/subscriptions/routes.py tests/subscriptions/test_routes.py
|
||||
git add src/subscriptions/routes.py tests/subscriptions/test_routes.py
|
||||
git commit -m "feat(abonnement): route /subscriptions/update (maj formule + facturation) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Page `/compte/abonnement` — prix, bouton « Configurer », message de succès
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement.py` (`_active_view`, `_feedback`, + nouveau helper `_price_text`)
|
||||
- Test: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `_price_text(meta: dict) -> str | None` ; `_active_view` affiche le prix sous le libellé et un lien « Configurer mon abonnement » (statuts `pending`/`trial`/`active`) ; `_feedback` gère `maj=succes`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_compte_abonnement.py` :
|
||||
|
||||
```python
|
||||
def test_price_text_rounds_ttc():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
assert compte_abonnement._price_text({"prix_ht": 20}) == "20 € HT / mois (24 € TTC)"
|
||||
assert compte_abonnement._price_text({"prix_ht": 50}) == "50 € HT / mois (60 € TTC)"
|
||||
assert compte_abonnement._price_text({"label": "x"}) is None
|
||||
|
||||
|
||||
def test_active_view_shows_price():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "active",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "24 € TTC" in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_active_view_shows_configure_button_for_active():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "active",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
text = str(compte_abonnement._active_view(row))
|
||||
assert "Configurer mon abonnement" in text
|
||||
assert "href='/compte/abonnement/mes-infos'" in text
|
||||
|
||||
|
||||
def test_active_view_shows_configure_button_for_pending():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {"plan": "simple", "status": "pending", "current_period_end": None}
|
||||
assert "Configurer mon abonnement" in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_active_view_hides_configure_button_for_cancelled():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "cancelled",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "Configurer mon abonnement" not in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_feedback_maj_succes():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._feedback({"maj": "succes"}))
|
||||
assert "Votre abonnement a été mis à jour." in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py::test_price_text_rounds_ttc -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_price_text'`
|
||||
|
||||
- [ ] **Step 3a: Add `_price_text` helper**
|
||||
|
||||
Dans `src/pages/compte/abonnement.py`, ajouter avant `_active_view` :
|
||||
|
||||
```python
|
||||
def _price_text(meta: dict) -> str | None:
|
||||
ht = meta.get("prix_ht")
|
||||
if ht is None:
|
||||
return None
|
||||
return f"{ht} € HT / mois ({round(ht * 1.2, 2):g} € TTC)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Show price in `_active_view`**
|
||||
|
||||
Dans `_active_view`, remplacer :
|
||||
|
||||
```python
|
||||
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
||||
end = format_date_french(row["current_period_end"])
|
||||
blocks = [html.H3(meta["label"], className="mb-3")]
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
||||
end = format_date_french(row["current_period_end"])
|
||||
blocks = [html.H3(meta["label"], className="mb-1")]
|
||||
price = _price_text(meta)
|
||||
if price:
|
||||
blocks.append(html.P(price, className="text-muted mb-3"))
|
||||
```
|
||||
|
||||
- [ ] **Step 3c: Add « Configurer mon abonnement » button**
|
||||
|
||||
Toujours dans `_active_view`, juste avant le bloc `if row["status"] in ("trial", "active"):` (celui du formulaire « Changer de méthode de paiement »), insérer :
|
||||
|
||||
```python
|
||||
if row["status"] in ("pending", "trial", "active"):
|
||||
blocks.append(
|
||||
html.A(
|
||||
"Configurer mon abonnement",
|
||||
href="/compte/abonnement/mes-infos",
|
||||
className="btn btn-outline-primary mt-3 me-2",
|
||||
)
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 3d: Handle `maj=succes` in `_feedback`**
|
||||
|
||||
Dans `_feedback`, ajouter avant `return out` :
|
||||
|
||||
```python
|
||||
if query.get("maj") == "succes":
|
||||
out.append(dbc.Alert("Votre abonnement a été mis à jour.", color="success"))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: PASS (tous, y compris les tests existants qui doivent rester verts)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "feat(abonnement): prix + bouton Configurer sur /compte/abonnement (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `mes-infos` — mode configure/subscribe
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement_mes_infos.py`
|
||||
- Test: `tests/subscriptions/test_mes_infos_plan.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
|
||||
- `_mode_for(row) -> str` — `"configure"` si `row` et `row["status"]` ∈ {active, trial, pending}, sinon `"subscribe"`.
|
||||
- `_submit_button(mode: str) -> html.Button` — `id="inf-submit"`, libellé + `disabled` selon le mode.
|
||||
- `_selectable_cards(trial_for, selected=None)` — la card dont la clé == `selected` reçoit la classe `selected`.
|
||||
- `_consent_checklists() -> list` et `_legal_note()` — extraits de l'ancien bloc `checkboxes`.
|
||||
- `layout()` rend un `dcc.Store(id="inf-sub-info")` et un `html.Div(id="inf-change-hint")` dans les deux modes (Task 5 s'en sert).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_mes_infos_plan.py` :
|
||||
|
||||
```python
|
||||
def test_mode_for_derives_from_status():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
for status in ("active", "trial", "pending"):
|
||||
assert m._mode_for({"status": status}) == "configure"
|
||||
assert m._mode_for({"status": "cancelled"}) == "subscribe"
|
||||
assert m._mode_for({"status": "expired"}) == "subscribe"
|
||||
assert m._mode_for(None) == "subscribe"
|
||||
|
||||
|
||||
def test_submit_button_subscribe_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
text = str(m._submit_button("subscribe"))
|
||||
assert "Ajouter une carte de paiement" in text
|
||||
assert "disabled" in text
|
||||
|
||||
|
||||
def test_submit_button_configure_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
btn = m._submit_button("configure")
|
||||
text = str(btn)
|
||||
assert "Mettre à jour mon abonnement" in text
|
||||
assert btn.disabled is False
|
||||
|
||||
|
||||
def test_selectable_cards_preselects_current_plan():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
text = str(m._selectable_cards(trial_for=lambda key: None, selected="soutien"))
|
||||
# la card soutien est marquée sélectionnée, pas la card simple
|
||||
assert "plan-selectable selected" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py::test_mode_for_derives_from_status -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_mode_for'`
|
||||
|
||||
- [ ] **Step 3a: Add import + helpers**
|
||||
|
||||
En haut de `src/pages/compte/abonnement_mes_infos.py`, ajouter l'import :
|
||||
|
||||
```python
|
||||
from src.utils.frontend import format_date_french
|
||||
```
|
||||
|
||||
Ajouter ces helpers (par ex. après `_trial_for`) :
|
||||
|
||||
```python
|
||||
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-primary",
|
||||
disabled=(mode == "subscribe"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Add `selected` param to `_selectable_cards`**
|
||||
|
||||
Remplacer la signature et le corps de `_selectable_cards` :
|
||||
|
||||
```python
|
||||
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")
|
||||
```
|
||||
|
||||
- [ ] **Step 3c: Extract legal note + consent checklists**
|
||||
|
||||
Remplacer le bloc `checkboxes = html.Div([...])` (dans `layout`) par deux helpers au niveau module :
|
||||
|
||||
```python
|
||||
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():
|
||||
return [
|
||||
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",
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 3d: Rewire `layout()`**
|
||||
|
||||
Au début de `layout`, après le `guard`, calculer le mode et les valeurs dérivées :
|
||||
|
||||
```python
|
||||
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 {}
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le `html.Form(...)` existant par :
|
||||
|
||||
```python
|
||||
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() if mode == "subscribe" else []),
|
||||
_submit_button(mode),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Enfin, ne rendre la modale CGU qu'en mode `subscribe`. Remplacer le `return account_shell(...)` final par :
|
||||
|
||||
```python
|
||||
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() if mode == "subscribe" else None,
|
||||
]
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: PASS (nouveaux + existants `test_selectable_cards_render_both_plans`, `test_selection_state_*`, `test_submit_disabled_without_plan`)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git commit -m "feat(abonnement): mode configure sur mes-infos (formule préactivée, sans cases) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `mes-infos` — hint « prochaine échéance »
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement_mes_infos.py` (`_change_hint` + callback `_select_plan`)
|
||||
- Test: `tests/subscriptions/test_mes_infos_plan.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `dcc.Store(id="inf-sub-info")` et `html.Div(id="inf-change-hint")` rendus en Task 4.
|
||||
- Produces: `_change_hint(selected: str, sub_info: dict | None) -> tuple[str, str]` — `(className, children)` du hint ; le callback `_select_plan` renvoie désormais 6 sorties (les 4 existantes + className/children du hint).
|
||||
|
||||
Règles : affiché uniquement si `sub_info` a un `current_plan`, `status` ∈ {active, trial}, et `selected != current_plan`. Sinon masqué (`d-none`, texte vide). `pending` → toujours masqué.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_mes_infos_plan.py` :
|
||||
|
||||
```python
|
||||
def test_change_hint_shown_when_plan_differs():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, txt = m._change_hint(
|
||||
"soutien",
|
||||
{"current_plan": "simple", "status": "active", "echeance": "1er janvier"},
|
||||
)
|
||||
assert cls != "d-none"
|
||||
assert "prochaine échéance : 1er janvier" in txt
|
||||
|
||||
|
||||
def test_change_hint_hidden_when_same_plan():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, txt = m._change_hint(
|
||||
"simple",
|
||||
{"current_plan": "simple", "status": "active", "echeance": "1er janvier"},
|
||||
)
|
||||
assert cls == "d-none"
|
||||
assert txt == ""
|
||||
|
||||
|
||||
def test_change_hint_hidden_for_pending():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, _ = m._change_hint(
|
||||
"soutien",
|
||||
{"current_plan": "simple", "status": "pending", "echeance": None},
|
||||
)
|
||||
assert cls == "d-none"
|
||||
|
||||
|
||||
def test_change_hint_hidden_in_subscribe_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, _ = m._change_hint("soutien", {})
|
||||
assert cls == "d-none"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py::test_change_hint_shown_when_plan_differs -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_change_hint'`
|
||||
|
||||
- [ ] **Step 3a: Add `_change_hint` helper**
|
||||
|
||||
Dans `src/pages/compte/abonnement_mes_infos.py`, ajouter :
|
||||
|
||||
```python
|
||||
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}.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Extend the `_select_plan` callback**
|
||||
|
||||
Remplacer le callback `_select_plan` et son décorateur par :
|
||||
|
||||
```python
|
||||
@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
|
||||
```
|
||||
|
||||
(`State` est déjà importé depuis `dash` en tête de fichier.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: PASS (tous)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git commit -m "feat(abonnement): message 'changement à la prochaine échéance' sous les cards (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Vérification finale
|
||||
|
||||
- [ ] **Step 1: Run the whole subscriptions test suite**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ -v`
|
||||
Expected: PASS (aucune régression)
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: PASS (les tests Selenium nécessitent Chrome/Chromium ; si l'environnement n'en a pas, noter les échecs Selenium comme non liés et confirmer que `tests/subscriptions/` est vert).
|
||||
|
||||
- [ ] **Step 3: Sanity-check manuel (optionnel mais recommandé)**
|
||||
|
||||
Lancer l'app (`uv run run.py`), se connecter avec un compte ayant un abonnement `active`, aller sur `/compte/abonnement` : vérifier le prix affiché et le bouton « Configurer mon abonnement ». Cliquer → `/compte/abonnement/mes-infos` : formule courante présélectionnée, pas de cases à cocher, bouton « Mettre à jour mon abonnement ». Cliquer sur l'autre formule → le message « … prochaine échéance : {date} » apparaît.
|
||||
|
||||
---
|
||||
|
||||
## Notes d'implémentation
|
||||
|
||||
- **Pourquoi `inf-change-hint` et `inf-sub-info` sont rendus dans les deux modes** : le callback `_select_plan` est enregistré une seule fois et référence ces `id` en sortie/état. Les rendre toujours (vides en mode `subscribe`) évite tout souci de composant manquant et garde le callback identique quel que soit le mode.
|
||||
- **Pourquoi `_toggle_submit` n'est pas modifié** : ses `Input` (`inf-cb-retractation`, `inf-cb-cgu`) n'existent pas en mode `configure`, donc le callback ne se déclenche pas et le bouton conserve son `disabled=False` défini par `_submit_button("configure")`. En mode `subscribe`, comportement inchangé.
|
||||
- **Timing facturation** : `update_customer` prend effet immédiatement (adresse/SIRET), tandis que le changement de formule est différé à l'échéance (`renewal`) — d'où le message de succès générique « Votre abonnement a été mis à jour. ».
|
||||
Reference in New Issue
Block a user