Feature #1453
Updated by Redmine Admin 15 days ago
### 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é **Ce qui existe côté API mais n'est pas 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` /beneficiary/{id}/wallets/{walletId}/close` — `wallet.go:43` `internal/rest/server/beneficiary.go:68` → `CloseWallet` (`db_wallet.go:160`) | `deleted_at` `beneficiaryCommand.Close` (`internal/core/beneficiary_command.go:393`). Réservé **Admin + `status='closed'` | **posé** | **Admin seul** | **non** | Operator**. Il positionne uniquement `wallet_beneficiary.status = 'closed'` via `UpdateWalletBeneficiary`. | `POST /beneficiary/{id}/wallets/{wid}/close` - **Ce endpoint n'est appelé nulle part depuis le web** : `app/api/beneficiary/[id]/wallets/api/beneficiaryWalletApi.ts` n'expose que `get`, `add`, `remove` (DELETE) et `activate`. Aucun appel à `/close`. **Suspension bénéficiaire actuelle** (`internal/db/db_beneficiary.go:457`) — `beneficiary.go:68` → `beneficiary_command.go:393` | `status='closed'` seul | non posé | Admin/Operator | **non** | une transaction qui pose, pour *tous* les mandats du bénéficiaire : | `DELETE /beneficiary/{id}/wallets/{wid}` (`RemoveWallet`) — `db_wallet_beneficiary.go:242` | `deleted_at` `beneficiary.deleted_at`, `wallet_beneficiary.deleted_at` + `status='closed'` | **non posé** | — | oui | `status='closed'`, et `wallet.deleted_at`. **Le calcul des états **Effet réel 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'` = 'closed'` aujourd'hui — 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é vérifié 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 :** - ✅ **Il bloque déjà l'attribution 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`** : nouveaux ordres.** `GetActiveBeneficiaryFromWalletID` (`db_wallet_beneficiary.go:296`, `... `where ... 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 attributions. C'est l'effet métier principal, et il est déjà acquis. - ❌ **Il ne retire le statut mandat d'aucune liste ni `deleted_at` ne sont filtrés de façon homogène en lecture** : les d'aucun KPI.** 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 null`, **pas** sur le statut. Un mandat passé en `closed` continue donc d'apparaître comme un mandat courant et de peser dans les agrégats. C'est précisément ce delta qui constitue le gros du travail de ce ticket. ### Comportement proposé **Semantique retenue **Choix retenu : cascade complète sur le seul mandat visé, précédée de la génération de l'état final. Action suspension par statut uniquement, et 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 On pose `wallet_beneficiary.status = now()` et `status = 'closed'` sur **le seul mandat visé**. - `wallet.deleted_at = now()` **Aucun `deleted_at`** n'est posé — ni sur `wallet`, ni sur `wallet_beneficiary`, ni évidemment sur `beneficiary`. 3. - Le **bénéficiaire bénéficiaire et tous **tous ses autres mandats restent strictement intacts actifs 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 inchangés**. - Pas de l'état final. **Droits d'accès** réactivation proposée en interface : 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`) présentée comme définitive. **Travail principal — faire en sorte que le statut soit réellement honoré en lecture.** Les chemins de lecture doivent distinguer un mandat suspendu d'un mandat courant : il faut l'élargir à Operator, en cohérence avec `POST /beneficiary/{id}/wallets/{wid}/close` listes de mandats, fiche bénéficiaire, vues BFF, KPI et agrégats de valorisation. Un mandat suspendu doit rester **consultable avec la suspension bénéficiaire, déjà tous deux Admin+Operator. son historique**, clairement identifié comme suspendu, mais ne doit plus être compté comme un mandat actif. **Interface** : ajouter une action « Suspendre le mandat » sur chaque mandat de la fiche bénéficiaire, bénéficiaire (Admin + Operator, cohérent avec les droits du endpoint), avec confirmation explicite rappelant que l'action est **définitive** 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 affiche ensuite un badge de clôture. statut. ### Acceptance criteria - [ ] Depuis la fiche d'un bénéficiaire, un **Admin Admin ou un Operator** 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** suspension : `wallet_beneficiary.deleted_at` et `status='closed'` sont posés, ainsi que `wallet.deleted_at`, `wallet_beneficiary.status = 'closed'` **pour ce seul mandat**. - [ ] **Aucun `deleted_at`** n'est posé par cette action, sur aucune table (`wallet`, `wallet_beneficiary`, `beneficiary`). - [ ] Le **bénéficiaire reste actif**, et **tous ses autres mandats restent inchangés** (aucun `deleted_at`, en statut préservé) inchangé** 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 (comportement déjà porté par `GetActiveBeneficiaryFromWalletID` — à couvrir par un test de non-régression sur `GetActiveBeneficiaryFromWalletID`). non-régression). - [ ] 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 « suspendu » est visible sans ambiguïté dans l'interface. - [ ] `POST /wallets/{wallet_id}/close` L'action est **élargi au rôle Operator** (aujourd'hui **réservée aux rôles Admin seul, `wallet.go:217`), en cohérence avec les autres chemins de fermeture. - [ ] L'action est et Operator**, et **tracée** (audit / event log) avec l'auteur, le mandat l'auteur 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. ### Points ouverts à trancher 1. **Droits sur `generate_state`.** `POST /wallets/{wallet_id}/generate_state` est Admin seul (`wallet.go:~270`). Si **Interaction avec #1427 (colonne « Date de clôture »).** Cette colonne lit `wallet.deleted_at`. Comme la génération de l'état final est déclenchée **en interne** suspension par l'action de suspension, statut ne pose aucun élargissement n'est nécessaire. Elle ne doit être ouverte à Operator que si l'on souhaite `deleted_at`, un mandat suspendu y affichera un **tiret (—)**, donc « ouvert ». À décider : soit la colonne s'appuie aussi exposer l'endpoint directement. sur le statut, soit on assume l'écart. À arbitrer avec #1427. 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` /beneficiary/{id}/wallets/{walletId}/activate` est déjà exposé côté API **et** **et côté web (`beneficiaryWalletApi.ts:31`) et repasse le statut à `active` — mais **sans lever `wallet.deleted_at`**, web** (`beneficiaryWalletApi.ts:31`). Techniquement, il permettrait de repasser un mandat suspendu en `active`, ce qui produirait un état incohérent sur un mandat suspendu. contredit le caractère « définitif » retenu. À décider : le restreindre aux mandats en brouillon, ou à corriger explicitement. accepter qu'il serve de porte de sortie non documentée. 4. **Harmonisation 3. **Cohérence des quatre trois chemins de fermeture** (tableau ci-dessus) : fermeture.** `Close` (statut seul), `RemoveWallet` ne pose notamment jamais `wallet.deleted_at`, (statut + `wallet_beneficiary.deleted_at`, mais **jamais** `wallet.deleted_at`) et `beneficiary_command.Close` ne pose que le statut. Mériterait la suspension bénéficiaire (les trois `deleted_at`) produisent aujourd'hui trois états différents en base. Une harmonisation ultérieure mériterait son propre ticket de consolidation. ticket. ### Classification - feature ### Complexity - 6/10 5/10 — les briques l'action d'écriture existent (`CloseWallet` transactionnel, `GenerateState` fonctionnel sur wallet clôturé) ; l'effort est quasi gratuite (le endpoint existe déjà, il suffit de le câbler au front). L'effort réel 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) : faire honorer le statut par les listes, les vues BFF et les agrégats, qui doivent cesser de compter un mandat suspendu comme actif. filtrent aujourd'hui sur `deleted_at` uniquement.