Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
31 KiB
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), jamaissubscriptions.client. suppress_callback_exceptions=Trueest déjà activé (src/app.py:88) : les composants rendus conditionnellement (cases à cocher absentes en modeconfigure) ne cassent pas les callbacks — un callback dont unInputn'existe pas ne se déclenche simplement pas.- TVA France = 20 % : prix TTC =
round(prix_ht * 1.2, 2), formaté avec:gpour éviter les artefacts flottants (ex.28.8, pas28.799999999999997). - Effet du changement de formule :
timing="renewal"(prochaine échéance) pouractive/trial;timing="immediate"pourpending(pas d'échéance à laquelle rattacher un renouvellement). - Avant
git add/git commit, exécuterpre-commit(ruff formate ; prettier reformate le markdown). Terminer chaque message de commit parCo-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— appellePUT /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 :
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 :
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
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èschange_payment_method, avantcancel) - 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, appellechange_subscription; redirige303vers/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 :
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) :
@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
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_viewaffiche le prix sous le libellé et un lien « Configurer mon abonnement » (statutspending/trial/active) ;_feedbackgèremaj=succes. -
Step 1: Write the failing tests
Ajouter dans tests/subscriptions/test_compte_abonnement.py :
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_texthelper
Dans src/pages/compte/abonnement.py, ajouter avant _active_view :
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 :
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 :
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 :
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=succesin_feedback
Dans _feedback, ajouter avant return out :
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
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"sirowetrow["status"]∈ {active, trial, pending}, sinon"subscribe"._submit_button(mode: str) -> html.Button—id="inf-submit", libellé +disabledselon le mode._selectable_cards(trial_for, selected=None)— la card dont la clé ==selectedreçoit la classeselected._consent_checklists() -> listet_legal_note()— extraits de l'ancien bloccheckboxes.layout()rend undcc.Store(id="inf-sub-info")et unhtml.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 :
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 :
from src.utils.frontend import format_date_french
Ajouter ces helpers (par ex. après _trial_for) :
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
selectedparam to_selectable_cards
Remplacer la signature et le corps de _selectable_cards :
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 :
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 :
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 :
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 :
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
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")ethtml.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_planrenvoie 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 :
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_hinthelper
Dans src/pages/compte/abonnement_mes_infos.py, ajouter :
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_plancallback
Remplacer le callback _select_plan et son décorateur par :
@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
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-hintetinf-sub-infosont rendus dans les deux modes : le callback_select_planest enregistré une seule fois et référence cesiden sortie/état. Les rendre toujours (vides en modesubscribe) évite tout souci de composant manquant et garde le callback identique quel que soit le mode. - Pourquoi
_toggle_submitn'est pas modifié : sesInput(inf-cb-retractation,inf-cb-cgu) n'existent pas en modeconfigure, donc le callback ne se déclenche pas et le bouton conserve sondisabled=Falsedéfini par_submit_button("configure"). En modesubscribe, comportement inchangé. - Timing facturation :
update_customerprend 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. ».