Feature #1453
openSuspendre un mandat d'un bénéficiaire individuellement, sans suspendre le bénéficiaire ni ses autres mandats
0%
Description
### Problème
La suspension se fait aujourd'hui **uniquement au niveau du bénéficiaire**, et elle est globale : suspendre un bénéficiaire ferme **tous** ses mandats d'un coup.
Or il arrive de vouloir **suspendre un seul mandat** d'un bénéficiaire (arrêt d'une offre, changement de mandat, fin d'un contrat spécifique) **sans toucher au bénéficiaire ni à ses autres mandats**, qui doivent rester pleinement actifs.
Il n'existe aujourd'hui **aucune action de suspension d'un mandat dans l'interface**.
### Contexte technique (état actuel)
**Quatre chemins de fermeture coexistent en base, avec trois résultats différents :**
| Chemin | `wallet_beneficiary` | `wallet.deleted_at` | Droits | Exposé au front ? |
|---|---|---|---|---|
| `DELETE /beneficiary/{id}` (suspension bénéficiaire) — `db_beneficiary.go:457` | `deleted_at` + `status='closed'` (TOUS) | posé (TOUS) | Admin/Operator | oui |
| `POST /wallets/{wallet_id}/close` — `wallet.go:43` → `CloseWallet` (`db_wallet.go:160`) | `deleted_at` + `status='closed'` | **posé** | **Admin seul** | **non** |
| `POST /beneficiary/{id}/wallets/{wid}/close` — `beneficiary.go:68` → `beneficiary_command.go:393` | `status='closed'` seul | non posé | Admin/Operator | **non** |
| `DELETE /beneficiary/{id}/wallets/{wid}` (`RemoveWallet`) — `db_wallet_beneficiary.go:242` | `deleted_at` + `status='closed'` | **non posé** | — | oui |
**Le calcul des états de wallet dépend de `wallet.deleted_at`, pas du statut.** Le cron `GenerateStateWalletBeneficiaries` (`internal/cron-tasks/kraken_requests.go:807`) ne saute que deux cas :
- `if wallet.DeletedAt != nil { continue }` (`kraken_requests.go:819`) — clé sur **`wallet.deleted_at` uniquement** ;
- `IsDraft` (`wallet_query.go:490`), qui ne teste que `status == 'draft'` — un statut `closed` **n'est pas filtré**.
Conséquence directe : une fermeture **par statut seul** ne stoppe pas le cron, et le mandat continuerait d'accumuler des états quotidiens indéfiniment. Une fermeture **avec `wallet.deleted_at`** stoppe bien le cron, mais alors **l'état final du jour de clôture n'est jamais généré** si on ne le produit pas au préalable. C'est déjà documenté dans le code (`internal/rest/server/wallet.go:209`) :
> *« Closing stops the daily cron from generating states for the wallet, so the recommended order is: generate the final state for the closing date first (`POST /wallets/{wallet_id}/generate_state?date=YYYY-MM-DD`), then close. »*
L'endpoint `POST /wallets/{wallet_id}/generate_state?date=YYYY-MM-DD` (`wallet.go:44`, Admin) existe et **fonctionne volontairement sur les wallets clôturés** : il reproduit exactement la logique du cron (construction depuis les ordres non statés du jour — cas courant le jour de clôture, typiquement l'ordre de vente qui vide le wallet — sinon snapshot quotidien). Rejouer un jour déjà staté est un no-op.
**Effet déjà acquis du statut `closed`** : `GetActiveBeneficiaryFromWalletID` (`db_wallet_beneficiary.go:296`, `... and wb.status = 'active'`) est appelé par `sales_order_command.go:451` et `:893` — un mandat `closed` n'est déjà plus éligible aux attributions de nouveaux ordres.
**En revanche, ni le statut ni `deleted_at` ne sont filtrés de façon homogène en lecture** : les requêtes de listing (`db_wallet_beneficiary.go:41`, `:127`, `:148`, `:415`, `:437`, `:477`) et les vues BFF filtrent sur `deleted_at is null` sans considérer le statut.
### Comportement proposé
**Semantique retenue : cascade complète sur le seul mandat visé, précédée de la génération de l'état final. Action définitive.**
L'action « Suspendre le mandat » exécute, pour **un unique mandat** :
1. **Génération de l'état final à la date de clôture** — même logique que `GenerateState` (`wallet.go:246`), *avant* la fermeture, tant que le cron peut encore produire un état cohérent.
2. **Fermeture en cascade**, en une transaction, à l'image de `CloseWallet` (`db_wallet.go:160`) :
- `wallet_beneficiary.deleted_at = now()` et `status = 'closed'`
- `wallet.deleted_at = now()`
3. Le **bénéficiaire et tous ses autres mandats restent strictement intacts et actifs** — aucun `deleted_at` sur `beneficiary`, aucune modification des autres `wallet_beneficiary`.
L'enchaînement génération → fermeture doit être **porté par l'API elle-même**, et non laissé à un appel curl manuel d'administrateur : c'est précisément l'ordre des opérations qui garantit la présence de l'état final.
**Droits d'accès** : l'action doit être ouverte aux rôles **Admin et Operator**. Le endpoint cascade existant `POST /wallets/{wallet_id}/close` est aujourd'hui **Admin seul** (`wallet.go:217`) : il faut l'élargir à Operator, en cohérence avec `POST /beneficiary/{id}/wallets/{wid}/close` et avec la suspension bénéficiaire, déjà tous deux Admin+Operator.
**Interface** : action « Suspendre le mandat » sur chaque mandat de la fiche bénéficiaire, avec confirmation explicite rappelant que l'action est **définitive** et qu'elle n'affecte ni le bénéficiaire ni ses autres mandats. Le mandat suspendu reste consultable, identifié comme tel, avec sa date de clôture.
### Points ouverts à trancher
1. **Droits sur `generate_state`.** `POST /wallets/{wallet_id}/generate_state` est Admin seul (`wallet.go:~270`). Si la génération de l'état final est déclenchée **en interne** par l'action de suspension, aucun élargissement n'est nécessaire. Elle ne doit être ouverte à Operator que si l'on souhaite aussi exposer l'endpoint directement.
2. **Date de clôture choisie.** Par défaut la date du jour. À confirmer : faut-il autoriser une date de clôture antérieure (mandat effectivement arrêté il y a quelques jours) ? Cela impliquerait de générer l'état final à cette date et non à aujourd'hui.
3. **Endpoint `/activate` existant.** `POST /beneficiary/{id}/wallets/{wid}/activate` est exposé côté API **et** côté web (`beneficiaryWalletApi.ts:31`) et repasse le statut à `active` — mais **sans lever `wallet.deleted_at`**, ce qui produirait un état incohérent sur un mandat suspendu. À restreindre aux mandats en brouillon, ou à corriger explicitement.
4. **Harmonisation des quatre chemins de fermeture** (tableau ci-dessus) : `RemoveWallet` ne pose notamment jamais `wallet.deleted_at`, et `beneficiary_command.Close` ne pose que le statut. Mériterait son propre ticket de consolidation.
## Acceptance criteria
- [ ] Depuis la fiche d'un bénéficiaire, un **Admin ou un Operator** peut **suspendre un mandat précis**, via une confirmation explicite indiquant que l'action est définitive.
- [ ] **Un état de wallet est généré pour la date de clôture**, avant la fermeture — y compris lorsque le jour de clôture porte des ordres non statés (cas de l'ordre de vente qui vide le mandat), qui doivent être pris en compte dans cet état final.
- [ ] Après suspension, en une **transaction** : `wallet_beneficiary.deleted_at` et `status='closed'` sont posés, ainsi que `wallet.deleted_at`, **pour ce seul mandat**.
- [ ] Le **bénéficiaire reste actif**, et **tous ses autres mandats restent inchangés** (aucun `deleted_at`, statut préservé) et pleinement fonctionnels.
- [ ] Le **cron cesse de générer des états** pour le mandat suspendu à partir du lendemain de la clôture — aucun état quotidien parasite après la date de clôture (`kraken_requests.go:819` saute désormais ce wallet).
- [ ] Le dernier état du mandat suspendu correspond bien à la **date de clôture**, et sa valorisation finale est consultable.
- [ ] Le mandat suspendu **n'accepte plus de nouvelle attribution d'ordre** (test de non-régression sur `GetActiveBeneficiaryFromWalletID`).
- [ ] Le mandat suspendu **n'est plus compté comme mandat actif** dans les listes de mandats, la fiche bénéficiaire, les vues BFF et les KPI/agrégats de valorisation.
- [ ] Le mandat suspendu **reste consultable** avec tout son historique (ordres, états, valorisations, transactions), et son statut est visible sans ambiguïté dans l'interface.
- [ ] `POST /wallets/{wallet_id}/close` est **élargi au rôle Operator** (aujourd'hui Admin seul, `wallet.go:217`), en cohérence avec les autres chemins de fermeture.
- [ ] L'action est **tracée** (audit / event log) avec l'auteur, le mandat et l'horodatage.
- [ ] Suspendre un mandat **n'altère pas les données comptables** : le mandat et ses opérations restent présents dans l'export compta crypto pour les périodes concernées.
- [ ] La **colonne « Date de clôture » de #1427 est correctement renseignée** pour un mandat suspendu (elle lit `wallet.deleted_at`, désormais posé).
- [ ] Aucune régression sur la suspension d'un bénéficiaire, qui continue de fermer l'ensemble de ses mandats comme aujourd'hui.
## Classification
- feature
## Complexity
- 6/10 — les briques d'écriture existent (`CloseWallet` transactionnel, `GenerateState` fonctionnel sur wallet clôturé) ; l'effort porte sur l'orchestration génération→fermeture au niveau d'un mandat unique, l'élargissement des droits, et surtout les **chemins de lecture** (listes, BFF, agrégats) qui doivent cesser de compter un mandat suspendu comme actif.
RA Updated by Redmine Admin 15 days ago
- Description updated (diff)
Ticket révisé après analyse du calcul des états de wallet.
**Constat déterminant** : le cron `GenerateStateWalletBeneficiaries` (`kraken_requests.go:819`) ne saute un wallet que sur `wallet.deleted_at != nil` — le statut `closed` n'est pas filtré (`IsDraft` ne teste que `draft`). Une fermeture par statut seul n'aurait donc **pas** arrêté la génération d'états : le mandat suspendu aurait continué à accumuler des états quotidiens indéfiniment.
**Décisions actées** (remplacent le choix initial « statut seulement ») :
- Semantique = **cascade complète** sur le seul mandat (`wallet_beneficiary.deleted_at` + `status='closed'` + `wallet.deleted_at`), à l'image de `CloseWallet` (`db_wallet.go:160`).
- **Génération de l'état final à la date de clôture, portée par l'API**, avant la fermeture — sinon le cron ne le produira jamais (cf. `wallet.go:209`).
- Droits élargis : action ouverte à **Admin et Operator** ; `POST /wallets/{wallet_id}/close` passe d'Admin seul à Admin+Operator.
- Action toujours **définitive** (pas de réactivation en interface).
Effet de bord positif : l'ancien point ouvert n°1 est résolu — `wallet.deleted_at` étant désormais posé, la colonne « Date de clôture » de #1427 s'affiche correctement pour un mandat suspendu.
Correction apportée au ticket initial : il existe **quatre** chemins de fermeture en base, pas trois. `POST /wallets/{wallet_id}/close` (Admin seul, non exposé au front) avait été omis — c'est justement celui qui porte la bonne sémantique.
RA Updated by Redmine Admin 15 days ago
- Description updated (diff)