Feature #1169
openRemboursement des crédits à la suppression d'un ticket (Soumis/Spec)
0%
issue:self — spec dans la description (décisions + seams)
Description
## Problème
La consommation de crédits au dépôt (#1153) fonctionne (ex. ticket #1158 → −8 évolutions). Mais **supprimer** un ticket ne **rembourse pas** les crédits débités, alors qu'il le devrait : la suppression n'est possible que tant que le ticket est **Soumis ou Spec** (avant le début du dev — déjà garanti par `ticket_is_mutable`), donc aucun travail n'a été consommé et les crédits doivent revenir.
## Décisions (verrouillées)
- **Déclencheur** : toute suppression réussie d'un ticket (l'endpoint delete est déjà restreint à Soumis/Spec via `ticket_is_mutable` → pas de logique de statut supplémentaire à ajouter ; rembourser à chaque delete réussi).
- **Montant** = le montant **réellement débité** au dépôt, lu depuis l'audit billing (`action=ticket_filed`, `reason="ticket:<issue_id>"`) → `old_value - new_value`, **vers le même pool**. (Robuste face à un score édité ou un flooring.)
- **Pas de débit trouvé** (déposé avant #1153, ou pool illimité au dépôt → aucune ligne `ticket_filed`) → **rien à rembourser**, suppression normale.
- **Anti double-remboursement** : ne rembourser que s'il n'existe pas déjà une ligne de remboursement pour ce ticket.
- **Plafond** : ne pas dépasser l'allocation mensuelle du pool (cap à `monthly_credits` — ex. si le client a changé de formule entre-temps).
- **Pool désormais illimité** → pas de remboursement (rien à créditer).
## Portée backend (api/, hexagonal, TDD)
Seams (origin/dev) :
- `application/ticket_management.py` → `DeleteTicket.__call__` (actuellement : `get_issue` → `ticket_is_mutable` guard → `redmine.delete_issue`). Endpoint : `presentation/api/tickets.py` `delete_ticket()` (~193), `owned: OwnedIssue` + client courant disponibles.
- Remboursement via `application/billing.py` `AdjustCredits` (delta **positif**), nouvelle action d'audit `TICKET_REFUNDED` (ou réutiliser une action avec `reason="ticket:<id>"`), pool = celui du débit. Lookup du débit via `AuditRepo.list_for_client` (filtrer `action=ticket_filed` + `reason="ticket:<id>"`).
- Le `client_id` propriétaire doit être passé au use-case (l'endpoint a le client courant / `owned`).
Comportement :
1. (inchangé) garde `ticket_is_mutable` ; supprimer l'issue Redmine (effet externe irréversible).
2. Chercher le débit `ticket_filed` du ticket pour ce client. Absent → fin (pas de remboursement).
3. Déjà remboursé (ligne refund existante pour ce ticket) → skip.
4. Pool désormais illimité → skip. Sinon créditer `min(debited, monthly_credits - remaining)` via `AdjustCredits(+amount)` ; écrire l'audit `TICKET_REFUNDED` `reason="ticket:<id>"` pool. Plafonner à l'allocation.
5. Best-effort : si le remboursement échoue après la suppression (issue déjà supprimée), rollback de la portion billing + log (ne pas 500 la suppression). Ordonner delete → refund(committé) comme pour le dépôt (#1153).
6. Pas de `LowCredits` sur un remboursement (le solde monte).
## Portée frontend (src/)
- Après une suppression réussie (le hook/mutation de `Supprimer le ticket`), **invalider la query des crédits** (`GET /billing/clients/{id}/credits`) + le profil, pour que la jauge « Crédits restants » se mette à jour immédiatement.
- (Optionnel) afficher un retour « X crédits remboursés » si simple ; sinon le rafraîchissement de la jauge suffit. i18n en/fr si texte ajouté.
## Acceptance criteria
- [ ] Supprimer un ticket Soumis/Spec rembourse au pool d'origine le montant exact débité au dépôt (audit `ticket_filed` / `reason="ticket:<id>"`).
- [ ] Ticket sans débit (avant #1153 / pool illimité au dépôt) → suppression sans remboursement, sans erreur.
- [ ] Pas de double remboursement (idempotent / garde sur audit refund existant).
- [ ] Le remboursement ne dépasse jamais l'allocation mensuelle du pool ; pool désormais illimité → pas de remboursement.
- [ ] Le remboursement écrit une entrée d'historique reliée au ticket ; pas d'alerte crédits bas sur un crédit.
- [ ] La jauge profil se met à jour après suppression.
- [ ] Backend : pytest, ruff, mypy verts. Frontend : vitest, tsc, eslint, build verts.
RA Updated by Redmine Admin about 1 month ago
- Status changed from Submitted to Spec
RA Updated by Redmine Admin about 1 month ago
- Status changed from Spec to In development
RA Updated by Redmine Admin about 1 month ago
- Status changed from In development to QA
- branch set to feat/1169-refund-on-delete
- pr_url set to https://github.com/omdev-tech/PipeLiner-Client/pull/86
PR ouverte vers `dev` : https://github.com/omdev-tech/PipeLiner-Client/pull/86
Implémentation (TDD, branchée sur `origin/dev` au-dessus de #1153) :
**Backend** (`api/`, hexagonal)
- Nouveau use case `RefundTicketCredits` (`application/refund_ticket_credits.py`) : retrouve le débit `ticket_filed` du ticket via `AuditRepo.list_for_client` (`reason="ticket:<id>"`), rembourse `old_value - new_value` vers le pool enregistré, **plafonné** à `min(débité, allocation - restant)`. Skip si : aucun débit (déposé avant #1153 / pool illimité au dépôt), déjà remboursé (ligne `ticket_refunded` existante → idempotent), ou pool désormais illimité. Réutilise `AdjustCredits` (delta positif) — un remboursement ne fait que monter le solde, donc **aucun `LowCredits`**.
- Nouvelle action d'audit `AuditAction.TICKET_REFUNDED` (colonne `action` = `String(32)` libre → pas de migration).
- `DeleteTicket.__call__` : garde `ticket_is_mutable` (inchangée) → suppression de l'issue d'abord (irréversible) → remboursement (commit propre) ; un échec après suppression rollback **uniquement** la portion billing + log, la suppression renvoie quand même 204 (jamais 500), mêmes garanties d'isolation que #1153.
- Endpoint `delete_ticket` câble le refunder avec le client courant.
**Frontend** (`src/`)
- `useDeleteTicket` invalide les queries crédits (`creditsQueryKey`) + profil (`profileQueryKey`) au succès quand l'id client est connu → la jauge « Crédits restants » se met à jour immédiatement ; `TaskDetailPane` passe l'id via `useMe()`.
**Tests** : remboursement = montant débité au même pool ; pas de débit → pas de remboursement ; idempotence ; plafond à l'allocation ; pool désormais illimité → skip ; échec après suppression → 204 + log, pas de 500 ; FE invalide crédits/profil après suppression.
**Gates** : Backend `pytest` (687, 2× sur la même base + `alembic upgrade head`), `ruff`, `mypy` verts ; Frontend `vitest` (681), `tsc`, `eslint`, `build` verts.