From c58c1d6c8ce5cec4724138361e86b038f89b0f44 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Fri, 9 Oct 2026 05:19:43 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(15-6b):=20un=20r=C3=A8glement=20ne=20v?= =?UTF-8?q?ise=20pas=20le=20compte=20qu'il=20solde=20=E2=80=94=20garde=20s?= =?UTF-8?q?erveur,=20lots,=20rapprochement=20(refs=20#474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DbError::SettlementCounterpartyIsClaimAccount (claim, role, batch), code SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT, 400 ; six clés plates, quatre locales. - Helper commun (invoice_settlements) : comparaison pure, refus construit à l'échec (numéro lu), details partagés ; refuse_if_claim_account (C-15-6b-1), GapAccountRole (C-15-6b-2). - Gardes : settle_invoice (contrepartie, arrondi), write_off_invoice (nature, reste d'arrondi, TVA due), pay_in_tx (lecteur soeur purchase_payable_line), création de lot (failed[] par facture, écriture d'achat malformée), confirmation contextualisée, accept_one_invoice (b bis, c-bis). - 21 tests d'intégration neufs (19 rouges avant les gardes, les tests 3 et 9 de non-régression verts avant comme après) et 8 tests unitaires ; trois mutations jouées et constatées rouges. - T0 au Change Log de la fiche. Co-Authored-By: Claude Opus 5.5 --- ...6b-contrepartie-distincte-de-la-creance.md | 41 +++ .../epic-15-choix-autonomes.md | 32 ++ crates/kesh-api/src/errors.rs | 240 +++++++++++++ crates/kesh-api/src/routes/reconciliation.rs | 80 ++++- .../kesh-api/tests/invoice_echeancier_e2e.rs | 108 ++++++ crates/kesh-api/tests/reconciliation_e2e.rs | 216 ++++++++++++ crates/kesh-db/src/errors.rs | 144 ++++++++ .../src/repositories/invoice_settlements.rs | 252 +++++++++++++- .../repositories/invoice_settlements_write.rs | 134 ++++++-- .../src/repositories/payment_batches.rs | 139 +++++++- .../src/repositories/supplier_invoices.rs | 67 +++- crates/kesh-db/tests/invoice_settlement.rs | 323 ++++++++++++++++++ crates/kesh-db/tests/invoice_write_off.rs | 253 +++++++++++++- .../tests/payment_batches_repository.rs | 229 +++++++++++++ .../tests/supplier_invoices_repository.rs | 137 ++++++++ crates/kesh-i18n/locales/de-CH/messages.ftl | 6 + crates/kesh-i18n/locales/en-CH/messages.ftl | 6 + crates/kesh-i18n/locales/fr-CH/messages.ftl | 6 + crates/kesh-i18n/locales/it-CH/messages.ftl | 6 + 19 files changed, 2370 insertions(+), 49 deletions(-) diff --git a/_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md b/_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md index 1c08e661..53b446ef 100644 --- a/_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md +++ b/_bmad-output/implementation-artifacts/15-6b-contrepartie-distincte-de-la-creance.md @@ -1210,3 +1210,44 @@ deux cas de l'AC7 (non compté : conditionnel). - Décompte après passe : **13 AC (AC1–AC12 et AC3 bis), 10 tâches (T0–T9), 23 tests** (19 backend dont 10 bis, 10 ter et 16 bis, 4 fichiers Vitest ; 22 neufs, 1 existant renommé et étendu) — inchangé (le test 18 gagne un cas, sans test neuf). Statut : `ready-for-dev`. +- 2026-10-09 — **T0 — relecture contre `39b52628`** (`origin/main`, qui porte la 15-6a, les + 15-5a à 15-5e et la 15-11b ; agent de développement Opus 5.5). **Aucun écart de fond** : aucune + règle ni aucun AC ne change. Relevés, numéros relocalisés par le texte : + - `settle_invoice` (`invoice_settlements_write.rs:48`) : créance lue par le lecteur de la 15-6a + (`:104-107`) **avant** la contrepartie ; contrepartie `:110-172`, dont le refus + `ACCOUNT_NOT_POSTABLE` de la 15-5a au **bloc du compte interne** (`:161-170`) ; trop-perçu + `:178-186` ; compte d'arrondi du règlement `:189-199`. Ordre **contrepartie → trop-perçu → + arrondi** : celui de l'AC3 bis. + - `write_off_invoice` (`:359`) : nature `:436-437`, **puis** créance `:438-441`, quote-parts de TVA + (aucun refus métier), reste d'arrondi `:476-489` (non relu pour la nature `rounding`, `:476-477`), + TVA due `:491-495`. Ordre **nature → reste d'arrondi → TVA due** : celui de l'AC3 bis. Le + commentaire « 5 bis » est celui que la 15-5e a réécrit (`:459-475`). + - `pay_in_tx` (`supplier_invoices.rs:683`) : dette et TTC `:711-722` (requête à extraire, AC4), + contrepartie `:725-797`, refus `ACCOUNT_NOT_POSTABLE` au bloc du compte interne (`:779-788`) ; + commentaire « jumeau exact » `:752-766`. + - Lots (`payment_batches.rs`) : `create_batch` `:68`, compte bancaire source `:84-103`, appel + `validate_invoice_for_batch` `:114` ; `validate_invoice_for_batch` `:226`, `fn fail` `:235-241`, + `SELECT` `:251-253` ; `confirm_batch` `:304`, boucle `pay_in_tx` `:359-370` ; `cancel_batch` + `:431`. + - `accept_one_invoice` (`reconciliation.rs:1226`) : refus du score (7bis) `:1402-1411` ; compte + de banque (a) `:1435-1463` ; créance (b) `:1474-1494` ; trop-perçu (c) `:1504-1525` ; arrondi + (c-bis) `:1528-1557`. Le commentaire de classement de la 15-5b est au **doc-comment** de la + fonction (`:1216-1224`, « aucun compte de cette écriture ne vient du client ») : c'est lui que + l'AC5 amende. + - Routes du règlement (`invoices::settle`), du solde (`invoices::write_off`), du paiement + fournisseur (`supplier_invoices::pay`) et de la confirmation d'un lot (`payment_batches::confirm`) + : rejouées par `retry_on_deadlock` (15-5e) — rejeu limité au 1213, un refus métier n'est jamais + rejoué. + - **15-5c mergée** : `failed-proposal-label.ts` existe (AC7 s'applique, clés `reconciliation-failed-*`) ; + son JSDoc et son test parlent de « 26 codes » (25 littéraux + `ACCOUNT_NOT_POSTABLE`) — le code + neuf, posé par `DbError::error_code()` comme `ACCOUNT_NOT_POSTABLE`, les porte à **27**. + - Borne `sitesTotal` : **1915** sur `HEAD` (`i18n-keys.test.ts:496`) ; **+6** attendus (15-5c + mergée). + - Écrans : `SettleInvoiceDialog.svelte` — effet de réinitialisation `:82-91`, `selectableAccounts` + `:105`, menu des comptes bancaires `:206-208` ; fiche fournisseur — filtres `:81-82` + (`journalAccountId !== null`, `active && postable`). + - Manuels : `user-manual.tex` § règlement client `:1124-1128` (« tout autre compte » `:1127`), + *Solder le reste* `:1143`, « contrepartie libre » `:1402`, pain.001 `:1444`, puce *Accepter* + `:1629`, balance âgée `:1861` ; `admin-manual.tex` `:2153`, `:2155`, `:2159`. + - Choix C36, C54, C55 présents au registre de la branche ; prochain numéro propre : + `C-15-6b-1`. diff --git a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md index 54abfdeb..2b53eda3 100644 --- a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md +++ b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md @@ -5028,3 +5028,35 @@ l'import (#458–#461). - **Retenu** : rebase sur `de285ea8`. Deux conflits seulement : `admin-manual.pdf` (binaire — pris de `main` pendant le rebase puis **régénéré** sur le `.tex` fusionné sans conflit par git, contrôlé aplati : « 104 des 112 routes », « 104 + 6 + 2 = 112 », la réserve OLICo réécrite et les apports de la 15-11b sur `.env` sont tous présents) et ce registre (union, les entrées C-15-11b avant les C-15-7a2). `CHANGELOG.md` et `sprint-status.yaml` fusionnés sans conflit ; `last_updated` (27) pris après le (26) de `main`. Aucun conflit de code : `routes/onboarding.rs` passe déjà par `config::env_nonempty` (15-11b) et le test (L) est vert. **Partition du registre recomptée depuis `LIB_ROUTES`** (et non relue) : 112 routes = 104 `Traced` + 6 `Exempt` + 2 `NoMatter` ; colonne `Rejeu` sur les 115 entrées : 22 rejouées + 4 exemptées + 89 sans écriture — inchangée par le rebase (`main` n'a pas touché `audit_route_registry.rs`), le manuel reste juste. Gates complets rejoués sur l'état rebasé (backend, frontend, E2E). - **Écartées** : merge de `main` dans la branche (historique moins lisible) ; garder l'un des deux PDF (il aurait omis l'apport de l'autre). - **Réversible** : oui (rebase ; branche poussée, sans PR). + + +## C-15-6b-1 — 15-6b : une garde d'un tenant, `refuse_if_claim_account`, au-dessus des deux fonctions de la fiche + +- **Contexte** : l'AC1 sépare la comparaison pure (`ensure_not_claim_account`) de la construction + du refus (`claim_account_refusal`, numéro lu à l'échec) et veut que chaque site appelle l'une + puis, sur `Err` seulement, l'autre. Neuf sites (règlement : contrepartie et arrondi ; solde : + nature, reste d'arrondi, TVA due ; règlement fournisseur ; création d'un lot ; rapprochement : + banque et arrondi) auraient chacun écrit le même `match` de quatre lignes. +- **Retenu** : une troisième fonction publique, `invoice_settlements::refuse_if_claim_account`, + qui fait exactement les deux gestes dans cet ordre et rend `Result<(), DbError>`. Les sites + l'appellent ; le rapprochement et la création de lot convertissent son `Err` (refus → item + `failed[]`, toute autre erreur → `DATABASE_ERROR` / propagation). Les deux fonctions de la fiche + restent publiques — la 15-6d emprunte la comparaison pure. +- **Écarté** : le `match` recopié site par site (DRY, et un site qui oublierait la lecture à + l'échec seulement). +- **Réversibilité** : totale (une fonction d'enrobage). + +## C-15-6b-2 — 15-6b : `GapAccountRole` au lieu du `DesignatedRole` de la fiche + +- **Contexte** : la fiche (AC1, signature indicative) nomme `DesignatedRole` le sous-ensemble + `{ Rounding, WriteOffNature, VatPayable }`. Un `DesignatedRole` existe déjà dans + `company_invoice_settings.rs` (Story 15-5d) : les quatre **champs** que la validation et la + saisie fournisseur écrivent (`Receivable`, `VatPayable`, `Payable`, `VatRecoverable`) — un autre + ensemble, au sens voisin. Deux types homonymes dans `kesh_db::repositories`, dont un + `VatPayable` de chaque côté, inviteraient à l'import du mauvais. +- **Retenu** : `invoice_settlements::GapAccountRole` (« compte d'écart », le vocabulaire de la + fiche : « comptes d'écart du même geste »), dans le même fichier et au même usage que la fiche + décrit (`ClaimSubject::Designated(GapAccountRole)`). Le doc-comment dit pourquoi. +- **Écarté** : garder l'homonyme (confusion à l'import) ; renommer celui de la 15-5d (hors + périmètre, 26 occurrences). +- **Réversibilité** : totale (renommage). diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index 46af2547..1dba344c 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -3041,6 +3041,22 @@ impl IntoResponse for AppError { }); (StatusCode::CONFLICT, Json(body)).into_response() } + // Story 15-6b (#474) — un règlement viserait le compte qu'il + // solde. 400 (la donnée proposée est invalide), un code pour les + // quatre rôles ; le message suit le rôle (le remède diffère). + DbError::SettlementCounterpartyIsClaimAccount { + account_id, + account_number, + claim, + role, + batch, + } => settlement_counterparty_response( + account_id, + account_number.as_deref(), + claim, + role, + batch.as_ref(), + ), // Story 25-3-a-1 (#414) — l'annulation d'un règlement refusée. // // ⚠️ Seuls les rangs que le GESTE refuse lui-même arrivent ici @@ -3648,6 +3664,109 @@ fn supplier_invoice_cancel_blocked_text( } } +/// Clé i18n et repli français du refus « contrepartie = compte soldé » +/// (Story 15-6b, AC2) — **un rôle, une clé**, toutes **plates** : un sélecteur +/// Fluent neuf rougirait `no_new_select_expression_reaches_the_frontend_dictionary` +/// (kesh-i18n), et le dictionnaire du frontend le résoudrait sans argument. +/// +/// ⚠️ `t_args` rend le repli **tel quel**, sans les arguments : il est donc +/// construit par `format!`, numéro et facture déjà en place. +fn settlement_counterparty_message( + account: &str, + claim: kesh_db::errors::ClaimSide, + role: kesh_db::errors::SettlementAccountRole, + batch: Option<&kesh_db::errors::SettlementBatchContext>, +) -> String { + use kesh_db::errors::{ClaimSide, SettlementAccountRole}; + let mut args = FluentArgs::new(); + args.set("account", account.to_string()); + let (key, fallback) = match (role, claim) { + (SettlementAccountRole::Counterparty, ClaimSide::Payable) => match batch { + Some(ctx) => { + let invoice = ctx + .supplier_invoice_number + .clone() + .unwrap_or_else(|| format!("#{}", ctx.supplier_invoice_id)); + args.set("invoice", invoice.clone()); + ( + "error-settlement-counterparty-is-payable-in-batch", + format!( + "Le compte bancaire de ce lot est lié au compte {account}, le compte créanciers de la facture {invoice} : le lot ne peut pas être confirmé. Reliez le compte bancaire à son propre compte de banque puis confirmez de nouveau, ou annulez le lot et réglez la facture depuis sa fiche." + ), + ) + } + None => ( + "error-settlement-counterparty-is-payable", + format!( + "Le compte {account} est le compte créanciers de cette facture : un règlement doit éteindre la dette par un autre compte (banque, caisse, compensation…)." + ), + ), + }, + (SettlementAccountRole::Counterparty, ClaimSide::Receivable) => ( + "error-settlement-counterparty-is-receivable", + format!( + "Le compte {account} est le compte débiteurs de cette facture : un règlement doit faire sortir la créance vers un autre compte (banque, caisse, compensation…)." + ), + ), + (SettlementAccountRole::Rounding, _) => ( + "error-settlement-rounding-account-is-receivable", + format!( + "Le compte {account}, désigné dans les réglages comme compte de différences d'arrondi, est le compte débiteurs de cette facture : un administrateur doit désigner un autre compte dans Paramètres → Facturation." + ), + ), + (SettlementAccountRole::WriteOffNature, _) => ( + "error-settlement-write-off-account-is-receivable", + format!( + "Le compte {account}, désigné dans les réglages comme compte de cette nature de solde, est le compte débiteurs de cette facture : un administrateur doit désigner un autre compte dans Paramètres → Facturation." + ), + ), + (SettlementAccountRole::VatPayable, _) => ( + "error-settlement-vat-payable-account-is-receivable", + format!( + "Le compte {account}, désigné dans les réglages comme compte de TVA due, est le compte débiteurs de cette facture : un administrateur doit désigner un autre compte dans Paramètres → Facturation." + ), + ), + }; + t_args(key, &fallback, &args) +} + +/// Réponse **400** `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT` (Story 15-6b, +/// AC2). `details` vient de `claim_account_refusal_details` — la forme que +/// partagent le rapprochement et la création d'un lot ; les clés **de lot** +/// (`paymentBatchId`, `supplierInvoiceId`) ne sont ajoutées qu'ici, seul +/// consommateur qui en ait. +fn settlement_counterparty_response( + account_id: i64, + account_number: Option<&str>, + claim: kesh_db::errors::ClaimSide, + role: kesh_db::errors::SettlementAccountRole, + batch: Option<&kesh_db::errors::SettlementBatchContext>, +) -> Response { + let account = account_number + .map(str::to_string) + .unwrap_or_else(|| format!("#{account_id}")); + let message = settlement_counterparty_message(&account, claim, role, batch); + let mut details = kesh_db::repositories::invoice_settlements::claim_account_refusal_details( + account_id, + account_number, + claim, + role, + None, + ); + if let Some(ctx) = batch { + details["paymentBatchId"] = serde_json::json!(ctx.payment_batch_id); + details["supplierInvoiceId"] = serde_json::json!(ctx.supplier_invoice_id); + } + let body = serde_json::json!({ + "error": { + "code": "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT", + "message": message, + "details": details, + } + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() +} + /// Le 400 `ACCOUNT_ARCHIVED` d'une contre-passation dont un compte a été /// archivé — [`DbError::ReversalAccountsArchived`] et sa jumelle de l'avoir /// [`DbError::CreditNoteAccountsArchived`] (Story 15-6a) : même code, même @@ -4026,6 +4145,127 @@ mod tests { ); } + fn claim_refusal( + role: kesh_db::errors::SettlementAccountRole, + claim: kesh_db::errors::ClaimSide, + batch: Option, + ) -> DbError { + DbError::SettlementCounterpartyIsClaimAccount { + account_id: 17, + account_number: Some("1100".into()), + claim, + role, + batch, + } + } + + /// Story 15-6b (#474, AC2) — 400, un code, `details` de la forme commune, + /// et un repli français **formaté** : sans i18n initialisée, `t_args` rend + /// le repli tel quel, qui ne doit pas laisser fuir le gabarit `{ $account }`. + #[tokio::test] + async fn settlement_counterparty_is_claim_account_is_400_with_details() { + use kesh_db::errors::{ClaimSide, SettlementAccountRole}; + let resp = AppError::from(claim_refusal( + SettlementAccountRole::Counterparty, + ClaimSide::Receivable, + None, + )) + .into_response(); + let (status, body) = response_body(resp).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["error"]["code"], + "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT" + ); + assert_eq!( + body["error"]["details"], + serde_json::json!({ + "accountId": 17, + "accountNumber": "1100", + "claim": "receivable", + "role": "counterparty", + }) + ); + let msg = body["error"]["message"].as_str().unwrap(); + assert!(msg.contains("1100") && msg.contains("débiteurs"), "{msg}"); + assert!(!msg.contains('$'), "gabarit non résolu : {msg}"); + } + + /// Story 15-6b (AC2) — la clé suit le RÔLE : un compte désigné renvoie à + /// Paramètres → Facturation, jamais à « banque, caisse ». + #[tokio::test] + async fn settlement_counterparty_message_follows_the_role() { + use kesh_db::errors::{ClaimSide, SettlementAccountRole}; + for (role, marker) in [ + (SettlementAccountRole::Rounding, "différences d'arrondi"), + (SettlementAccountRole::WriteOffNature, "nature de solde"), + (SettlementAccountRole::VatPayable, "TVA due"), + ] { + let resp = + AppError::from(claim_refusal(role, ClaimSide::Receivable, None)).into_response(); + let (_, body) = response_body(resp).await; + assert_eq!(body["error"]["details"]["role"], role.as_str()); + let msg = body["error"]["message"].as_str().unwrap(); + assert!(msg.contains(marker), "{role:?} : {msg}"); + assert!(msg.contains("Paramètres → Facturation"), "{role:?} : {msg}"); + assert!(!msg.contains("banque, caisse"), "{role:?} : {msg}"); + } + let resp = AppError::from(claim_refusal( + SettlementAccountRole::Counterparty, + ClaimSide::Payable, + None, + )) + .into_response(); + let (_, body) = response_body(resp).await; + assert_eq!(body["error"]["details"]["claim"], "payable"); + let msg = body["error"]["message"].as_str().unwrap(); + assert!(msg.contains("créanciers"), "{msg}"); + } + + /// Story 15-6b (AC2, AC6) — à la confirmation d'un lot, le message dit les + /// deux issues et `details` gagne les clés de lot, et elles seules. + #[tokio::test] + async fn settlement_counterparty_in_batch_carries_batch_keys() { + use kesh_db::errors::{ClaimSide, SettlementAccountRole, SettlementBatchContext}; + let resp = AppError::from(claim_refusal( + SettlementAccountRole::Counterparty, + ClaimSide::Payable, + Some(SettlementBatchContext { + payment_batch_id: 5, + supplier_invoice_id: 42, + supplier_invoice_number: Some("FF-7".into()), + }), + )) + .into_response(); + let (status, body) = response_body(resp).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["details"]["paymentBatchId"], 5); + assert_eq!(body["error"]["details"]["supplierInvoiceId"], 42); + assert!(body["error"]["details"].get("bankAccountId").is_none()); + let msg = body["error"]["message"].as_str().unwrap(); + assert!( + msg.contains("FF-7") && msg.contains("1100") && msg.contains("annulez le lot"), + "{msg}" + ); + + // Sans numéro de facture : repli `#`. + let resp = AppError::from(claim_refusal( + SettlementAccountRole::Counterparty, + ClaimSide::Payable, + Some(SettlementBatchContext { + payment_batch_id: 5, + supplier_invoice_id: 42, + supplier_invoice_number: None, + }), + )) + .into_response(); + let (_, body) = response_body(resp).await; + assert!( + body["error"]["message"].as_str().unwrap().contains("#42"), + "{body}" + ); + } + /// Story 16-3b — le conflit de numéro de client porte **son propre code**, /// et pas celui de l'IDE. /// diff --git a/crates/kesh-api/src/routes/reconciliation.rs b/crates/kesh-api/src/routes/reconciliation.rs index c27aa1b4..cf37a8e2 100644 --- a/crates/kesh-api/src/routes/reconciliation.rs +++ b/crates/kesh-api/src/routes/reconciliation.rs @@ -23,8 +23,8 @@ use chrono::{Duration, NaiveDate}; use kesh_db::entities::audit_log::NewAuditLogEntry; use kesh_db::entities::bank_transaction::{BankTransaction, BankTransactionStatus}; use kesh_db::entities::invoice::Invoice; -use kesh_db::errors::DbError; -use kesh_db::repositories::invoice_settlements::PaymentAgainstDue; +use kesh_db::errors::{ClaimSide, DbError}; +use kesh_db::repositories::invoice_settlements::{ClaimSubject, GapAccountRole, PaymentAgainstDue}; use kesh_db::repositories::reconciliation::UnpaidInvoiceCandidate; use kesh_db::repositories::{ accounts as accounts_repo, audit_log, bank_accounts, company_invoice_settings, @@ -1204,6 +1204,43 @@ async fn accept_one( } } +/// Convertit le refus « compte = créance » (Story 15-6b, #474) en +/// `FailedProposal` — **une seule** fonction pour l'étape (b bis) et (c-bis), +/// `details` par le helper commun `claim_account_refusal_details`. +/// `bank_account_id` n'est fourni que pour le rôle `counterparty`. Toute autre +/// erreur — celle de la lecture du numéro — est un `DATABASE_ERROR`, comme aux +/// étapes voisines (pattern batch : jamais d'`AppError` global). +fn claim_account_failed_proposal( + bank_transaction_id: i64, + err: DbError, + bank_account_id: Option, +) -> FailedProposal { + match &err { + DbError::SettlementCounterpartyIsClaimAccount { + account_id, + account_number, + claim, + role, + .. + } => FailedProposal { + bank_transaction_id, + error_code: err.error_code().to_string(), + details: Some(invoice_settlements::claim_account_refusal_details( + *account_id, + account_number.as_deref(), + *claim, + *role, + bank_account_id, + )), + }, + other => FailedProposal { + bank_transaction_id, + error_code: "DATABASE_ERROR".to_string(), + details: Some(serde_json::json!({ "message": other.to_string() })), + }, + } +} + /// Helper interne — traite UNE proposal `type='invoice'` dans son savepoint. /// /// **C2 Pass 1 code review (TOCTOU fix)** : la BankTransaction est @@ -1222,6 +1259,17 @@ async fn accept_one( /// `company_invoice_settings::rounding_account_for_write`. Les deux autres /// flux d'acceptation (`split`, `rule`) et les rapprochements manuel et ventilé /// gardent, eux, le compte de contrepartie venu du client. +/// +/// ⚠️ **Mais ne pas venir du client ne veut pas dire ne pas pouvoir être la +/// créance** (Story 15-6b, #474, amendement du classement ci-dessus) : le compte +/// de banque vient de la **configuration** du compte bancaire, et rien n'y +/// interdit le 1100 ; le compte d'arrondi vient des réglages, et la créance +/// retypée en charge passe son contrôle de type. L'un et l'autre écriraient +/// `D 1100 / C 1100`. Ils sont donc **comparés à la créance** — le compte de +/// banque aussitôt la créance lue (étape b bis, avant le trop-perçu), le compte +/// d'arrondi aussitôt lu (c-bis) — et le refus est une proposition de +/// `failed[]` (`SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`), jamais une erreur +/// globale. #[allow(clippy::too_many_arguments)] async fn accept_one_invoice( tx: &mut sqlx::Transaction<'_, sqlx::MySql>, @@ -1493,6 +1541,19 @@ async fn accept_one_invoice( } }; + // (b bis) ⛔ Story 15-6b (#474) — le compte de banque n'est pas la créance. + // Après tous les refus antérieurs (score, compte bancaire sans + // journal, écriture de vente malformée), avant le trop-perçu. + invoice_settlements::refuse_if_claim_account( + tx, + company_id, + bank_ledger_account_id, + receivable_account_id, + ClaimSubject::Counterparty(ClaimSide::Receivable), + ) + .await + .map_err(|e| claim_account_failed_proposal(bank_transaction_id, e, Some(bank_account_id)))?; + // (c) ⛔ Le trop-perçu est REFUSÉ, il ne s'écrit pas. Sans ce garde, le // compte de créance passerait CRÉDITEUR — un solde contre nature que le // grand livre signalerait, mais après coup. @@ -1533,7 +1594,20 @@ async fn accept_one_invoice( ) .await { - Ok(id) => Some(id), + // Story 15-6b (AC3 bis) — le compte d'arrondi n'est pas la créance : + // `details` sans `bankAccountId`, qui n'a pas de sens ici. + Ok(id) => { + invoice_settlements::refuse_if_claim_account( + tx, + company_id, + id, + receivable_account_id, + ClaimSubject::Designated(GapAccountRole::Rounding), + ) + .await + .map_err(|e| claim_account_failed_proposal(bank_transaction_id, e, None))?; + Some(id) + } Err(DbError::RoundingAccountNotConfigured { .. }) => { return Err(FailedProposal { bank_transaction_id, diff --git a/crates/kesh-api/tests/invoice_echeancier_e2e.rs b/crates/kesh-api/tests/invoice_echeancier_e2e.rs index 50413dea..c440dac7 100644 --- a/crates/kesh-api/tests/invoice_echeancier_e2e.rs +++ b/crates/kesh-api/tests/invoice_echeancier_e2e.rs @@ -1837,3 +1837,111 @@ async fn write_off_route_refusals(pool: MySqlPool) { "{body}" ); } + +// --- Story 15-6b (#474) — la contrepartie n'est pas la créance, à la frontière HTTP + +/// Story 15-6b, test 16 — `POST …/settlements` par compte interne = la créance : +/// **400**, code, `details.claim = "receivable"`, message **fr** (la langue des +/// messages est globale au processus, `init_error_i18n`). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn settle_on_the_receivable_account_is_a_400_with_details(pool: MySqlPool) { + let (admin_id, company_id) = seed_base(&pool).await; + let contact_id = seed_contact(&pool, company_id, admin_id).await; + let (id, _v) = create_validated_invoice( + &pool, + company_id, + contact_id, + admin_id, + NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(), + dec!(100.00), + ) + .await; + let creance = receivable_id(&pool, company_id).await; + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + + let resp = post_settle(&app, &token, id, creance, "108.10").await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!( + body["error"]["code"], + "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT" + ); + assert_eq!( + body["error"]["details"], + json!({ + "accountId": creance, + "accountNumber": "1100", + "claim": "receivable", + "role": "counterparty", + }) + ); + let msg = body["error"]["message"].as_str().unwrap(); + assert!( + msg.contains("1100") && msg.contains("compte débiteurs de cette facture"), + "message fr attendu : {msg}" + ); + assert!(!msg.contains('$') && !msg.contains('\u{2068}'), "{msg}"); + assert!(!paid(&pool, id).await); +} + +/// Story 15-6b, test 16 bis — `POST …/write-off` dont le compte de la nature +/// `discount` est la créance retypée : **400**, `details.role = +/// "write_off_nature"`, message qui renvoie à Paramètres → Facturation — et non +/// à « banque, caisse » (clé plate `error-settlement-write-off-account-is-receivable`). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn write_off_on_a_nature_account_that_is_the_receivable_is_a_400(pool: MySqlPool) { + let (admin_id, company_id) = seed_base(&pool).await; + let contact_id = seed_contact(&pool, company_id, admin_id).await; + let (id, _v) = create_validated_invoice( + &pool, + company_id, + contact_id, + admin_id, + NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(), + dec!(100.00), + ) + .await; + let creance = receivable_id(&pool, company_id).await; + sqlx::query("UPDATE accounts SET account_type = 'Expense', role = NULL WHERE id = ?") + .bind(creance) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "UPDATE company_invoice_settings SET default_discount_account_id = ? WHERE company_id = ?", + ) + .bind(creance) + .bind(company_id) + .execute(&pool) + .await + .unwrap(); + let app = spawn_app(pool.clone()).await; + let token = login(&app).await; + let version = invoice_version(&app, &token, id).await; + + let resp = post_write_off( + &app, + &token, + id, + json!({ "nature": "discount", "settledOn": "2026-04-20", "version": version }), + ) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!( + body["error"]["code"], + "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT" + ); + assert_eq!(body["error"]["details"]["role"], "write_off_nature"); + assert_eq!(body["error"]["details"]["claim"], "receivable"); + let msg = body["error"]["message"].as_str().unwrap(); + assert!( + msg.contains("nature de solde") && msg.contains("Paramètres → Facturation"), + "{msg}" + ); + assert!(!msg.contains("banque, caisse"), "{msg}"); + assert!(!paid(&pool, id).await); +} diff --git a/crates/kesh-api/tests/reconciliation_e2e.rs b/crates/kesh-api/tests/reconciliation_e2e.rs index 6033747b..3bb24338 100644 --- a/crates/kesh-api/tests/reconciliation_e2e.rs +++ b/crates/kesh-api/tests/reconciliation_e2e.rs @@ -5267,3 +5267,219 @@ async fn accept_split_missing_and_non_postable_reports_not_found_first(pool: MyS assert_eq!(failed.len(), 1, "{body}"); assert_eq!(failed[0]["errorCode"], "ACCOUNT_NOT_FOUND"); } + +// ============================================================ +// Story 15-6b (#474) — le compte de banque n'est pas la créance +// ============================================================ + +/// Une transaction de `amount`, référencée `reference`, sur le compte bancaire +/// du contexte. Rend son id. +async fn one_tx( + pool: &MySqlPool, + ctx: &CompanyCtx, + hash_seed: &str, + amount: Decimal, + reference: &str, +) -> i64 { + let day = NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(); + seed_bank_transactions( + pool, + ctx.company_id, + ctx.bank_account_id, + ctx.user_id, + &unique_hash(hash_seed), + day, + day, + vec![make_new_tx( + ctx.company_id, + ctx.bank_account_id, + day, + Some(day), + amount, + "CHF", + reference, + None, + )], + ) + .await[0] +} + +async fn tx_status(pool: &MySqlPool, tx_id: i64) -> String { + sqlx::query_scalar("SELECT status FROM bank_transactions WHERE id = ?") + .bind(tx_id) + .fetch_one(pool) + .await + .unwrap() +} + +/// Story 15-6b, test 7 — **un lot de deux propositions, deux créances** : A sur +/// 1100, B sur 1101 ; le compte bancaire du lot est lié au 1100. HTTP 200, B +/// acceptée, A dans `failed[]` avec ses cinq clés ; la transaction de A reste +/// en attente. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn accept_refuses_per_proposal_a_bank_ledger_that_is_the_receivable(pool: MySqlPool) { + let ctx = setup_company(&pool, "ContrepA", "CH4431999123000889012", Role::Comptable).await; + let inv_date = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(); + let (inv_a, _) = seed_validated_invoice( + &pool, + ctx.company_id, + ctx.contact_id, + "INV-CP-A", + inv_date, + dec!(100.00), + ) + .await; + let (inv_b, je_b) = seed_validated_invoice( + &pool, + ctx.company_id, + ctx.contact_id, + "INV-CP-B", + inv_date, + dec!(200.00), + ) + .await; + // B porte sa créance sur un 1101 (débiteurs changé dans les réglages entre + // les deux ventes) : la ligne de débit de son écriture de vente. + let r1101: i64 = sqlx::query_scalar( + "INSERT INTO accounts (company_id, number, name, account_type, active, postable) \ + VALUES (?, '1101', 'Débiteurs bis', 'Asset', 1, 1) RETURNING id", + ) + .bind(ctx.company_id) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("UPDATE journal_entry_lines SET account_id = ? WHERE entry_id = ? AND debit > 0") + .bind(r1101) + .bind(je_b) + .execute(&pool) + .await + .unwrap(); + // Le compte bancaire du lot, lié au 1100 (sans passer par la 15-6c). + sqlx::query("UPDATE bank_accounts SET journal_account_id = ? WHERE id = ?") + .bind(ctx.receivable_account_id) + .bind(ctx.bank_account_id) + .execute(&pool) + .await + .unwrap(); + let tx_a = one_tx(&pool, &ctx, "cp-a", dec!(100.00), "INV-CP-A").await; + let tx_b = one_tx(&pool, &ctx, "cp-b", dec!(200.00), "INV-CP-B").await; + let app = spawn_app(pool.clone()).await; + + let resp = app + .client + .post(app.url("/api/v1/reconciliation/accept")) + .bearer_auth(&ctx.jwt) + .json(&serde_json::json!({ + "bankAccountId": ctx.bank_account_id, + "proposals": [ + { "type": "invoice", "bankTransactionId": tx_a, "invoiceId": inv_a }, + { "type": "invoice", "bankTransactionId": tx_b, "invoiceId": inv_b }, + ], + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "succès partiel = succès HTTP"); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["accepted"].as_array().map(Vec::len), Some(1), "{body}"); + let failed = body["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "{body}"); + assert_eq!(failed[0]["bankTransactionId"], tx_a); + assert_eq!( + failed[0]["errorCode"], + "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT" + ); + assert_eq!( + failed[0]["details"], + serde_json::json!({ + "bankAccountId": ctx.bank_account_id, + "accountId": ctx.receivable_account_id, + "accountNumber": "1100", + "claim": "receivable", + "role": "counterparty", + }) + ); + assert_eq!(settlements_and_paid_at(&pool, inv_a).await, (0, None)); + assert_eq!(tx_status(&pool, tx_a).await, "pending"); + let (settled_b, _) = settlements_and_paid_at(&pool, inv_b).await; + assert_eq!(settled_b, 1, "B est réglée"); +} + +/// Story 15-6b, test 8 — **ordre** : sur A, une transaction supérieure au reste +/// (référencée, donc de score positif — l'étape 7bis passe) rend le refus de +/// contrepartie, pas `RECONCILIATION_OVERPAYMENT`. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn the_counterparty_refusal_comes_before_the_reconciliation_overpayment(pool: MySqlPool) { + let ctx = setup_company(&pool, "ContrepB", "CH4431999123000889012", Role::Comptable).await; + let (inv_a, _) = seed_validated_invoice( + &pool, + ctx.company_id, + ctx.contact_id, + "INV-CP-C", + NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), + dec!(100.00), + ) + .await; + sqlx::query("UPDATE bank_accounts SET journal_account_id = ? WHERE id = ?") + .bind(ctx.receivable_account_id) + .bind(ctx.bank_account_id) + .execute(&pool) + .await + .unwrap(); + let tx_a = one_tx(&pool, &ctx, "cp-c", dec!(150.00), "INV-CP-C").await; + let app = spawn_app(pool.clone()).await; + + let body = post_accept_one(&app, &ctx, tx_a, inv_a).await; + let failed = body["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "{body}"); + assert_eq!( + failed[0]["errorCode"], "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT", + "la contrepartie précède le trop-perçu" + ); + assert_eq!(settlements_and_paid_at(&pool, inv_a).await, (0, None)); +} + +/// Story 15-6b, test 12 — **arrondi (c-bis) = créance** (AC3 bis) : la créance +/// retypée en charge et désignée compte d'arrondi. `failed[]`, HTTP 200, +/// `details.role = "rounding"`, et PAS de `bankAccountId`. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn accept_refuses_a_rounding_account_that_is_the_receivable(pool: MySqlPool) { + let ctx = setup_company(&pool, "ContrepR", "CH4431999123000889012", Role::Comptable).await; + designate_rounding_account(&pool, ctx.company_id).await; + let (inv_id, tx_id) = + half_centime_invoice_and_tx(&pool, &ctx, "INV-CP-R", dec!(10.01), "INV-CP-R").await; + sqlx::query("UPDATE accounts SET account_type = 'Expense', role = NULL WHERE id = ?") + .bind(ctx.receivable_account_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "UPDATE company_invoice_settings SET default_rounding_account_id = ? WHERE company_id = ?", + ) + .bind(ctx.receivable_account_id) + .bind(ctx.company_id) + .execute(&pool) + .await + .unwrap(); + let app = spawn_app(pool.clone()).await; + + let body = post_accept_one(&app, &ctx, tx_id, inv_id).await; + let failed = body["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "{body}"); + assert_eq!( + failed[0]["errorCode"], + "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT" + ); + assert_eq!( + failed[0]["details"], + serde_json::json!({ + "accountId": ctx.receivable_account_id, + "accountNumber": "1100", + "claim": "receivable", + "role": "rounding", + }) + ); + assert!(failed[0]["details"].get("bankAccountId").is_none()); + assert_eq!(settlements_and_paid_at(&pool, inv_id).await, (0, None)); + assert_eq!(tx_status(&pool, tx_id).await, "pending"); +} diff --git a/crates/kesh-db/src/errors.rs b/crates/kesh-db/src/errors.rs index 3b96850e..71f6a85a 100644 --- a/crates/kesh-db/src/errors.rs +++ b/crates/kesh-db/src/errors.rs @@ -919,6 +919,47 @@ pub enum DbError { #[error("Comptes archivés sur l'avoir à émettre ({})", .0.len())] CreditNoteAccountsArchived(Vec), + /// Un règlement viserait **le compte même qu'il doit solder** (Story 15-6b, + /// #474) : la contrepartie — ou un compte d'écart du même geste — est le + /// compte débiteurs de la facture client, ou le compte créanciers de la + /// facture fournisseur. + /// + /// ⛔ **Le motif** : l'écriture serait `D X / C X`. Elle s'équilibre (rien + /// n'interdit un même compte au débit et au crédit), le règlement + /// s'enregistre, le reste dû baisse, la facture peut passer « payée » — et le + /// grand livre **ne bouge pas**. Le refus vient avant toute écriture. + /// + /// `role` dit **d'où vient** le compte en cause, donc le remède + /// ([`SettlementAccountRole`]). ⚠️ **Le rôle suit la colonne de réglage lue, + /// pas la route** : le compte d'arrondi du règlement, celui du rapprochement, + /// le reste d'arrondi d'un solde et le compte de la nature `rounding` lisent + /// tous `default_rounding_account_id` — ils sortent tous sous + /// `role: Rounding`. `claim: Payable` ne se combine qu'avec `Counterparty` : + /// le règlement fournisseur n'écrit aucun compte d'écart, et le constructeur + /// unique (`invoice_settlements::claim_account_refusal`) rend la combinaison + /// irreprésentable. + /// + /// `batch` ne vaut `Some` qu'à la **confirmation d'un lot** de paiement + /// (`payment_batches::confirm_batch`), qui intercepte le refus de + /// `pay_in_tx` pour dire lequel de ses règlements est en cause. + /// + /// Mappé vers HTTP **400** `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT` — un + /// seul code pour les quatre rôles, le défaut étant le même ; `details.role` + /// et le message distinguent le remède. + #[error("La contrepartie du règlement est le compte qu'il solde (compte {account_id})")] + SettlementCounterpartyIsClaimAccount { + /// Le compte en cause — qui **est** le compte soldé. + account_id: i64, + /// Son numéro, lu à l'échec ; `None` s'il n'a pas pu être résolu. + account_number: Option, + /// Le côté soldé : créance client ou dette fournisseur. + claim: ClaimSide, + /// D'où vient le compte en cause, donc quel est le remède. + role: SettlementAccountRole, + /// Le lot et la facture, à la seule confirmation d'un lot. + batch: Option, + }, + /// L'écriture a été contre-passée : on ne la modifie ni ne la supprime /// plus (Story 24-4a ; la modification, Story 15-8a). /// @@ -1042,6 +1083,72 @@ pub enum RoundingContext { Issuance, } +/// Le côté que solde un règlement (Story 15-6b, #474) : la **créance** d'une +/// facture client, ou la **dette** d'une facture fournisseur. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimSide { + /// Le compte débiteurs, lu sur l'écriture de vente. + Receivable, + /// Le compte créanciers, lu sur l'écriture d'achat. + Payable, +} + +impl ClaimSide { + /// Discriminant machine exposé dans `details.claim`. + pub fn as_str(self) -> &'static str { + match self { + Self::Receivable => "receivable", + Self::Payable => "payable", + } + } +} + +/// D'où vient le compte qui coïncide avec le compte soldé — donc quel est le +/// remède (Story 15-6b, #474 ; choix C-15-6-26, C-15-6-30). +/// +/// ⚠️ **Le rôle suit la colonne de réglage lue, pas la route** : un même compte +/// désigné sort sous le même rôle partout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettlementAccountRole { + /// La contrepartie que l'utilisateur **choisit** (compte interne, compte + /// bancaire du virement) ou que porte le **compte bancaire** du geste (lot, + /// rapprochement). Remède : un autre compte, ou relier le compte bancaire à + /// son propre compte de banque. + Counterparty, + /// Le compte de différences d'arrondi des réglages + /// (`default_rounding_account_id`), **quel que soit le geste** : règlement, + /// rapprochement, reste d'arrondi d'un solde, nature `rounding`. + Rounding, + /// Le compte d'une nature de solde des réglages — `discount`, `bank_fees`, + /// `bad_debt` seulement (la nature `rounding` est [`Self::Rounding`]). + WriteOffNature, + /// Le compte de TVA due des réglages (solde du reste). + VatPayable, +} + +impl SettlementAccountRole { + /// Discriminant machine exposé dans `details.role`. + pub fn as_str(self) -> &'static str { + match self { + Self::Counterparty => "counterparty", + Self::Rounding => "rounding", + Self::WriteOffNature => "write_off_nature", + Self::VatPayable => "vat_payable", + } + } +} + +/// Le lot de paiement et la facture en cause, quand le refus naît à la +/// **confirmation d'un lot** (Story 15-6b, AC6) : le remède n'y est pas celui +/// du règlement unitaire — l'utilisateur n'y choisit aucun compte. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettlementBatchContext { + pub payment_batch_id: i64, + pub supplier_invoice_id: i64, + /// Numéro de la facture fournisseur, relu par `confirm_batch`. + pub supplier_invoice_number: Option, +} + impl DbError { /// Raccourci de construction de [`DbError::AccountsNotPostable`] : trie, /// dédoublonne et vérifie la non-vacuité par [`NonPostableAccounts::new`]. @@ -1081,6 +1188,9 @@ impl DbError { Self::EntryNotReversable { .. } => "ENTRY_NOT_REVERSABLE", Self::ReversalAccountsArchived(_) => "ACCOUNT_ARCHIVED", Self::CreditNoteAccountsArchived(_) => "ACCOUNT_ARCHIVED", + Self::SettlementCounterpartyIsClaimAccount { .. } => { + "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT" + } Self::InvoiceNotUnvalidatable { blocker, .. } => blocker.code(), Self::SettlementNotCancellable { blocker } => blocker.code(), Self::ReconciliationNotCancellable { blocker } => blocker.code(), @@ -1217,4 +1327,38 @@ mod tests { let err = DbError::accounts_not_postable([acc(1, "1000")]); assert_eq!(err.error_code(), "ACCOUNT_NOT_POSTABLE"); } + + /// Story 15-6b (#474) — un code pour les quatre rôles. + #[test] + fn settlement_counterparty_is_claim_account_error_code() { + for role in [ + SettlementAccountRole::Counterparty, + SettlementAccountRole::Rounding, + SettlementAccountRole::WriteOffNature, + SettlementAccountRole::VatPayable, + ] { + let err = DbError::SettlementCounterpartyIsClaimAccount { + account_id: 1, + account_number: Some("1100".into()), + claim: ClaimSide::Receivable, + role, + batch: None, + }; + assert_eq!(err.error_code(), "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT"); + } + } + + /// Story 15-6b — les discriminants machine de `details.claim` et `details.role`. + #[test] + fn claim_side_and_role_discriminants() { + assert_eq!(ClaimSide::Receivable.as_str(), "receivable"); + assert_eq!(ClaimSide::Payable.as_str(), "payable"); + assert_eq!(SettlementAccountRole::Counterparty.as_str(), "counterparty"); + assert_eq!(SettlementAccountRole::Rounding.as_str(), "rounding"); + assert_eq!( + SettlementAccountRole::WriteOffNature.as_str(), + "write_off_nature" + ); + assert_eq!(SettlementAccountRole::VatPayable.as_str(), "vat_payable"); + } } diff --git a/crates/kesh-db/src/repositories/invoice_settlements.rs b/crates/kesh-db/src/repositories/invoice_settlements.rs index 2c7b52e4..cfe6c0d0 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements.rs @@ -20,7 +20,7 @@ const COLUMNS: &str = "id, company_id, invoice_id, journal_entry_id, amount, set write_off_nature, write_off_vat, created_at"; use crate::entities::{InvoiceSettlement, NewInvoiceSettlement, NewJournalEntryLine}; -use crate::errors::{DbError, map_db_error}; +use crate::errors::{ClaimSide, DbError, SettlementAccountRole, map_db_error}; use crate::repositories::invoices::line_ttc_sql; /// Forme **scalaire par facture** du total réglé — sous-requête corrélée. @@ -617,6 +617,170 @@ pub async fn sale_rounding_account( } } +// --------------------------------------------------------------------------- +// Story 15-6b (#474) — un règlement ne vise pas le compte qu'il solde +// --------------------------------------------------------------------------- + +/// Constat **pur** qu'un compte coïncide avec le compte soldé — rendu par +/// [`ensure_not_claim_account`], consommé par [`claim_account_refusal`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClaimAccountClash { + /// Le compte en cause (égal au compte soldé). + pub account_id: i64, +} + +/// Le **compte d'écart** désigné dans les réglages qu'un geste écrit en face de +/// la créance (Story 15-6b, AC3 bis). Sous-ensemble de +/// [`SettlementAccountRole`] : un compte désigné est toujours comparé à une +/// **créance** client. +/// +/// ⚠️ Nommé `GapAccountRole`, et non `DesignatedRole` comme l'écrivait la fiche +/// (signature indicative ; choix C-15-6b-2) : un `DesignatedRole` existe déjà +/// dans `company_invoice_settings` (Story 15-5d), qui désigne les quatre champs +/// que la validation et la saisie fournisseur écrivent — un autre ensemble. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GapAccountRole { + /// `default_rounding_account_id`, quel que soit le geste. + Rounding, + /// Le compte d'une nature `discount`, `bank_fees` ou `bad_debt`. + WriteOffNature, + /// `default_vat_payable_account_id` (solde du reste). + VatPayable, +} + +/// Le **sujet** d'une comparaison au compte soldé (Story 15-6b, AC1 ; finding +/// F5 de la P5) : la combinaison « dette fournisseur + compte désigné » est +/// **irreprésentable** — le règlement fournisseur n'écrit aucun compte d'écart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimSubject { + /// La contrepartie choisie, ou celle que porte le compte bancaire du geste. + Counterparty(ClaimSide), + /// Un compte désigné dans les réglages — comparé à la créance client. + Designated(GapAccountRole), +} + +impl ClaimSubject { + /// Le côté soldé : celui de la contrepartie, ou la créance pour un compte + /// désigné. + pub fn claim(self) -> ClaimSide { + match self { + Self::Counterparty(side) => side, + Self::Designated(_) => ClaimSide::Receivable, + } + } + + /// Le rôle exposé (`details.role`), qui porte le remède. + pub fn role(self) -> SettlementAccountRole { + match self { + Self::Counterparty(_) => SettlementAccountRole::Counterparty, + Self::Designated(GapAccountRole::Rounding) => SettlementAccountRole::Rounding, + Self::Designated(GapAccountRole::WriteOffNature) => { + SettlementAccountRole::WriteOffNature + } + Self::Designated(GapAccountRole::VatPayable) => SettlementAccountRole::VatPayable, + } + } +} + +/// Compare un compte que le geste écrit **en face** du compte soldé à ce compte +/// soldé (Story 15-6b, #474). **Pure** : ni I/O ni transaction — c'est la seule +/// partie que la 15-6d emprunte (sa garde compare la contrepartie au compte de +/// banque). +/// +/// ⛔ Une écriture `D X / C X` s'équilibre : rien, au grand livre, ne la refuse. +/// Seule cette comparaison d'**identifiants** la ferme — le contrôle de type +/// d'un compte désigné ne suffit pas, le type d'un compte qui porte des +/// écritures se changeant avec confirmation. +pub fn ensure_not_claim_account( + account_id: i64, + claim_account_id: i64, +) -> Result<(), ClaimAccountClash> { + if account_id == claim_account_id { + Err(ClaimAccountClash { account_id }) + } else { + Ok(()) + } +} + +/// Construit le refus [`DbError::SettlementCounterpartyIsClaimAccount`] — +/// **seule** construction de la variante hors de l'interception de +/// `payment_batches::confirm_batch` (qui la reconstruit avec son lot). +/// +/// Appelée **à l'échec seulement** : elle lit le numéro du compte dans la +/// transaction de l'appelant (la requête est écrite d'un tenant, sur une ligne, +/// pour rester trouvable par `grep`). Une erreur SQL de cette lecture est rendue +/// telle quelle — c'est alors elle, et non le refus, que l'appelant propage. +/// `batch` vaut toujours `None` ici. +pub async fn claim_account_refusal( + conn: &mut sqlx::MySqlConnection, + company_id: i64, + clash: ClaimAccountClash, + subject: ClaimSubject, +) -> DbError { + let number: Result, DbError> = + sqlx::query_scalar("SELECT number FROM accounts WHERE id = ? AND company_id = ?") + .bind(clash.account_id) + .bind(company_id) + .fetch_optional(&mut *conn) + .await + .map_err(map_db_error); + match number { + Ok(account_number) => DbError::SettlementCounterpartyIsClaimAccount { + account_id: clash.account_id, + account_number, + claim: subject.claim(), + role: subject.role(), + batch: None, + }, + Err(e) => e, + } +} + +/// Garde complète d'un site : [`ensure_not_claim_account`], puis, sur +/// coïncidence **seulement**, [`claim_account_refusal`] (Story 15-6b ; choix +/// C-15-6b-1). Rend `Err` avec le refus — ou avec l'erreur SQL de la lecture du +/// numéro. +pub async fn refuse_if_claim_account( + conn: &mut sqlx::MySqlConnection, + company_id: i64, + account_id: i64, + claim_account_id: i64, + subject: ClaimSubject, +) -> Result<(), DbError> { + match ensure_not_claim_account(account_id, claim_account_id) { + Ok(()) => Ok(()), + Err(clash) => Err(claim_account_refusal(conn, company_id, clash, subject).await), + } +} + +/// Les `details` du refus, construits **une fois** pour ses trois +/// consommateurs — le mapping HTTP, le `FailedProposal` du rapprochement et le +/// `PaymentBatchFailedItem` de la création d'un lot — pour que leurs clés ne +/// divergent pas. +/// +/// `bankAccountId` n'est posé que s'il est fourni (rôle `counterparty` d'un +/// geste qui porte un compte bancaire). Les clés **de lot** +/// (`paymentBatchId`, `supplierInvoiceId`) sont hors de ce helper : le mapping +/// HTTP, seul consommateur qui en ait, les ajoute. +pub fn claim_account_refusal_details( + account_id: i64, + account_number: Option<&str>, + claim: ClaimSide, + role: SettlementAccountRole, + bank_account_id: Option, +) -> serde_json::Value { + let mut details = serde_json::json!({ + "accountId": account_id, + "accountNumber": account_number, + "claim": claim.as_str(), + "role": role.as_str(), + }); + if let Some(bank_account_id) = bank_account_id { + details["bankAccountId"] = serde_json::json!(bank_account_id); + } + details +} + // --------------------------------------------------------------------------- // Story 25-5-a (#386) — lecture exhaustive pour l'export de souveraineté // --------------------------------------------------------------------------- @@ -754,4 +918,90 @@ mod tests { assert!(parse_write_off_vat(&bad).is_err(), "accepté : {bad}"); } } + + /// Story 15-6b (#474) — la comparaison est une égalité d'identifiants, et + /// rien d'autre. + #[test] + fn la_comparaison_au_compte_solde_est_une_egalite_d_identifiants() { + assert_eq!(ensure_not_claim_account(7, 8), Ok(())); + assert_eq!( + ensure_not_claim_account(8, 8), + Err(ClaimAccountClash { account_id: 8 }) + ); + } + + /// Story 15-6b — le sujet rend le côté et le rôle ; un compte désigné est + /// toujours comparé à la créance. + #[test] + fn le_sujet_rend_le_cote_et_le_role() { + let cases = [ + ( + ClaimSubject::Counterparty(ClaimSide::Receivable), + ClaimSide::Receivable, + SettlementAccountRole::Counterparty, + ), + ( + ClaimSubject::Counterparty(ClaimSide::Payable), + ClaimSide::Payable, + SettlementAccountRole::Counterparty, + ), + ( + ClaimSubject::Designated(GapAccountRole::Rounding), + ClaimSide::Receivable, + SettlementAccountRole::Rounding, + ), + ( + ClaimSubject::Designated(GapAccountRole::WriteOffNature), + ClaimSide::Receivable, + SettlementAccountRole::WriteOffNature, + ), + ( + ClaimSubject::Designated(GapAccountRole::VatPayable), + ClaimSide::Receivable, + SettlementAccountRole::VatPayable, + ), + ]; + for (subject, claim, role) in cases { + assert_eq!(subject.claim(), claim, "{subject:?}"); + assert_eq!(subject.role(), role, "{subject:?}"); + } + } + + /// Story 15-6b — forme des `details`, avec et sans compte bancaire. + #[test] + fn les_details_du_refus_ont_une_seule_forme() { + assert_eq!( + claim_account_refusal_details( + 3, + Some("1100"), + ClaimSide::Receivable, + SettlementAccountRole::Counterparty, + Some(9), + ), + serde_json::json!({ + "accountId": 3, + "accountNumber": "1100", + "claim": "receivable", + "role": "counterparty", + "bankAccountId": 9, + }) + ); + let sans_banque = claim_account_refusal_details( + 3, + None, + ClaimSide::Receivable, + SettlementAccountRole::Rounding, + None, + ); + assert_eq!( + sans_banque, + serde_json::json!({ + "accountId": 3, + "accountNumber": null, + "claim": "receivable", + "role": "rounding", + }) + ); + assert!(sans_banque.get("bankAccountId").is_none()); + } } diff --git a/crates/kesh-db/src/repositories/invoice_settlements_write.rs b/crates/kesh-db/src/repositories/invoice_settlements_write.rs index bf41b04a..bddee26b 100644 --- a/crates/kesh-db/src/repositories/invoice_settlements_write.rs +++ b/crates/kesh-db/src/repositories/invoice_settlements_write.rs @@ -21,8 +21,10 @@ use crate::entities::{ Journal, NewInvoiceSettlement, NewJournalEntry, SettlementChoice, SettlementKind, SettlementWriteOffNature, }; -use crate::errors::{DbError, NonPostableAccount, SettlementCancelBlocker, map_db_error}; -use crate::repositories::invoice_settlements::PaymentAgainstDue; +use crate::errors::{ + ClaimSide, DbError, NonPostableAccount, SettlementCancelBlocker, map_db_error, +}; +use crate::repositories::invoice_settlements::{ClaimSubject, GapAccountRole, PaymentAgainstDue}; use crate::repositories::journal_entries::ReversalAuthority; use crate::repositories::settlement_cancellation::{ SettlementCancelHit, settlement_entry_cancel_blocker, @@ -45,6 +47,22 @@ pub struct SettlementOutcome { /// ⛔ **Le trop-perçu est refusé, jamais écrit** : sinon le compte de créance /// passerait créditeur, une anomalie que le grand livre signalerait — mais après /// coup. +/// +/// ⛔ **Un règlement ne vise pas le compte qu'il solde** (Story 15-6b, #474) : ni +/// la contrepartie (compte interne choisi, ou compte du grand livre du compte +/// bancaire) ni le compte d'arrondi ne peuvent être la créance lue sur la vente +/// — l'écriture serait `D 1100 / C 1100`, équilibrée, et le reste dû baisserait +/// sans que rien ne bouge au grand livre. +/// +/// **Ordre des refus**, celui des lectures : statut et date ; créance (lue sur +/// la vente) ; **contrepartie** — compte bancaire inconnu (404), sans compte lié +/// (`CONFIGURATION_REQUIRED`), compte interne archivé ou inconnu +/// (`INACTIVE_OR_INVALID_ACCOUNTS`), non imputable (`ACCOUNT_NOT_POSTABLE`), +/// **puis** contrepartie = créance (`SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, +/// `role: counterparty`) ; **trop-perçu** ; **compte d'arrondi** — +/// `ROUNDING_ACCOUNT_NOT_CONFIGURED`, puis compte d'arrondi = créance +/// (`role: rounding`). Trop-perçu et écart d'arrondi sont exclusifs. Aucun de +/// ces refus n'écrit rien. pub async fn settle_invoice( pool: &MySqlPool, user_id: i64, @@ -171,6 +189,19 @@ pub async fn settle_invoice( } }; + // (3 bis) ⛔ Story 15-6b (#474) — la contrepartie n'est pas la créance. + // Comparée aussitôt résolue, donc après ses propres refus et avant + // le trop-perçu : un montant excessif sur 1100 rend ce refus-ci, le + // défaut de fond. Aucune relecture, sauf le numéro à l'échec. + invoice_settlements::refuse_if_claim_account( + &mut tx, + company_id, + counterparty_account_id, + receivable_account_id, + ClaimSubject::Counterparty(ClaimSide::Receivable), + ) + .await?; + // (4) ⛔ Le trop-perçu est refusé AVANT toute écriture — à DOUBLE BORNE // (Story 25-4-c3-b, AC 3) : un paiement égal au reste arrondi au centime // solde la facture, l'écart passant en écriture ; au-delà du brut, tout le @@ -185,18 +216,27 @@ pub async fn settle_invoice( } PaymentAgainstDue::Ordinary => (amount, None), // (4bis) Le compte d'arrondi n'est exigé QUE s'il y a un écart, et il - // est revérifié ici, au moment d'écrire (AC 4). - PaymentAgainstDue::SettlesWithRounding { raw_due } => ( - raw_due, - Some( - company_invoice_settings::rounding_account_for_write( - &mut tx, - company_id, - crate::errors::RoundingContext::Payment, - ) - .await?, - ), - ), + // est revérifié ici, au moment d'écrire (AC 4) — puis comparé + // à la créance (Story 15-6b, AC3 bis) : son type, contrôlé, ne + // suffit pas, celui d'un compte qui porte des écritures se + // change avec confirmation. + PaymentAgainstDue::SettlesWithRounding { raw_due } => { + let rounding = company_invoice_settings::rounding_account_for_write( + &mut tx, + company_id, + crate::errors::RoundingContext::Payment, + ) + .await?; + invoice_settlements::refuse_if_claim_account( + &mut tx, + company_id, + rounding, + receivable_account_id, + ClaimSubject::Designated(GapAccountRole::Rounding), + ) + .await?; + (raw_due, Some(rounding)) + } }; // (5) Exercice OUVERT couvrant la date de règlement. @@ -355,6 +395,18 @@ pub struct WriteOffOutcome { /// - ⛔ **Tant qu'un solde existe, la facture est payée** : il éteint tout le /// reste, et aucun autre règlement ne s'annule avant lui /// (`SettlementCancelBlocker::WriteOffExists`). +/// - ⛔ **Aucun compte d'écart n'est la créance** (Story 15-6b, #474, AC3 bis) : +/// le compte de la nature, celui du reste d'arrondi et celui de TVA due sont +/// comparés par **identifiant** à la créance lue sur la vente — le contrôle de +/// type des comptes désignés ne suffit pas (un compte qui porte des écritures +/// change de type avec confirmation), et la TVA due n'en a aucun au moment +/// d'écrire. Chaque comparaison suit la lecture de son compte et celle de la +/// créance ; l'ordre des refus est donc celui des lectures : **nature** +/// (`role: write_off_nature`, ou `rounding` pour la nature `rounding`), **reste +/// d'arrondi** (`role: rounding` — même colonne de réglage), **TVA due** +/// (`role: vat_payable`). Le premier égal est nommé ; une nature égale à la +/// créance précède les refus de configuration du reste d'arrondi et de la TVA +/// due. #[allow(clippy::too_many_arguments)] pub async fn write_off_invoice( pool: &MySqlPool, @@ -439,6 +491,23 @@ pub async fn write_off_invoice( invoice_settlements::sale_receivable_account(&mut tx, company_id, sale_entry_id) .await? .ok_or_else(|| DbError::Invariant("écriture de vente sans ligne de débit".into()))?; + // (4 bis) Story 15-6b — le compte de la nature n'est pas la créance, comparé + // aussitôt la créance lue (la nature a été lue avant elle ; aucun + // refus métier ne les sépare, et aucune lecture ne s'avance). La + // nature `rounding` lit la colonne du compte d'arrondi : son rôle est + // `rounding`. + invoice_settlements::refuse_if_claim_account( + &mut tx, + company_id, + nature_account_id, + receivable_account_id, + ClaimSubject::Designated(if nature == SettlementWriteOffNature::Rounding { + GapAccountRole::Rounding + } else { + GapAccountRole::WriteOffNature + }), + ) + .await?; // (5) La TVA corrigée, au prorata des taux (escompte, perte). let shares = if nature.corrects_vat() { @@ -476,14 +545,22 @@ pub async fn write_off_invoice( let rounding_account_id = if nature == SettlementWriteOffNature::Rounding { Some(nature_account_id) } else if amount != invoice_settlements::amount_due_to_centime(amount) { - Some( - company_invoice_settings::write_off_account_for_write( - &mut tx, - company_id, - SettlementWriteOffNature::Rounding, - ) - .await?, + let rounding = company_invoice_settings::write_off_account_for_write( + &mut tx, + company_id, + SettlementWriteOffNature::Rounding, ) + .await?; + // Story 15-6b — le compte du reste d'arrondi n'est pas la créance. + invoice_settlements::refuse_if_claim_account( + &mut tx, + company_id, + rounding, + receivable_account_id, + ClaimSubject::Designated(GapAccountRole::Rounding), + ) + .await?; + Some(rounding) } else { None }; @@ -491,7 +568,20 @@ pub async fn write_off_invoice( let vat_account_id = if shares.is_empty() { None } else { - Some(company_invoice_settings::vat_payable_account_for_write(&mut tx, company_id).await?) + let vat = + company_invoice_settings::vat_payable_account_for_write(&mut tx, company_id).await?; + // Story 15-6b — la TVA due n'est pas la créance. Aucun contrôle de type ne + // garde ce compte au moment d'écrire : cette comparaison est sa seule + // garde contre `D 1100 / C 1100`. + invoice_settlements::refuse_if_claim_account( + &mut tx, + company_id, + vat, + receivable_account_id, + ClaimSubject::Designated(GapAccountRole::VatPayable), + ) + .await?; + Some(vat) }; // (6) Exercice ouvert, puis l'écriture au journal OD. diff --git a/crates/kesh-db/src/repositories/payment_batches.rs b/crates/kesh-db/src/repositories/payment_batches.rs index f6cdda94..c06ce2a0 100644 --- a/crates/kesh-db/src/repositories/payment_batches.rs +++ b/crates/kesh-db/src/repositories/payment_batches.rs @@ -16,7 +16,8 @@ use sqlx::MySqlPool; use crate::entities::{ NewAuditLogEntry, NewPaymentBatch, PaymentBatch, PaymentBatchItem, SettlementChoice, }; -use crate::errors::{DbError, map_db_error}; +use crate::errors::{ClaimSide, DbError, SettlementBatchContext, map_db_error}; +use crate::repositories::invoice_settlements::{self, ClaimSubject}; use crate::repositories::{audit_log, supplier_invoices}; /// Échec per-facture lors de la génération d'un lot (pattern `FailedProposal`). @@ -96,11 +97,11 @@ pub async fn create_batch( "le compte bancaire source est archivé".into(), )); } - if journal_account_id.is_none() { + let Some(bank_ledger_account_id) = journal_account_id else { return Err(DbError::ConfigurationRequired( "bank_account.journal_account_id".into(), )); - } + }; // (1) Valider chaque facture (FailedProposal per-facture). let mut accepted: Vec = Vec::new(); @@ -111,7 +112,15 @@ pub async fn create_batch( if !seen.insert(sid) { continue; // doublon dans la requête : ignoré silencieusement. } - match validate_invoice_for_batch(&mut tx, company_id, sid).await? { + match validate_invoice_for_batch( + &mut tx, + company_id, + sid, + bank_account_id, + bank_ledger_account_id, + ) + .await? + { Ok(acc) => accepted.push(acc), Err(f) => failed.push(f), } @@ -227,10 +236,23 @@ pub async fn create_batch( /// (`FOR UPDATE`, sérialise vs pay/cancel/autre lot). Retourne `Ok(Accepted)` /// ou `Err(PaymentBatchFailedItem)` (échec métier per-facture), ou un `DbError` /// (échec infra → propage). +/// +/// ⛔ **Story 15-6b (#474) — la défense principale contre un lot inconfirmable** : +/// après ses refus existants, la dette lue sur l'écriture d'achat +/// (`supplier_invoices::purchase_payable_line`) est comparée au compte du grand +/// livre du compte bancaire source. Égalité → la facture va dans `failed[]` +/// (`SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`) : sa confirmation écrirait +/// `D 2000 / C 2000`, et le fichier pain.001, peut-être déjà déposé à la +/// banque, ne pourrait plus être confirmé. Une écriture d'achat sans ligne de +/// crédit est une donnée de CETTE facture (`SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED`), +/// pas une erreur globale. Une erreur SQL de la lecture du numéro, elle, est +/// propagée comme les autres `DbError`. async fn validate_invoice_for_batch( tx: &mut sqlx::Transaction<'_, sqlx::MySql>, company_id: i64, sid: i64, + bank_account_id: i64, + bank_ledger_account_id: i64, ) -> Result, DbError> { fn fail(sid: i64, code: &str) -> Result { Err(PaymentBatchFailedItem { @@ -240,16 +262,19 @@ async fn validate_invoice_for_batch( }) } - // (status, creditor_iban, creditor_qr_iban, payment_reference, total_amount) + // (status, creditor_iban, creditor_qr_iban, payment_reference, total_amount, + // purchase_journal_entry_id) type InvoiceCoordsRow = ( String, Option, Option, Option, Decimal, + i64, ); let row: Option = sqlx::query_as( - "SELECT status, creditor_iban, creditor_qr_iban, payment_reference, total_amount \ + "SELECT status, creditor_iban, creditor_qr_iban, payment_reference, total_amount, \ + purchase_journal_entry_id \ FROM supplier_invoices WHERE id = ? AND company_id = ? FOR UPDATE", ) .bind(sid) @@ -258,7 +283,7 @@ async fn validate_invoice_for_batch( .await .map_err(map_db_error)?; - let Some((status, iban, qr_iban, _reference, total_amount)) = row else { + let Some((status, iban, qr_iban, _reference, total_amount, purchase_entry_id)) = row else { return Ok(fail(sid, "SUPPLIER_INVOICE_NOT_FOUND")); }; if status != "open" { @@ -293,6 +318,54 @@ async fn validate_invoice_for_batch( return Ok(fail(sid, "INVALID_IBAN")); } + // Story 15-6b — la dette, lue sur l'écriture d'achat, n'est pas le compte de + // banque du lot. + let Some((payable_account_id, _ttc)) = + supplier_invoices::purchase_payable_line(tx, company_id, purchase_entry_id).await? + else { + return Ok(Err(PaymentBatchFailedItem { + supplier_invoice_id: sid, + error_code: "SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED".to_string(), + details: Some(serde_json::json!({ + "reason": "no_credit_line_on_purchase_entry", + "purchaseEntryId": purchase_entry_id, + })), + })); + }; + if let Err(refusal) = invoice_settlements::refuse_if_claim_account( + tx, + company_id, + bank_ledger_account_id, + payable_account_id, + ClaimSubject::Counterparty(ClaimSide::Payable), + ) + .await + { + return match &refusal { + // Le refus métier devient un item de `failed[]`, `details` par le + // helper commun (mêmes clés que le rapprochement et le mapping HTTP). + DbError::SettlementCounterpartyIsClaimAccount { + account_id, + account_number, + claim, + role, + .. + } => Ok(Err(PaymentBatchFailedItem { + supplier_invoice_id: sid, + error_code: refusal.error_code().to_string(), + details: Some(invoice_settlements::claim_account_refusal_details( + *account_id, + account_number.as_deref(), + *claim, + *role, + Some(bank_account_id), + )), + })), + // Erreur SQL de la lecture du numéro : échec infra de toute la création. + _ => Err(refusal), + }; + } + Ok(Ok(Accepted { supplier_invoice_id: sid, amount: total_amount, @@ -301,6 +374,18 @@ async fn validate_invoice_for_batch( /// Confirme un lot `generated` : poste les règlements de toutes les factures /// dans UNE transaction atomique. Pré-check du compte source archivé. +/// +/// ⛔ **Story 15-6b (#474) — deux contrôles, et pourquoi le second subsiste.** La +/// création du lot refuse déjà une facture dont le compte créanciers est le +/// compte de banque du lot. Mais le compte bancaire a pu être **relié** entre la +/// création et la confirmation : la garde de `pay_in_tx` refuse alors le +/// règlement, et la confirmation entière échoue (transaction atomique, le lot +/// reste `generated`, rien d'écrit). Le refus est **intercepté** pour porter le +/// lot et la facture (`SettlementBatchContext`, numéro de facture relu ici) : +/// le remède n'est pas celui du règlement unitaire. **Deux issues** : relier le +/// compte bancaire à son propre compte de banque puis confirmer de nouveau, ou +/// annuler le lot (`cancel_batch` — les factures redeviennent réglables à +/// l'unité) et régler la facture depuis sa fiche. pub async fn confirm_batch( pool: &MySqlPool, company_id: i64, @@ -357,7 +442,7 @@ pub async fn confirm_batch( .map_err(map_db_error)?; for item in &items { - supplier_invoices::pay_in_tx( + let paid = supplier_invoices::pay_in_tx( &mut tx, company_id, item.supplier_invoice_id, @@ -367,7 +452,43 @@ pub async fn confirm_batch( payment_date, user_id, ) - .await?; + .await; + match paid { + Ok(_) => {} + // Story 15-6b — le refus « contrepartie = dette », reconstruit avec + // son lot. La transaction reste utilisable : c'est un refus métier, + // aucune erreur SQL ne l'a précédé. + Err(DbError::SettlementCounterpartyIsClaimAccount { + account_id, + account_number, + claim, + role, + batch: None, + }) => { + let supplier_invoice_number: Option = sqlx::query_scalar( + "SELECT supplier_invoice_number FROM supplier_invoices \ + WHERE id = ? AND company_id = ?", + ) + .bind(item.supplier_invoice_id) + .bind(company_id) + .fetch_optional(&mut *tx) + .await + .map_err(map_db_error)? + .flatten(); + return Err(DbError::SettlementCounterpartyIsClaimAccount { + account_id, + account_number, + claim, + role, + batch: Some(SettlementBatchContext { + payment_batch_id: batch_id, + supplier_invoice_id: item.supplier_invoice_id, + supplier_invoice_number, + }), + }); + } + Err(e) => return Err(e), + } } // (4) Lot → confirmed. diff --git a/crates/kesh-db/src/repositories/supplier_invoices.rs b/crates/kesh-db/src/repositories/supplier_invoices.rs index 4f179191..d7a6eea0 100644 --- a/crates/kesh-db/src/repositories/supplier_invoices.rs +++ b/crates/kesh-db/src/repositories/supplier_invoices.rs @@ -23,9 +23,10 @@ use crate::entities::{ NewAuditLogEntry, NewJournalEntryLine, NewSupplierInvoice, SettlementChoice, SupplierInvoice, SupplierInvoiceLine, }; -use crate::errors::{DbError, NonPostableAccount, map_db_error}; +use crate::errors::{ClaimSide, DbError, NonPostableAccount, map_db_error}; use crate::repositories::audit_log; use crate::repositories::company_invoice_settings::{DesignatedRole, GeneratedLines}; +use crate::repositories::invoice_settlements::{self, ClaimSubject}; /// SELECT scopé multi-tenant (anti-IDOR — toujours `AND company_id = ?`). const FIND_SCOPED_SQL: &str = "SELECT id, company_id, contact_id, supplier_invoice_number, status, \ @@ -645,6 +646,35 @@ async fn in_generated_batch( .map_err(map_db_error) } +/// La ligne de **crédit** de l'écriture d'achat — `(compte créanciers, TTC)` — +/// ou `None` si l'écriture n'en a pas (Story 15-6b, #474 ; choix C-15-6-11). +/// +/// Lecteur sœur de `invoice_settlements::sale_receivable_account` : la dette se +/// lit **sur l'écriture d'achat**, jamais sur les réglages, pour que le compte +/// se solde exactement quoi qu'il soit arrivé à la configuration. Il diffère du +/// lecteur de la vente (ligne de crédit, et le montant). Deux appelants : +/// `pay_in_tx` (dont `None` est un `Invariant`) et la création d'un lot de +/// paiement (dont `None` est un refus de **cette** facture, +/// `SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED`). Portée par `je.company_id`. +/// Aucun verrou : les lignes d'une écriture d'achat sont gelées. +pub async fn purchase_payable_line( + conn: &mut sqlx::MySqlConnection, + company_id: i64, + purchase_entry_id: i64, +) -> Result, DbError> { + sqlx::query_as( + "SELECT jel.account_id, jel.credit FROM journal_entry_lines jel \ + JOIN journal_entries je ON je.id = jel.entry_id \ + WHERE jel.entry_id = ? AND je.company_id = ? AND jel.credit > 0 \ + ORDER BY jel.id LIMIT 1", + ) + .bind(purchase_entry_id) + .bind(company_id) + .fetch_optional(&mut *conn) + .await + .map_err(map_db_error) +} + /// Règle une facture fournisseur `open` (choix binaire). Poste l'écriture de /// règlement et passe en `paid`. Refuse si la facture est dans un lot `generated`. pub async fn pay( @@ -708,18 +738,13 @@ pub async fn pay_in_tx( // (2) Compte débité (créanciers) ET montant = ligne de crédit de l'écriture // d'achat → solde 2000 garanti à 0 quelle que soit l'évolution des settings. - let (payable_account_id, ttc): (i64, Decimal) = sqlx::query_as( - "SELECT jel.account_id, jel.credit FROM journal_entry_lines jel \ - JOIN journal_entries je ON je.id = jel.entry_id \ - WHERE jel.entry_id = ? AND je.company_id = ? AND jel.credit > 0 \ - ORDER BY jel.id LIMIT 1", - ) - .bind(inv.purchase_journal_entry_id) - .bind(company_id) - .fetch_optional(&mut **tx) - .await - .map_err(map_db_error)? - .ok_or_else(|| DbError::Invariant("écriture d'achat sans ligne de crédit créanciers".into()))?; + // Lecteur partagé avec la création d'un lot (Story 15-6b). + let (payable_account_id, ttc) = + purchase_payable_line(tx, company_id, inv.purchase_journal_entry_id) + .await? + .ok_or_else(|| { + DbError::Invariant("écriture d'achat sans ligne de crédit créanciers".into()) + })?; // (3) Contrepartie selon le choix binaire (company-scoped). let (counterparty_account_id, settlement_type, bank_account_id, internal_account_id, journal) = @@ -752,6 +777,8 @@ pub async fn pay_in_tx( // ⚠️ ACTIF **et IMPUTABLE** — jumeau exact de la garde de // `invoice_settlements_write.rs`. Ce flux écrit sans la garde de // postabilité de la 14-3b, donc ce SELECT est le seul contrôle. + // Jumeau aussi pour la garde qui suit l'étape (3) : la + // contrepartie n'est pas le compte créanciers (Story 15-6b). // // Ici l'écran filtre déjà `active && postable` // (`supplier-invoices/[id]/+page.svelte:66`), si bien que le @@ -796,6 +823,20 @@ pub async fn pay_in_tx( } }; + // (3 bis) ⛔ Story 15-6b (#474) — la contrepartie n'est pas la dette : + // `D 2000 / C 2000` s'équilibre, et la facture passerait « payée » + // sans paiement. Comparée aussitôt résolue, après ses propres refus. + // À la confirmation d'un lot, `confirm_batch` intercepte ce refus pour + // le contextualiser. + invoice_settlements::refuse_if_claim_account( + tx, + company_id, + counterparty_account_id, + payable_account_id, + ClaimSubject::Counterparty(ClaimSide::Payable), + ) + .await?; + // (4) Exercice ouvert couvrant la date de règlement. let fy = fiscal_years::find_open_covering_date(tx, company_id, payment_date) .await? diff --git a/crates/kesh-db/tests/invoice_settlement.rs b/crates/kesh-db/tests/invoice_settlement.rs index ea4182d6..cb3f1e85 100644 --- a/crates/kesh-db/tests/invoice_settlement.rs +++ b/crates/kesh-db/tests/invoice_settlement.rs @@ -1265,3 +1265,326 @@ async fn une_cloture_concurrente_attend_l_annulation(pool: MySqlPool) { "rien n'a été annulé" ); } + +// --------------------------------------------------------------------------- +// Story 15-6b (#474) — un règlement ne vise pas le compte qu'il solde +// --------------------------------------------------------------------------- +mod contrepartie_distincte_de_la_creance { + use super::*; + use kesh_db::errors::{ClaimSide, SettlementAccountRole}; + + /// Un compte bancaire lié au compte du grand livre `ledger_id`. + async fn bank_account_linked_to( + pool: &MySqlPool, + seeded: &SeededCompany, + ledger_id: i64, + ) -> i64 { + sqlx::query_scalar( + "INSERT INTO bank_accounts (company_id, bank_name, iban, is_primary, journal_account_id) \ + VALUES (?, 'UBS', 'CH4431999123000889012', TRUE, ?) RETURNING id", + ) + .bind(seeded.company_id) + .bind(ledger_id) + .fetch_one(pool) + .await + .expect("compte bancaire") + } + + /// Le compte de banque 1020, absent du seed (`test_fixtures.rs`). + async fn bank_ledger_1020(pool: &MySqlPool, seeded: &SeededCompany) -> i64 { + sqlx::query_scalar( + "INSERT INTO accounts (company_id, number, name, account_type, active, postable) \ + VALUES (?, '1020', 'Banque', 'Asset', 1, 1) RETURNING id", + ) + .bind(seeded.company_id) + .fetch_one(pool) + .await + .expect("compte banque") + } + + async fn settle( + pool: &MySqlPool, + seeded: &SeededCompany, + inv_id: i64, + choice: SettlementChoice, + amount: Decimal, + ) -> Result { + invoice_settlements_write::settle_invoice( + pool, + seeded.admin_user_id, + seeded.company_id, + inv_id, + choice, + amount, + ymd(2026, 3, 5), + ) + .await + } + + async fn scalar(pool: &MySqlPool, sql: &str, id: i64) -> i64 { + sqlx::query_scalar(sql) + .bind(id) + .fetch_one(pool) + .await + .expect("lecture") + } + + /// Rien n'est écrit : ni écriture, ni ligne de règlement, ni `paid_at`, ni + /// `version`, ni audit. + async fn assert_nothing_written( + pool: &MySqlPool, + inv_id: i64, + (entries_before, version_before): (i64, i32), + ) { + let (paid_at, version): (Option, i32) = + sqlx::query_as("SELECT paid_at, version FROM invoices WHERE id = ?") + .bind(inv_id) + .fetch_one(pool) + .await + .unwrap(); + assert!(paid_at.is_none(), "paid_at inchangé"); + assert_eq!(version, version_before, "version inchangée"); + assert_eq!( + scalar( + pool, + "SELECT COUNT(*) FROM invoice_settlements WHERE invoice_id = ?", + inv_id + ) + .await, + 0, + "aucune ligne de règlement" + ); + let entries: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(entries, entries_before, "aucune écriture neuve"); + assert_eq!( + scalar( + pool, + "SELECT COUNT(*) FROM audit_log WHERE entity_type = 'invoice' \ + AND action IN ('invoice.paid', 'invoice.partially_settled') AND entity_id = ?", + inv_id + ) + .await, + 0, + "aucun audit" + ); + } + + /// L'état avant le geste : nombre d'écritures, `version` de la facture. + async fn before(pool: &MySqlPool, inv_id: i64) -> (i64, i32) { + let entries: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(pool) + .await + .unwrap(); + let version: i32 = sqlx::query_scalar("SELECT version FROM invoices WHERE id = ?") + .bind(inv_id) + .fetch_one(pool) + .await + .unwrap(); + (entries, version) + } + + fn assert_counterparty_refusal(err: &DbError, receivable: i64) { + match err { + DbError::SettlementCounterpartyIsClaimAccount { + account_id, + account_number, + claim: ClaimSide::Receivable, + role: SettlementAccountRole::Counterparty, + batch: None, + } => { + assert_eq!(*account_id, receivable); + assert_eq!(account_number.as_deref(), Some("1100")); + } + other => panic!("refus de contrepartie attendu, obtenu {other:?}"), + } + } + + /// Test 1 — **compte interne = créance** : `D 1100 / C 1100` refusé. + #[sqlx::test(migrations = "./test-schema")] + async fn an_internal_account_that_is_the_receivable_is_refused(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let creance = seeded.accounts["1100"]; + let inv_id = validated_invoice(&pool, &seeded, dec!(100.00), ymd(2026, 3, 1)).await; + let n = before(&pool, inv_id).await; + + let err = settle( + &pool, + &seeded, + inv_id, + SettlementChoice::InternalAccount { + account_id: creance, + }, + dec!(100.00), + ) + .await + .expect_err("la créance comme contrepartie doit être refusée"); + assert_counterparty_refusal(&err, creance); + assert_nothing_written(&pool, inv_id, n).await; + } + + /// Test 2 — **virement sur un compte bancaire lié au 1100** : même refus. + #[sqlx::test(migrations = "./test-schema")] + async fn a_bank_account_linked_to_the_receivable_is_refused(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let creance = seeded.accounts["1100"]; + let inv_id = validated_invoice(&pool, &seeded, dec!(100.00), ymd(2026, 3, 1)).await; + let bank_account_id = bank_account_linked_to(&pool, &seeded, creance).await; + let n = before(&pool, inv_id).await; + + let err = settle( + &pool, + &seeded, + inv_id, + SettlementChoice::BankTransfer { bank_account_id }, + dec!(100.00), + ) + .await + .expect_err("un compte bancaire lié à la créance doit être refusé"); + assert_counterparty_refusal(&err, creance); + assert_nothing_written(&pool, inv_id, n).await; + } + + /// Test 3 — non-régression : un règlement ordinaire, par banque 1020 puis + /// par la caisse, reste accepté. + #[sqlx::test(migrations = "./test-schema")] + async fn an_ordinary_settlement_is_still_accepted(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let inv_id = validated_invoice(&pool, &seeded, dec!(100.00), ymd(2026, 3, 1)).await; + let ledger = bank_ledger_1020(&pool, &seeded).await; + let bank_account_id = bank_account_linked_to(&pool, &seeded, ledger).await; + + let out = settle( + &pool, + &seeded, + inv_id, + SettlementChoice::BankTransfer { bank_account_id }, + dec!(30.00), + ) + .await + .expect("virement"); + assert_eq!(out.amount_due_after, dec!(70.00)); + let out = settle( + &pool, + &seeded, + inv_id, + SettlementChoice::InternalAccount { + account_id: seeded.accounts["1000"], + }, + dec!(70.00), + ) + .await + .expect("espèces"); + assert!(out.fully_settled); + assert_eq!(solde(&pool, seeded.accounts["1100"]).await, Decimal::ZERO); + } + + /// Test 4 — **montant excessif sur 1100** : le refus est celui de la + /// contrepartie, pas le trop-perçu (la contrepartie précède). + #[sqlx::test(migrations = "./test-schema")] + async fn the_counterparty_refusal_comes_before_the_overpayment(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let creance = seeded.accounts["1100"]; + let inv_id = validated_invoice(&pool, &seeded, dec!(100.00), ymd(2026, 3, 1)).await; + let n = before(&pool, inv_id).await; + + let err = settle( + &pool, + &seeded, + inv_id, + SettlementChoice::InternalAccount { + account_id: creance, + }, + dec!(500.00), + ) + .await + .expect_err("refus attendu"); + assert_counterparty_refusal(&err, creance); + assert_nothing_written(&pool, inv_id, n).await; + } + + /// Test 9 — **ordre** : la créance archivée comme compte interne rend + /// l'ancien refus (`INACTIVE_OR_INVALID_ACCOUNTS`), qui prime. + #[sqlx::test(migrations = "./test-schema")] + async fn an_archived_receivable_as_internal_account_keeps_the_older_refusal(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let creance = seeded.accounts["1100"]; + let inv_id = validated_invoice(&pool, &seeded, dec!(100.00), ymd(2026, 3, 1)).await; + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(creance) + .execute(&pool) + .await + .unwrap(); + + let err = settle( + &pool, + &seeded, + inv_id, + SettlementChoice::InternalAccount { + account_id: creance, + }, + dec!(100.00), + ) + .await + .expect_err("refus attendu"); + assert!( + matches!(err, DbError::InactiveOrInvalidAccounts), + "l'ancien refus prime : {err:?}" + ); + } + + /// Test 11 — **arrondi du règlement = créance** (AC3 bis) : la créance + /// retypée en charge et désignée compte d'arrondi ; un paiement au centime + /// d'un reste brut hors centime, par la caisse, écrirait l'écart sur 1100. + /// Refus `role: Rounding` — le compte interne, valide, a été contrôlé avant. + #[sqlx::test(migrations = "./test-schema")] + async fn a_payment_rounding_account_that_is_the_receivable_is_refused(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let creance = seeded.accounts["1100"]; + kesh_db::test_fixtures::designate_rounding_account(&pool, seeded.company_id) + .await + .expect("compte d'arrondi"); + let inv_id = validated_invoice(&pool, &seeded, dec!(10.0050), ymd(2026, 3, 1)).await; + sqlx::query("UPDATE accounts SET account_type = 'Expense', role = NULL WHERE id = ?") + .bind(creance) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "UPDATE company_invoice_settings SET default_rounding_account_id = ? WHERE company_id = ?", + ) + .bind(creance) + .bind(seeded.company_id) + .execute(&pool) + .await + .unwrap(); + let n = before(&pool, inv_id).await; + + let err = settle( + &pool, + &seeded, + inv_id, + SettlementChoice::InternalAccount { + account_id: seeded.accounts["1000"], + }, + dec!(10.01), + ) + .await + .expect_err("refus attendu"); + assert!( + matches!( + err, + DbError::SettlementCounterpartyIsClaimAccount { + claim: ClaimSide::Receivable, + role: SettlementAccountRole::Rounding, + batch: None, + .. + } + ), + "{err:?}" + ); + assert_nothing_written(&pool, inv_id, n).await; + } +} diff --git a/crates/kesh-db/tests/invoice_write_off.rs b/crates/kesh-db/tests/invoice_write_off.rs index d63803f9..6d830e41 100644 --- a/crates/kesh-db/tests/invoice_write_off.rs +++ b/crates/kesh-db/tests/invoice_write_off.rs @@ -14,7 +14,7 @@ use kesh_db::entities::{ NewCreditNote, NewInvoice, NewInvoiceLine, NewJournalEntry, NewJournalEntryLine, SettlementChoice, SettlementWriteOffNature as Nature, }; -use kesh_db::errors::{DbError, SettlementCancelBlocker}; +use kesh_db::errors::{ClaimSide, DbError, SettlementAccountRole, SettlementCancelBlocker}; use kesh_db::repositories::{ credit_notes, invoice_settlements, invoice_settlements_write, invoices, reconciliation, reconciliation_cancel, @@ -1186,3 +1186,254 @@ fn write_off_lines_put_a_sub_half_centime_remainder_on_the_rounding_account() { vec![(9, dec!(0.0040), dec!(0)), (2, dec!(0), dec!(0.0040))] ); } + +// --------------------------------------------------------------------------- +// Story 15-6b (#474, AC3 bis) — un compte d'écart désigné n'est pas la créance +// --------------------------------------------------------------------------- + +/// Le chemin `confirm_retype` sans la route : la créance 1100 devient un compte +/// de `account_type` (rôle retiré), et porte toujours la créance des ventes +/// déjà validées. C'est ce qui rend le contrôle de TYPE d'un compte désigné +/// insuffisant. +async fn retype_receivable(pool: &MySqlPool, seeded: &SeededCompany, account_type: &str) { + sqlx::query("UPDATE accounts SET account_type = ?, role = NULL WHERE id = ?") + .bind(account_type) + .bind(seeded.accounts["1100"]) + .execute(pool) + .await + .expect("changement de type"); +} + +/// Désigne `account_id` dans la colonne `column` des réglages de facturation. +async fn designate( + pool: &MySqlPool, + seeded: &SeededCompany, + column: &str, + account_id: Option, +) { + sqlx::query(&format!( + "UPDATE company_invoice_settings SET {column} = ? WHERE company_id = ?" + )) + .bind(account_id) + .bind(seeded.company_id) + .execute(pool) + .await + .expect("désignation"); +} + +/// Rien n'a été écrit : facture non payée, version inchangée, aucune ligne de +/// règlement, aucune écriture neuve, aucun audit de solde. +async fn assert_nothing_written( + pool: &MySqlPool, + inv: i64, + version_before: i32, + entries_before: i64, +) { + let (paid_at, version): (Option, i32) = + sqlx::query_as("SELECT paid_at, version FROM invoices WHERE id = ?") + .bind(inv) + .fetch_one(pool) + .await + .unwrap(); + assert!(paid_at.is_none(), "la facture ne doit pas être payée"); + assert_eq!(version, version_before, "version inchangée"); + let settlements: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM invoice_settlements WHERE invoice_id = ?") + .bind(inv) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(settlements, 0, "aucune ligne de règlement"); + let entries: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(entries, entries_before, "aucune écriture neuve"); + let audits: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = 'invoice.written_off' AND entity_id = ?", + ) + .bind(inv) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(audits, 0, "aucun audit"); +} + +async fn entry_count(pool: &MySqlPool) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(pool) + .await + .unwrap() +} + +/// Story 15-6b, test 10 — **nature = créance** : la créance 1100 retypée en +/// charge et désignée compte d'escompte ; solder en escompte écrirait +/// `D 1100 / C 1100`. Refus `role: WriteOffNature`, rien d'écrit. +#[sqlx::test(migrations = "./test-schema")] +async fn a_write_off_nature_account_that_is_the_receivable_is_refused(pool: MySqlPool) { + let seeded = company(&pool).await; + let inv = validated_invoice(&pool, &seeded, &[(dec!(100.00), dec!(0))]).await; + retype_receivable(&pool, &seeded, "Expense").await; + designate( + &pool, + &seeded, + "default_discount_account_id", + Some(seeded.accounts["1100"]), + ) + .await; + let (v, n) = (version(&pool, inv).await, entry_count(&pool).await); + + let err = write_off(&pool, &seeded, inv, Nature::Discount) + .await + .unwrap_err(); + match err { + DbError::SettlementCounterpartyIsClaimAccount { + account_id, + ref account_number, + claim: ClaimSide::Receivable, + role: SettlementAccountRole::WriteOffNature, + batch: None, + } => { + assert_eq!(account_id, seeded.accounts["1100"]); + assert_eq!(account_number.as_deref(), Some("1100")); + } + other => panic!("refus attendu, obtenu {other:?}"), + } + assert_nothing_written(&pool, inv, v, n).await; +} + +/// Story 15-6b, test 10 bis — **reste d'arrondi = créance** : la nature est un +/// compte de charge distinct, mais le reste à quatre décimales envoie sa +/// fraction de centime au compte d'arrondi, qui est la créance retypée. Le rôle +/// suit la COLONNE lue (`rounding`), pas la nature du geste. +#[sqlx::test(migrations = "./test-schema")] +async fn a_rounding_remainder_account_that_is_the_receivable_is_refused_as_rounding( + pool: MySqlPool, +) { + let seeded = company(&pool).await; + let inv = validated_invoice(&pool, &seeded, &[(dec!(3.3350), dec!(0))]).await; + retype_receivable(&pool, &seeded, "Expense").await; + designate( + &pool, + &seeded, + "default_rounding_account_id", + Some(seeded.accounts["1100"]), + ) + .await; + let (v, n) = (version(&pool, inv).await, entry_count(&pool).await); + + let err = write_off(&pool, &seeded, inv, Nature::Discount) + .await + .unwrap_err(); + assert!( + matches!( + err, + DbError::SettlementCounterpartyIsClaimAccount { + claim: ClaimSide::Receivable, + role: SettlementAccountRole::Rounding, + .. + } + ), + "{err:?}" + ); + assert_nothing_written(&pool, inv, v, n).await; +} + +/// Story 15-6b — la nature `rounding` EST le compte d'arrondi : une seule +/// comparaison, sous `role: Rounding`. +#[sqlx::test(migrations = "./test-schema")] +async fn the_rounding_nature_on_the_receivable_is_refused_as_rounding(pool: MySqlPool) { + let seeded = company(&pool).await; + let inv = validated_invoice(&pool, &seeded, &[(dec!(0.0300), dec!(0))]).await; + retype_receivable(&pool, &seeded, "Expense").await; + designate( + &pool, + &seeded, + "default_rounding_account_id", + Some(seeded.accounts["1100"]), + ) + .await; + let (v, n) = (version(&pool, inv).await, entry_count(&pool).await); + + let err = write_off(&pool, &seeded, inv, Nature::Rounding) + .await + .unwrap_err(); + assert!( + matches!( + err, + DbError::SettlementCounterpartyIsClaimAccount { + role: SettlementAccountRole::Rounding, + .. + } + ), + "{err:?}" + ); + assert_nothing_written(&pool, inv, v, n).await; +} + +/// Story 15-6b, test 10 ter — **ordre des refus du solde** : la nature égale à +/// la créance est refusée aussitôt la créance lue, AVANT la lecture de la TVA +/// due — donc avant son `CONFIGURATION_REQUIRED`. +#[sqlx::test(migrations = "./test-schema")] +async fn a_nature_on_the_receivable_is_refused_before_a_missing_vat_payable(pool: MySqlPool) { + let seeded = company(&pool).await; + let inv = validated_invoice(&pool, &seeded, &[(dec!(100.00), dec!(8.1))]).await; + retype_receivable(&pool, &seeded, "Expense").await; + designate( + &pool, + &seeded, + "default_discount_account_id", + Some(seeded.accounts["1100"]), + ) + .await; + designate(&pool, &seeded, "default_vat_payable_account_id", None).await; + let (v, n) = (version(&pool, inv).await, entry_count(&pool).await); + + let err = write_off(&pool, &seeded, inv, Nature::Discount) + .await + .unwrap_err(); + assert!( + matches!( + err, + DbError::SettlementCounterpartyIsClaimAccount { + role: SettlementAccountRole::WriteOffNature, + .. + } + ), + "la nature précède la TVA due : {err:?}" + ); + assert_nothing_written(&pool, inv, v, n).await; +} + +/// Story 15-6b — **TVA due = créance** : aucun contrôle de type ne garde ce +/// compte au moment d'écrire ; la comparaison est sa seule garde. +#[sqlx::test(migrations = "./test-schema")] +async fn a_vat_payable_account_that_is_the_receivable_is_refused(pool: MySqlPool) { + let seeded = company(&pool).await; + let inv = validated_invoice(&pool, &seeded, &[(dec!(100.00), dec!(8.1))]).await; + retype_receivable(&pool, &seeded, "Liability").await; + designate( + &pool, + &seeded, + "default_vat_payable_account_id", + Some(seeded.accounts["1100"]), + ) + .await; + let (v, n) = (version(&pool, inv).await, entry_count(&pool).await); + + let err = write_off(&pool, &seeded, inv, Nature::Discount) + .await + .unwrap_err(); + assert!( + matches!( + err, + DbError::SettlementCounterpartyIsClaimAccount { + claim: ClaimSide::Receivable, + role: SettlementAccountRole::VatPayable, + .. + } + ), + "{err:?}" + ); + assert_nothing_written(&pool, inv, v, n).await; +} diff --git a/crates/kesh-db/tests/payment_batches_repository.rs b/crates/kesh-db/tests/payment_batches_repository.rs index 5830df40..2cf3a2de 100644 --- a/crates/kesh-db/tests/payment_batches_repository.rs +++ b/crates/kesh-db/tests/payment_batches_repository.rs @@ -538,3 +538,232 @@ async fn last_confirmed_batch_is_historical_and_most_recent(pool: MySqlPool) { assert_eq!(last.map(|(id, _)| id), Some(second), "le plus récent"); assert_ne!(first, second); } + +// --------------------------------------------------------------------------- +// Story 15-6b (#474, AC6) — refus à la création, garde à la confirmation +// --------------------------------------------------------------------------- + +/// Relie le compte bancaire source du contexte au compte du grand livre `ledger`. +async fn link_bank(pool: &MySqlPool, ctx: &Ctx, ledger: i64) { + sqlx::query("UPDATE bank_accounts SET journal_account_id = ? WHERE id = ?") + .bind(ledger) + .bind(ctx.bank_id) + .execute(pool) + .await + .unwrap(); +} + +/// Le compte de banque 1020, absent du seed. +async fn ledger_1020(pool: &MySqlPool, ctx: &Ctx) -> i64 { + sqlx::query_scalar( + "INSERT INTO accounts (company_id, number, name, account_type, active, postable) \ + VALUES (?, '1020', 'Banque', 'Asset', 1, 1) RETURNING id", + ) + .bind(ctx.seeded.company_id) + .fetch_one(pool) + .await + .unwrap() +} + +/// Story 15-6b, test 13 — **création** : A (dette sur 2000) refusée par +/// facture, B (dette sur 2001) retenue ; avec A seule, aucun lot. +#[sqlx::test(migrations = "./test-schema")] +async fn create_batch_refuses_an_invoice_whose_payable_is_the_bank_ledger(pool: MySqlPool) { + let ctx = setup(&pool).await; + let a = make_invoice(&pool, &ctx, Some(IBAN_A), None, Some("A"), dec!(100.00)).await; + let p2001: i64 = sqlx::query_scalar( + "INSERT INTO accounts (company_id, number, name, account_type) \ + VALUES (?, '2001', 'Créanciers bis', 'Liability') RETURNING id", + ) + .bind(ctx.seeded.company_id) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query( + "UPDATE company_invoice_settings SET default_payable_account_id = ? WHERE company_id = ?", + ) + .bind(p2001) + .bind(ctx.seeded.company_id) + .execute(&pool) + .await + .unwrap(); + let b = make_invoice(&pool, &ctx, Some(IBAN_A), None, Some("B"), dec!(200.00)).await; + let payable = ctx.seeded.accounts["2000"]; + link_bank(&pool, &ctx, payable).await; + + let outcome = + payment_batches::create_batch(&pool, new_batch(&ctx, vec![a, b]), ctx.seeded.admin_user_id) + .await + .unwrap(); + let batch = outcome.batch.expect("B forme un lot"); + assert_eq!(batch.items.len(), 1); + assert_eq!(batch.items[0].supplier_invoice_id, b); + assert_eq!(outcome.failed.len(), 1); + let failed = &outcome.failed[0]; + assert_eq!(failed.supplier_invoice_id, a); + assert_eq!( + failed.error_code, + "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT" + ); + assert_eq!( + failed.details, + Some(serde_json::json!({ + "bankAccountId": ctx.bank_id, + "accountId": payable, + "accountNumber": "2000", + "claim": "payable", + "role": "counterparty", + })) + ); + + // A seule : aucun lot n'est créé — donc aucun fichier pain.001. + let outcome = + payment_batches::create_batch(&pool, new_batch(&ctx, vec![a]), ctx.seeded.admin_user_id) + .await + .unwrap(); + assert!(outcome.batch.is_none(), "aucun lot"); + assert_eq!( + outcome.failed[0].error_code, + "SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT" + ); + let batches: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM payment_batches") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(batches, 1, "seul le lot de B existe"); +} + +/// Story 15-6b, test 14 — **écriture d'achat sans ligne de crédit** : la +/// facture va dans `failed[]` (donnée de CETTE facture), les autres restent. +#[sqlx::test(migrations = "./test-schema")] +async fn create_batch_reports_a_purchase_entry_without_credit_line_per_invoice(pool: MySqlPool) { + let ctx = setup(&pool).await; + let ok = make_invoice(&pool, &ctx, Some(IBAN_A), None, Some("OK"), dec!(100.00)).await; + let bad = make_invoice(&pool, &ctx, Some(IBAN_A), None, Some("KO"), dec!(50.00)).await; + let empty_entry: i64 = sqlx::query_scalar( + "INSERT INTO journal_entries (company_id, fiscal_year_id, entry_number, entry_date, journal, description) \ + VALUES (?, ?, 999999, '2026-06-15', 'Achats', 'Écriture vide') RETURNING id", + ) + .bind(ctx.seeded.company_id) + .bind(ctx.seeded.fiscal_year_id) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("UPDATE supplier_invoices SET purchase_journal_entry_id = ? WHERE id = ?") + .bind(empty_entry) + .bind(bad) + .execute(&pool) + .await + .unwrap(); + + let outcome = payment_batches::create_batch( + &pool, + new_batch(&ctx, vec![ok, bad]), + ctx.seeded.admin_user_id, + ) + .await + .unwrap(); + let batch = outcome.batch.expect("lot créé avec la facture saine"); + assert_eq!(batch.items.len(), 1); + assert_eq!(batch.items[0].supplier_invoice_id, ok); + assert_eq!(outcome.failed.len(), 1); + assert_eq!(outcome.failed[0].supplier_invoice_id, bad); + assert_eq!( + outcome.failed[0].error_code, + "SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED" + ); + assert_eq!( + outcome.failed[0].details, + Some(serde_json::json!({ + "reason": "no_credit_line_on_purchase_entry", + "purchaseEntryId": empty_entry, + })) + ); +} + +/// Story 15-6b, test 15 — **confirmation, puis remède** : le compte bancaire, +/// relié au 2000 après la création, bloque la confirmation (400 contextualisé, +/// lot toujours `generated`, rien d'écrit) ; relié de nouveau au 1020, elle +/// passe. +#[sqlx::test(migrations = "./test-schema")] +async fn confirm_batch_refuses_then_passes_once_the_bank_is_relinked(pool: MySqlPool) { + let ctx = setup(&pool).await; + let l1020 = ledger_1020(&pool, &ctx).await; + link_bank(&pool, &ctx, l1020).await; + let inv = make_invoice(&pool, &ctx, Some(IBAN_A), None, Some("R"), dec!(100.00)).await; + let batch_id = + payment_batches::create_batch(&pool, new_batch(&ctx, vec![inv]), ctx.seeded.admin_user_id) + .await + .unwrap() + .batch + .expect("lot créé") + .batch + .id; + let payable = ctx.seeded.accounts["2000"]; + link_bank(&pool, &ctx, payable).await; + let entries_before: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(&pool) + .await + .unwrap(); + + let err = payment_batches::confirm_batch( + &pool, + ctx.seeded.company_id, + batch_id, + d(2026, 7, 2), + ctx.seeded.admin_user_id, + ) + .await + .expect_err("la confirmation doit être refusée"); + match err { + DbError::SettlementCounterpartyIsClaimAccount { + account_id, + ref account_number, + claim: kesh_db::errors::ClaimSide::Payable, + role: kesh_db::errors::SettlementAccountRole::Counterparty, + batch: Some(ref ctx_batch), + } => { + assert_eq!(account_id, payable); + assert_eq!(account_number.as_deref(), Some("2000")); + assert_eq!(ctx_batch.payment_batch_id, batch_id); + assert_eq!(ctx_batch.supplier_invoice_id, inv); + assert_eq!(ctx_batch.supplier_invoice_number.as_deref(), Some("FF-001")); + } + other => panic!("refus contextualisé attendu, obtenu {other:?}"), + } + let (status,): (String,) = sqlx::query_as("SELECT status FROM payment_batches WHERE id = ?") + .bind(batch_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, "generated"); + let inv_status: String = + sqlx::query_scalar("SELECT status FROM supplier_invoices WHERE id = ?") + .bind(inv) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(inv_status, "open"); + let entries_after: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(entries_after, entries_before, "aucun règlement écrit"); + + // Le remède : relier de nouveau le compte bancaire à son compte de banque. + link_bank(&pool, &ctx, l1020).await; + let confirmed = payment_batches::confirm_batch( + &pool, + ctx.seeded.company_id, + batch_id, + d(2026, 7, 2), + ctx.seeded.admin_user_id, + ) + .await + .expect("la confirmation passe après le remède"); + assert_eq!(confirmed.batch.status, "confirmed"); + assert_eq!( + account_balance(&pool, ctx.seeded.company_id, payable).await, + Decimal::ZERO + ); +} diff --git a/crates/kesh-db/tests/supplier_invoices_repository.rs b/crates/kesh-db/tests/supplier_invoices_repository.rs index cb235d83..ba8dcdd8 100644 --- a/crates/kesh-db/tests/supplier_invoices_repository.rs +++ b/crates/kesh-db/tests/supplier_invoices_repository.rs @@ -2361,3 +2361,140 @@ mod garde_usage_comptes_reglage { .expect("la saisie réussit une fois l'exercice rendu"); } } + +// --------------------------------------------------------------------------- +// Story 15-6b (#474, AC4) — un règlement ne vise pas le compte qu'il solde +// --------------------------------------------------------------------------- + +/// Le règlement fournisseur refusé ne laisse rien : facture `open`, version +/// inchangée, aucune écriture neuve. +async fn assert_supplier_nothing_written( + pool: &MySqlPool, + invoice_id: i64, + (entries_before, version_before): (i64, i32), +) { + let (status, version, settlement): (String, i32, Option) = sqlx::query_as( + "SELECT status, version, settlement_journal_entry_id FROM supplier_invoices WHERE id = ?", + ) + .bind(invoice_id) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(status, "open"); + assert_eq!(version, version_before); + assert!(settlement.is_none()); + let entries: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(entries, entries_before, "aucune écriture neuve"); +} + +async fn supplier_before(pool: &MySqlPool, invoice_id: i64) -> (i64, i32) { + let entries: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") + .fetch_one(pool) + .await + .unwrap(); + let version: i32 = sqlx::query_scalar("SELECT version FROM supplier_invoices WHERE id = ?") + .bind(invoice_id) + .fetch_one(pool) + .await + .unwrap(); + (entries, version) +} + +fn assert_payable_refusal(err: &DbError, payable: i64) { + match err { + DbError::SettlementCounterpartyIsClaimAccount { + account_id, + account_number, + claim: kesh_db::errors::ClaimSide::Payable, + role: kesh_db::errors::SettlementAccountRole::Counterparty, + batch: None, + } => { + assert_eq!(*account_id, payable); + assert_eq!(account_number.as_deref(), Some("2000")); + } + other => panic!("refus de contrepartie attendu, obtenu {other:?}"), + } +} + +/// Story 15-6b, test 5 — **compte interne = 2000** : `D 2000 / C 2000` refusé. +#[sqlx::test(migrations = "./test-schema")] +async fn pay_with_the_payable_account_as_internal_account_is_refused(pool: MySqlPool) { + let ctx = setup(&pool).await; + let payable = ctx.seeded.accounts["2000"]; + let created = supplier_invoices::create( + &pool, + one_line(&ctx, dec!(500.00), dec!(0)), + ctx.seeded.admin_user_id, + ) + .await + .unwrap(); + let before = supplier_before(&pool, created.invoice.id).await; + + let err = supplier_invoices::pay( + &pool, + ctx.seeded.company_id, + created.invoice.id, + SettlementChoice::InternalAccount { + account_id: payable, + }, + d(2026, 6, 20), + ctx.seeded.admin_user_id, + ) + .await + .expect_err("la dette comme contrepartie doit être refusée"); + assert_payable_refusal(&err, payable); + assert_supplier_nothing_written(&pool, created.invoice.id, before).await; +} + +/// Story 15-6b, test 6 — **virement sur un compte bancaire lié au 2000** : même +/// refus. (La fixture `pay_bank_transfer_uses_journal_account` lie au 1100 : +/// elle reste valide, le compte soldé est 2000.) +#[sqlx::test(migrations = "./test-schema")] +async fn pay_by_a_bank_account_linked_to_the_payable_account_is_refused(pool: MySqlPool) { + let ctx = setup(&pool).await; + let payable = ctx.seeded.accounts["2000"]; + let bank = bank_accounts::create( + &pool, + NewBankAccount { + company_id: ctx.seeded.company_id, + bank_name: "Banque Test".into(), + iban: "CH9300762011623852957".into(), + qr_iban: None, + is_primary: true, + }, + ) + .await + .unwrap(); + sqlx::query("UPDATE bank_accounts SET journal_account_id = ? WHERE id = ?") + .bind(payable) + .bind(bank.id) + .execute(&pool) + .await + .unwrap(); + let created = supplier_invoices::create( + &pool, + one_line(&ctx, dec!(500.00), dec!(0)), + ctx.seeded.admin_user_id, + ) + .await + .unwrap(); + let before = supplier_before(&pool, created.invoice.id).await; + + let err = supplier_invoices::pay( + &pool, + ctx.seeded.company_id, + created.invoice.id, + SettlementChoice::BankTransfer { + bank_account_id: bank.id, + }, + d(2026, 6, 20), + ctx.seeded.admin_user_id, + ) + .await + .expect_err("un compte bancaire lié à la dette doit être refusé"); + assert_payable_refusal(&err, payable); + assert_supplier_nothing_written(&pool, created.invoice.id, before).await; +} diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 0081e646..6d4e14da 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -25,6 +25,12 @@ error-check-constraint = Ungültiger Wert error-illegal-state = Unzulässiger Statusübergang error-invoice-unvalidate-blocked-settled = Diese Rechnung weist eine — auch nur teilweise — Zahlung auf: stornieren Sie diese zuerst. error-credit-note-blocked-settled = Diese Rechnung weist eine — auch nur teilweise — Zahlung auf: Stornieren Sie diese zuerst, um eine Gutschrift ausstellen zu können. +error-settlement-counterparty-is-receivable = Das Konto { $account } ist das Debitorenkonto dieser Rechnung: Eine Zahlung muss die Forderung auf ein anderes Konto ausbuchen (Bank, Kasse, Verrechnung…). +error-settlement-counterparty-is-payable = Das Konto { $account } ist das Kreditorenkonto dieser Rechnung: Eine Zahlung muss die Schuld über ein anderes Konto tilgen (Bank, Kasse, Verrechnung…). +error-settlement-counterparty-is-payable-in-batch = Das Bankkonto dieses Stapels ist mit dem Konto { $account } verknüpft, dem Kreditorenkonto der Rechnung { $invoice }: Der Stapel kann nicht bestätigt werden. Verknüpfen Sie das Bankkonto mit seinem eigenen Bankkonto in der Buchhaltung und bestätigen Sie erneut, oder stornieren Sie den Stapel und begleichen Sie die Rechnung über ihre Detailansicht. +error-settlement-rounding-account-is-receivable = Das Konto { $account }, in den Einstellungen als Konto für Rundungsdifferenzen festgelegt, ist das Debitorenkonto dieser Rechnung: Eine Administratorin oder ein Administrator muss unter Einstellungen → Fakturierung ein anderes Konto festlegen. +error-settlement-write-off-account-is-receivable = Das Konto { $account }, in den Einstellungen als Konto dieser Ausbuchungsart festgelegt, ist das Debitorenkonto dieser Rechnung: Eine Administratorin oder ein Administrator muss unter Einstellungen → Fakturierung ein anderes Konto festlegen. +error-settlement-vat-payable-account-is-receivable = Das Konto { $account }, in den Einstellungen als Konto für geschuldete MWST festgelegt, ist das Debitorenkonto dieser Rechnung: Eine Administratorin oder ein Administrator muss unter Einstellungen → Fakturierung ein anderes Konto festlegen. error-invoice-unvalidate-blocked-credited = Diese Rechnung wurde durch eine Gutschrift korrigiert, die bereits die Korrektur darstellt. error-invoice-unvalidate-blocked-reminders = Diese Rechnung hat eine Mahnhistorie: deren Löschung würde den Nachweis des Inkassos vernichten. error-invoice-unvalidate-blocked-emailed = Diese Rechnung wurde dem Kunden zugestellt: korrigieren Sie sie mit einer Gutschrift. diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 2a1ab19b..7e3ee359 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -25,6 +25,12 @@ error-check-constraint = Invalid value error-illegal-state = Illegal state transition error-invoice-unvalidate-blocked-settled = This invoice carries a payment, even a partial one: cancel it first. error-credit-note-blocked-settled = This invoice carries a payment, even a partial one: cancel it first to be able to issue a credit note. +error-settlement-counterparty-is-receivable = Account { $account } is this invoice's receivables account: a payment must move the receivable to another account (bank, cash, offset…). +error-settlement-counterparty-is-payable = Account { $account } is this invoice's payables account: a payment must settle the debt through another account (bank, cash, offset…). +error-settlement-counterparty-is-payable-in-batch = This batch's bank account is linked to account { $account }, the payables account of invoice { $invoice }: the batch cannot be confirmed. Link the bank account to its own bank ledger account and confirm again, or cancel the batch and pay the invoice from its page. +error-settlement-rounding-account-is-receivable = Account { $account }, designated in the settings as the rounding differences account, is this invoice's receivables account: an administrator must designate another account in Settings → Invoicing. +error-settlement-write-off-account-is-receivable = Account { $account }, designated in the settings as the account for this write-off nature, is this invoice's receivables account: an administrator must designate another account in Settings → Invoicing. +error-settlement-vat-payable-account-is-receivable = Account { $account }, designated in the settings as the VAT payable account, is this invoice's receivables account: an administrator must designate another account in Settings → Invoicing. error-invoice-unvalidate-blocked-credited = This invoice is credited by a credit note, which is already the correction. error-invoice-unvalidate-blocked-reminders = This invoice has a reminder history: removing it would erase the proof of collection. error-invoice-unvalidate-blocked-emailed = This invoice was sent to the client: correct it with a credit note. diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 39b2d23e..7e5ebb5f 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -25,6 +25,12 @@ error-check-constraint = Valeur invalide error-illegal-state = Transition d'état interdite error-invoice-unvalidate-blocked-settled = Cette facture porte un règlement, même partiel : annulez-le d'abord. error-credit-note-blocked-settled = Cette facture porte un règlement, même partiel : annulez-le d'abord pour pouvoir émettre un avoir. +error-settlement-counterparty-is-receivable = Le compte { $account } est le compte débiteurs de cette facture : un règlement doit faire sortir la créance vers un autre compte (banque, caisse, compensation…). +error-settlement-counterparty-is-payable = Le compte { $account } est le compte créanciers de cette facture : un règlement doit éteindre la dette par un autre compte (banque, caisse, compensation…). +error-settlement-counterparty-is-payable-in-batch = Le compte bancaire de ce lot est lié au compte { $account }, le compte créanciers de la facture { $invoice } : le lot ne peut pas être confirmé. Reliez le compte bancaire à son propre compte de banque puis confirmez de nouveau, ou annulez le lot et réglez la facture depuis sa fiche. +error-settlement-rounding-account-is-receivable = Le compte { $account }, désigné dans les réglages comme compte de différences d'arrondi, est le compte débiteurs de cette facture : un administrateur doit désigner un autre compte dans Paramètres → Facturation. +error-settlement-write-off-account-is-receivable = Le compte { $account }, désigné dans les réglages comme compte de cette nature de solde, est le compte débiteurs de cette facture : un administrateur doit désigner un autre compte dans Paramètres → Facturation. +error-settlement-vat-payable-account-is-receivable = Le compte { $account }, désigné dans les réglages comme compte de TVA due, est le compte débiteurs de cette facture : un administrateur doit désigner un autre compte dans Paramètres → Facturation. error-invoice-unvalidate-blocked-credited = Cette facture est créditée par un avoir, qui en est déjà la correction. error-invoice-unvalidate-blocked-reminders = Cette facture a un historique de rappels : le dévalider effacerait la preuve du recouvrement. error-invoice-unvalidate-blocked-emailed = Cette facture a été envoyée au client : corrigez-la par un avoir. diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 4c1e400b..bdb26ffa 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -25,6 +25,12 @@ error-check-constraint = Valore non valido error-illegal-state = Transizione di stato non consentita error-invoice-unvalidate-blocked-settled = Questa fattura presenta un pagamento, anche parziale: annullatelo prima. error-credit-note-blocked-settled = Questa fattura presenta un pagamento, anche parziale: annullalo prima per poter emettere una nota di credito. +error-settlement-counterparty-is-receivable = Il conto { $account } è il conto debitori di questa fattura: un pagamento deve trasferire il credito su un altro conto (banca, cassa, compensazione…). +error-settlement-counterparty-is-payable = Il conto { $account } è il conto creditori di questa fattura: un pagamento deve estinguere il debito tramite un altro conto (banca, cassa, compensazione…). +error-settlement-counterparty-is-payable-in-batch = Il conto bancario di questo lotto è collegato al conto { $account }, il conto creditori della fattura { $invoice }: il lotto non può essere confermato. Collega il conto bancario al proprio conto di banca e conferma di nuovo, oppure annulla il lotto e paga la fattura dalla sua scheda. +error-settlement-rounding-account-is-receivable = Il conto { $account }, designato nelle impostazioni come conto delle differenze di arrotondamento, è il conto debitori di questa fattura: un amministratore deve designare un altro conto in Impostazioni → Fatturazione. +error-settlement-write-off-account-is-receivable = Il conto { $account }, designato nelle impostazioni come conto di questa natura di saldo, è il conto debitori di questa fattura: un amministratore deve designare un altro conto in Impostazioni → Fatturazione. +error-settlement-vat-payable-account-is-receivable = Il conto { $account }, designato nelle impostazioni come conto dell'IVA dovuta, è il conto debitori di questa fattura: un amministratore deve designare un altro conto in Impostazioni → Fatturazione. error-invoice-unvalidate-blocked-credited = Questa fattura è stornata da una nota di credito, che ne è già la correzione. error-invoice-unvalidate-blocked-reminders = Questa fattura ha uno storico di solleciti: eliminarlo cancellerebbe la prova del recupero. error-invoice-unvalidate-blocked-emailed = Questa fattura è stata inviata al cliente: correggetela con una nota di credito. From 153429af86b87cb5cc87c797fb9a602d7851a26c Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Fri, 9 Oct 2026 05:25:33 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(15-6b):=20les=20=C3=A9crans=20ne=20pro?= =?UTF-8?q?posent=20plus=20le=20compte=20sold=C3=A9=20;=20libell=C3=A9s=20?= =?UTF-8?q?des=20refus=20(refs=20#474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - account-options : accountIdsWithRole, withoutAccountIds, bankAccountsNotLinkedTo (ids sur la liste reçue, avant active && postable). - SettleInvoiceDialog : 1100 et comptes bancaires liés au 1100 écartés ; présélection dans un effet distinct (la saisie survit à l'arrivée de accounts) ; message de liste vide si le filtre a écarté un compte. - Fiche fournisseur : idem pour le 2000. - failedItemLabel des lots : deux codes neufs (six → huit) ; libellé failed[] du rapprochement à deux cas selon details.role (26 → 27 codes). - Six clés d'écran, quatre locales ; sitesTotal 1915 → 1921 (+6) ; CLES_RELEVEES 210 → 213 (+3 nommées). - Tests 17-20 et AC7 ; hôte SettleInvoiceDialogHost.test.svelte (rerender change toutes les props) ; deux mutations jouées et constatées rouges. Co-Authored-By: Claude Opus 5.5 --- crates/kesh-i18n/locales/de-CH/messages.ftl | 6 + crates/kesh-i18n/locales/en-CH/messages.ftl | 6 + crates/kesh-i18n/locales/fr-CH/messages.ftl | 6 + crates/kesh-i18n/locales/it-CH/messages.ftl | 6 + .../features/accounts/account-options.test.ts | 56 +++++++++- .../lib/features/accounts/account-options.ts | 48 +++++++- .../invoices/SettleInvoiceDialog.svelte | 55 +++++++++- .../invoices/SettleInvoiceDialog.test.ts | 103 ++++++++++++++++++ .../SettleInvoiceDialogHost.test.svelte | 34 ++++++ .../payment-batch-helpers.test.ts | 13 ++- .../payment-batches/payment-batch-helpers.ts | 22 +++- .../failed-proposal-label.test.ts | 40 ++++++- .../reconciliation/failed-proposal-label.ts | 41 +++++-- frontend/src/lib/shared/i18n-keys.test.ts | 10 +- .../lib/shared/i18n-un-repli-par-cle.test.ts | 8 +- .../(app)/supplier-invoices/[id]/+page.svelte | 32 +++++- .../[id]/supplier-settlement-page.test.ts | 91 ++++++++++++++++ 17 files changed, 553 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/features/invoices/SettleInvoiceDialogHost.test.svelte diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 6d4e14da..d5a5016a 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -721,6 +721,7 @@ invoice-settle-type-label = Zahlungsart invoice-settle-type-bank = Banküberweisung invoice-settle-type-internal = Bar oder anderes Konto invoice-settle-bank-label = Bankkonto +invoices-settle-no-eligible-bank-account = Kein verwendbares Bankkonto: Das einzige verknüpfte Konto ist das Debitorenkonto dieser Rechnung. Verknüpfen Sie ein Bankkonto mit seinem eigenen Bankkonto in der Buchhaltung, oder begleichen Sie über ein internes Konto. invoice-settle-account-label = Konto invoice-settle-account-placeholder = — Konto wählen invoice-settle-amount-label = Betrag @@ -1087,6 +1088,8 @@ reconciliation-failed-project-archived = Das Analyseprojekt ist archiviert. reconciliation-failed-project-not-found = Analyseprojekt nicht gefunden. reconciliation-failed-currency-mismatch = Nur Transaktionen in CHF können mit einer Rechnung oder einer Regel abgeglichen werden. reconciliation-failed-overpayment = Der Betrag der Transaktion übersteigt den offenen Betrag der Rechnung. +reconciliation-failed-counterparty-is-claim-account = Das Bankkonto ist mit dem Debitorenkonto der Rechnung verknüpft: Verknüpfen Sie es mit seinem eigenen Bankkonto in der Buchhaltung. +reconciliation-failed-rounding-account-is-claim-account = Das festgelegte Konto für Rundungsdifferenzen ist das Debitorenkonto der Rechnung: Eine Administratorin oder ein Administrator muss unter Einstellungen → Fakturierung ein anderes festlegen. reconciliation-failed-payment-before-invoice = Die Zahlung liegt mehr als einen Tag vor dem Rechnungsdatum: Sie kann die Rechnung nicht begleichen, auch wenn diese vorgeschlagen wurde. reconciliation-failed-rule-mismatch = Das Gegenkonto der Regel hat sich geändert: Laden Sie die Seite neu. reconciliation-failed-rule-no-longer-matches = Die Regel passt nicht mehr auf diese Transaktion: Sie wurde inzwischen geändert. @@ -1976,6 +1979,7 @@ supplier-invoices-new = Rechnung erfassen supplier-invoices-paid-info = Rechnung bezahlt. supplier-invoices-pay-account-ph = Gegenkonto supplier-invoices-pay-bank-ph = Belastungskonto +supplier-invoices-pay-no-eligible-bank-account = Kein verwendbares Bankkonto: Das einzige verknüpfte Konto ist das Kreditorenkonto dieser Rechnung. Verknüpfen Sie ein Bankkonto mit seinem eigenen Bankkonto in der Buchhaltung, oder begleichen Sie über ein internes Konto. supplier-invoices-pay-date = Zahlungsdatum supplier-invoices-pay-err-account = Wählen Sie ein Konto. supplier-invoices-pay-err-bank = Wählen Sie ein Bankkonto. @@ -2030,6 +2034,8 @@ nav-users = Benutzer payment-batches-failed-already-in-generated-batch = Bereits in einem erstellten Stapel payment-batches-failed-invalid-iban = IBAN ungültig payment-batches-failed-invalid-qr-iban = QR-IBAN ungültig +payment-batches-failed-counterparty-is-claim-account = Das Bankkonto des Stapels ist mit dem Kreditorenkonto dieser Rechnung verknüpft +payment-batches-failed-purchase-entry-malformed = Einkaufsbuchung ohne Habenzeile: Rechnung prüfen payment-batches-failed-no-payment-coordinates = Keine Zahlungsverbindung (IBAN/QR-IBAN) payment-batches-failed-supplier-invoice-not-found = Rechnung nicht gefunden payment-batches-failed-supplier-invoice-not-open = Rechnung nicht offen diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 7e3ee359..9143479f 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -721,6 +721,7 @@ invoice-settle-type-label = Settlement method invoice-settle-type-bank = Bank transfer invoice-settle-type-internal = Cash or other account invoice-settle-bank-label = Bank account +invoices-settle-no-eligible-bank-account = No usable bank account: the only linked account is this invoice's receivables account. Link a bank account to its own bank ledger account, or settle through an internal account. invoice-settle-account-label = Account invoice-settle-account-placeholder = — Choose an account invoice-settle-amount-label = Amount @@ -1087,6 +1088,8 @@ reconciliation-failed-project-archived = The analytical project is archived. reconciliation-failed-project-not-found = Analytical project not found. reconciliation-failed-currency-mismatch = Only CHF transactions can be reconciled with an invoice or a rule. reconciliation-failed-overpayment = The transaction amount exceeds the amount due on the invoice. +reconciliation-failed-counterparty-is-claim-account = The bank account is linked to the invoice's receivables account: link it to its own bank ledger account. +reconciliation-failed-rounding-account-is-claim-account = The designated rounding account is the invoice's receivables account: an administrator must designate another one in Settings → Invoicing. reconciliation-failed-payment-before-invoice = The payment is dated more than one day before the invoice: it cannot settle it, even though the invoice was proposed. reconciliation-failed-rule-mismatch = The rule's counterpart account has changed: reload the page. reconciliation-failed-rule-no-longer-matches = The rule no longer matches this transaction: it has been changed in the meantime. @@ -1982,6 +1985,7 @@ supplier-invoices-new = Record an invoice supplier-invoices-paid-info = Invoice paid. supplier-invoices-pay-account-ph = Counterparty account supplier-invoices-pay-bank-ph = Source bank account +supplier-invoices-pay-no-eligible-bank-account = No usable bank account: the only linked account is this invoice's payables account. Link a bank account to its own bank ledger account, or pay through an internal account. supplier-invoices-pay-date = Payment date supplier-invoices-pay-err-account = Select an account. supplier-invoices-pay-err-bank = Select a bank account. @@ -2036,6 +2040,8 @@ nav-users = Users payment-batches-failed-already-in-generated-batch = Already in a created batch payment-batches-failed-invalid-iban = IBAN invalid payment-batches-failed-invalid-qr-iban = QR-IBAN invalid +payment-batches-failed-counterparty-is-claim-account = The batch's bank account is linked to this invoice's payables account +payment-batches-failed-purchase-entry-malformed = Purchase entry without a credit line: check the invoice payment-batches-failed-no-payment-coordinates = No payment details (IBAN/QR-IBAN) payment-batches-failed-supplier-invoice-not-found = Invoice not found payment-batches-failed-supplier-invoice-not-open = Invoice not open diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 7e5ebb5f..53f47250 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -769,6 +769,7 @@ invoice-settle-type-label = Mode de règlement invoice-settle-type-bank = Virement bancaire invoice-settle-type-internal = Espèces ou autre compte invoice-settle-bank-label = Compte bancaire +invoices-settle-no-eligible-bank-account = Aucun compte bancaire utilisable : le seul compte lié est le compte débiteurs de cette facture. Reliez un compte bancaire à son propre compte de banque, ou réglez par un compte interne. invoice-settle-account-label = Compte invoice-settle-account-placeholder = — Choisir un compte invoice-settle-amount-label = Montant @@ -1150,6 +1151,8 @@ reconciliation-failed-project-archived = Le projet analytique est archivé. reconciliation-failed-project-not-found = Projet analytique introuvable. reconciliation-failed-currency-mismatch = Seules les transactions en CHF peuvent être rapprochées d’une facture ou d’une règle. reconciliation-failed-overpayment = Le montant de la transaction dépasse le reste dû de la facture. +reconciliation-failed-counterparty-is-claim-account = Le compte bancaire est lié au compte débiteurs de la facture : reliez-le à son propre compte de banque. +reconciliation-failed-rounding-account-is-claim-account = Le compte d’arrondi désigné est le compte débiteurs de la facture : un administrateur doit en désigner un autre dans Paramètres → Facturation. reconciliation-failed-payment-before-invoice = Le paiement est daté de plus d’un jour avant la facture : il ne peut pas la régler, même si elle a été proposée. reconciliation-failed-rule-mismatch = Le compte de contrepartie de la règle a changé : rechargez la page. reconciliation-failed-rule-no-longer-matches = La règle ne correspond plus à cette transaction : elle a été modifiée entre-temps. @@ -2003,6 +2006,7 @@ supplier-invoices-new = Enregistrer une facture supplier-invoices-paid-info = Facture réglée. supplier-invoices-pay-account-ph = Compte de contrepartie supplier-invoices-pay-bank-ph = Compte bancaire source +supplier-invoices-pay-no-eligible-bank-account = Aucun compte bancaire utilisable : le seul compte lié est le compte créanciers de cette facture. Reliez un compte bancaire à son propre compte de banque, ou réglez par un compte interne. supplier-invoices-pay-date = Date de règlement supplier-invoices-pay-err-account = Sélectionnez un compte. supplier-invoices-pay-err-bank = Sélectionnez un compte bancaire. @@ -2071,6 +2075,8 @@ nav-users = Utilisateurs payment-batches-failed-already-in-generated-batch = Déjà dans un lot créé payment-batches-failed-invalid-iban = IBAN invalide payment-batches-failed-invalid-qr-iban = QR-IBAN invalide +payment-batches-failed-counterparty-is-claim-account = Le compte bancaire du lot est lié au compte créanciers de cette facture +payment-batches-failed-purchase-entry-malformed = Écriture d'achat sans ligne de crédit : facture à vérifier payment-batches-failed-no-payment-coordinates = Pas de coordonnées de paiement (IBAN/QR-IBAN) payment-batches-failed-supplier-invoice-not-found = Facture introuvable payment-batches-failed-supplier-invoice-not-open = Facture non ouverte diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index bdb26ffa..d2973828 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -721,6 +721,7 @@ invoice-settle-type-label = Modalità di pagamento invoice-settle-type-bank = Bonifico bancario invoice-settle-type-internal = Contanti o altro conto invoice-settle-bank-label = Conto bancario +invoices-settle-no-eligible-bank-account = Nessun conto bancario utilizzabile: l'unico conto collegato è il conto debitori di questa fattura. Collegare un conto bancario al proprio conto di banca, oppure pagare tramite un conto interno. invoice-settle-account-label = Conto invoice-settle-account-placeholder = — Scegliere un conto invoice-settle-amount-label = Importo @@ -1087,6 +1088,8 @@ reconciliation-failed-project-archived = Il progetto analitico è archiviato. reconciliation-failed-project-not-found = Progetto analitico introvabile. reconciliation-failed-currency-mismatch = Solo le transazioni in CHF possono essere riconciliate con una fattura o una regola. reconciliation-failed-overpayment = L'importo della transazione supera il saldo dovuto della fattura. +reconciliation-failed-counterparty-is-claim-account = Il conto bancario è collegato al conto debitori della fattura: collegarlo al proprio conto di banca. +reconciliation-failed-rounding-account-is-claim-account = Il conto delle differenze di arrotondamento designato è il conto debitori della fattura: un amministratore deve designarne un altro in Impostazioni → Fatturazione. reconciliation-failed-payment-before-invoice = Il pagamento è anteriore di più di un giorno alla data della fattura: non può saldarla, anche se è stata proposta. reconciliation-failed-rule-mismatch = Il conto di contropartita della regola è cambiato: ricaricare la pagina. reconciliation-failed-rule-no-longer-matches = La regola non corrisponde più a questa transazione: è stata modificata nel frattempo. @@ -1977,6 +1980,7 @@ supplier-invoices-new = Registra una fattura supplier-invoices-paid-info = Fattura pagata. supplier-invoices-pay-account-ph = Conto di contropartita supplier-invoices-pay-bank-ph = Conto bancario di origine +supplier-invoices-pay-no-eligible-bank-account = Nessun conto bancario utilizzabile: l'unico conto collegato è il conto creditori di questa fattura. Collegare un conto bancario al proprio conto di banca, oppure pagare tramite un conto interno. supplier-invoices-pay-date = Data di pagamento supplier-invoices-pay-err-account = Seleziona un conto. supplier-invoices-pay-err-bank = Seleziona un conto bancario. @@ -2031,6 +2035,8 @@ nav-users = Utenti payment-batches-failed-already-in-generated-batch = Già in un lotto creato payment-batches-failed-invalid-iban = IBAN non valido payment-batches-failed-invalid-qr-iban = QR-IBAN non valido +payment-batches-failed-counterparty-is-claim-account = Il conto bancario del lotto è collegato al conto creditori di questa fattura +payment-batches-failed-purchase-entry-malformed = Registrazione d'acquisto senza riga in avere: fattura da verificare payment-batches-failed-no-payment-coordinates = Nessuna coordinata di pagamento (IBAN/QR-IBAN) payment-batches-failed-supplier-invoice-not-found = Fattura non trovata payment-batches-failed-supplier-invoice-not-open = Fattura non aperta diff --git a/frontend/src/lib/features/accounts/account-options.test.ts b/frontend/src/lib/features/accounts/account-options.test.ts index 674d4344..393dbcac 100644 --- a/frontend/src/lib/features/accounts/account-options.test.ts +++ b/frontend/src/lib/features/accounts/account-options.test.ts @@ -6,7 +6,12 @@ import { describe, it, expect } from 'vitest'; import type { AccountResponse } from './accounts.types'; -import { withCurrentAccount } from './account-options'; +import { + accountIdsWithRole, + bankAccountsNotLinkedTo, + withCurrentAccount, + withoutAccountIds, +} from './account-options'; function acc( partial: Partial & { id: number; number: string; name: string }, @@ -97,3 +102,52 @@ describe('withCurrentAccount — un compte ARCHIVÉ configuré avant son archiva expect(options[0].id).toBe(ARCHIVE.id); }); }); + +// Story 15-6b (#474, AC8, test 17) — les écrans de règlement ne proposent plus le compte soldé. +describe('fonctions d’écran de la Story 15-6b', () => { + const DEBITEURS = acc({ id: 1100, number: '1100', name: 'Débiteurs', role: 'Receivable' }); + const CREANCIERS = acc({ + id: 2000, + number: '2000', + name: 'Créanciers', + accountType: 'Liability', + role: 'Payable', + }); + const SANS_ROLE = acc({ id: 1000, number: '1000', name: 'Caisse' }); + const PLAN = [SANS_ROLE, DEBITEURS, CREANCIERS]; + + function bank(id: number, journalAccountId: number | null) { + return { id, journalAccountId }; + } + + it('accountIdsWithRole rend les seuls comptes du rôle donné', () => { + expect([...accountIdsWithRole(PLAN, 'Receivable')]).toEqual([1100]); + expect([...accountIdsWithRole(PLAN, 'Payable')]).toEqual([2000]); + expect(accountIdsWithRole(PLAN, 'VatPayable').size).toBe(0); + }); + + it('withoutAccountIds écarte le rôle donné et garde les autres, sans rôle compris', () => { + const kept = withoutAccountIds(PLAN, accountIdsWithRole(PLAN, 'Receivable')); + expect(kept.map((a) => a.id)).toEqual([1000, 2000]); + }); + + it('bankAccountsNotLinkedTo écarte les comptes bancaires liés, garde les autres et les non liés', () => { + const ids = accountIdsWithRole(PLAN, 'Receivable'); + const kept = bankAccountsNotLinkedTo([bank(1, 1100), bank(2, 1020), bank(3, null)], ids); + expect(kept.map((b) => b.id)).toEqual([2, 3]); + }); + + it('un compte bancaire lié à un débiteurs NON IMPUTABLE est écarté (ids calculés avant le filtre)', () => { + const nonImputable = acc({ ...DEBITEURS, postable: false }); + const plan = [SANS_ROLE, nonImputable]; + // Le geste correct : les ids sur la liste reçue, puis le filtre `active && postable`. + const ids = accountIdsWithRole(plan, 'Receivable'); + expect(bankAccountsNotLinkedTo([bank(1, 1100)], ids)).toEqual([]); + // La mutation à éviter : calculer les ids APRÈS le filtre laisse passer le compte bancaire. + const idsApresFiltre = accountIdsWithRole( + plan.filter((a) => a.active && a.postable), + 'Receivable', + ); + expect(bankAccountsNotLinkedTo([bank(1, 1100)], idsApresFiltre)).toHaveLength(1); + }); +}); diff --git a/frontend/src/lib/features/accounts/account-options.ts b/frontend/src/lib/features/accounts/account-options.ts index bd68a6c5..a0f1a508 100644 --- a/frontend/src/lib/features/accounts/account-options.ts +++ b/frontend/src/lib/features/accounts/account-options.ts @@ -41,7 +41,7 @@ * dont les options sont l'unique source de vérité de ce qui est affichable. */ -import type { AccountResponse } from './accounts.types'; +import type { AccountResponse, AccountRole } from './accounts.types'; /** * Rend `filtered`, en y ajoutant le compte d'identifiant `currentId` s'il @@ -72,3 +72,49 @@ export function withCurrentAccount( return [current, ...filtered]; } + +// --------------------------------------------------------------------------- +// Story 15-6b (#474) — les écrans de règlement ne proposent plus le compte soldé +// --------------------------------------------------------------------------- + +/** + * Identifiants des comptes portant le rôle `role` (Story 15-6b, AC8 ; signature + * partagée avec la 15-6c, choix C-15-6-14). + * + * **Pourquoi le rôle** : un écran de règlement n'a pas l'écriture de vente sous + * la main ; le rôle `Receivable` / `Payable` est un singleton par société, dont + * les réglages de facturation sont dérivés. ⚠️ **La garde serveur, exacte, reste + * l'autorité** : elle compare la contrepartie au compte que porte l'écriture de + * vente ou d'achat. Si le réglage a été déplacé hors du compte de rôle, l'écran + * peut proposer un compte que le serveur refusera, avec un message clair — + * jamais l'inverse. + * + * ⛔ **Calculer les ids sur la liste que l'écran REÇOIT, avant le filtre + * `active && postable`** : sans quoi un compte bancaire lié à un compte + * débiteurs devenu **non imputable** ne serait pas écarté. + * + * ⚠️ **Les comptes archivés n'y sont pas, et c'est voulu** (choix C-15-6-17) : + * les écrans chargent le plan sans eux. Un compte bancaire lié à un compte + * archivé reste donc proposé — mais aucune écriture ne peut viser un compte + * archivé (garde `active` du serveur) : le serveur suffit. + */ +export function accountIdsWithRole(accounts: AccountResponse[], role: AccountRole): Set { + return new Set(accounts.filter((a) => a.role === role).map((a) => a.id)); +} + +/** Les comptes dont l'identifiant n'est pas dans `ids` (Story 15-6b, AC8). */ +export function withoutAccountIds(accounts: AccountResponse[], ids: Set): AccountResponse[] { + return accounts.filter((a) => !ids.has(a.id)); +} + +/** + * Les comptes bancaires dont le compte du grand livre (`journalAccountId`) + * n'est pas dans `ids` (Story 15-6b, AC8). Un compte bancaire sans compte lié + * est gardé : ce filtre ne juge que le lien au compte soldé. + */ +export function bankAccountsNotLinkedTo( + bankAccounts: T[], + ids: Set, +): T[] { + return bankAccounts.filter((b) => b.journalAccountId === null || !ids.has(b.journalAccountId)); +} diff --git a/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte b/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte index 5831f966..bdb8d614 100644 --- a/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte +++ b/frontend/src/lib/features/invoices/SettleInvoiceDialog.svelte @@ -21,6 +21,11 @@ import type { AccountResponse } from '$lib/features/accounts/accounts.types'; import type { BankAccountSummary } from '$lib/features/bank-accounts/bank-accounts.api'; import { dueToCentime } from './invoice-helpers'; + import { + accountIdsWithRole, + bankAccountsNotLinkedTo, + withoutAccountIds, + } from '$lib/features/accounts/account-options'; export type SettlementPayload = { settlementType: 'bank_transfer' | 'internal_account'; @@ -86,11 +91,40 @@ // (« 68.1000 ») ; le champ montre des centimes. amount = amountDue !== null ? dueToCentime(amountDue).toFixed(2) : ''; settlementType = 'bank_transfer'; - bankAccountId = bankAccounts.find((b) => b.isPrimary)?.id ?? bankAccounts[0]?.id ?? null; + // La présélection du compte bancaire vit dans l'effet suivant (Story + // 15-6b) : elle dépend de `accounts`, que cet effet ne doit pas lire — + // une liste arrivée après l'ouverture effacerait la saisie en cours. + bankAccountId = null; accountId = null; } }); + // Story 15-6b (#474, AC8) — le compte débiteurs n'est jamais une contrepartie : + // `D 1100 / C 1100` s'équilibre, le reste dû baisserait sans que rien ne bouge + // au grand livre. Les ids se calculent sur la liste REÇUE, avant le filtre + // `active && postable` (un compte bancaire lié à un 1100 devenu non imputable + // doit être écarté aussi). La garde serveur reste l'autorité. + let receivableIds = $derived(accountIdsWithRole(accounts, 'Receivable')); + let eligibleBankAccounts = $derived(bankAccountsNotLinkedTo(bankAccounts, receivableIds)); + // Liste vide PARCE QUE le filtre a écarté un compte : le dire. Une société + // sans aucun compte bancaire n'a rien d'écarté, et le message, qui accuse le + // compte débiteurs, y serait faux. + let noEligibleBankAccount = $derived( + eligibleBankAccounts.length === 0 && bankAccounts.length > eligibleBankAccounts.length, + ); + + // Présélection du compte bancaire, DANS la liste filtrée : le primaire, sinon + // le premier. Elle ne remplace la valeur courante que si celle-ci est absente + // de la liste (`null` compris) — un choix de l'utilisateur survit à l'arrivée + // tardive de `accounts`, et ni la date, ni le montant, ni le mode ne bougent. + $effect(() => { + if (!open) return; + const current = bankAccountId; + if (current !== null && eligibleBankAccounts.some((b) => b.id === current)) return; + bankAccountId = + eligibleBankAccounts.find((b) => b.isPrimary)?.id ?? eligibleBankAccounts[0]?.id ?? null; + }); + // ⚠️ Seuls les comptes ACTIFS **et IMPUTABLES** : le backend refuse l'un et // l'autre, et proposer ce qu'il refusera est une erreur qu'on fait commettre. // @@ -102,7 +136,14 @@ // défaut même que la story ferme. Le patron est celui de l'écran de règlement // fournisseur (`supplier-invoices/[id]/+page.svelte:66`), qui filtrait déjà // les deux. - let selectableAccounts = $derived(accounts.filter((a) => a.active && a.postable)); + // + // Story 15-6b : ni le compte débiteurs (rôle `Receivable`) — cf. plus haut. + let selectableAccounts = $derived( + withoutAccountIds( + accounts.filter((a) => a.active && a.postable), + receivableIds, + ), + ); let clientError = $derived.by(() => { if (!settledOn) { @@ -205,10 +246,18 @@ bind:value={bankAccountId} data-testid="settle-bank" > - {#each bankAccounts as b (b.id)} + {#each eligibleBankAccounts as b (b.id)} {/each} + {#if noEligibleBankAccount} +

+ {i18nMsg( + 'invoices-settle-no-eligible-bank-account', + 'Aucun compte bancaire utilisable : le seul compte lié est le compte débiteurs de cette facture. Reliez un compte bancaire à son propre compte de banque, ou réglez par un compte interne.', + )} +

+ {/if} {:else}
diff --git a/frontend/src/lib/features/invoices/SettleInvoiceDialog.test.ts b/frontend/src/lib/features/invoices/SettleInvoiceDialog.test.ts index 45aeb609..88350203 100644 --- a/frontend/src/lib/features/invoices/SettleInvoiceDialog.test.ts +++ b/frontend/src/lib/features/invoices/SettleInvoiceDialog.test.ts @@ -11,7 +11,9 @@ vi.mock('$lib/shared/utils/i18n.svelte', () => ({ })); import SettleInvoiceDialog from './SettleInvoiceDialog.svelte'; +import SettleInvoiceDialogHost from './SettleInvoiceDialogHost.test.svelte'; import type { BankAccountSummary } from '$lib/features/bank-accounts/bank-accounts.api'; +import type { AccountResponse } from '$lib/features/accounts/accounts.types'; const banque = { id: 1, @@ -91,3 +93,104 @@ describe('SettleInvoiceDialog — le reste dû au centime', () => { expect(queryByText(SCALE)).toBeNull(); }); }); + +// Story 15-6b (#474, AC8, AC9 ; test 18) — le compte débiteurs n'est pas une contrepartie. +describe('SettleInvoiceDialog — la contrepartie n’est pas le compte débiteurs', () => { + const DEBITEURS = { + id: 1100, + number: '1100', + name: 'Débiteurs', + accountType: 'Asset', + active: true, + postable: true, + role: 'Receivable', + } as unknown as AccountResponse; + const CAISSE = { + id: 1000, + number: '1000', + name: 'Caisse', + accountType: 'Asset', + active: true, + postable: true, + role: null, + } as unknown as AccountResponse; + function compte(id: number, journalAccountId: number | null, isPrimary = false) { + return { + ...banque, + id, + bankName: `Banque ${id}`, + isPrimary, + journalAccountId, + } as BankAccountSummary; + } + const EMPTY = + 'Aucun compte bancaire utilisable : le seul compte lié est le compte débiteurs de cette facture. Reliez un compte bancaire à son propre compte de banque, ou réglez par un compte interne.'; + + function monter(accounts: AccountResponse[], bankAccounts: BankAccountSummary[], errorMsg = '') { + return render(SettleInvoiceDialog, { + open: true, + onOpenChange: vi.fn(), + invoiceDate: '2026-01-01', + amountDue: '100.00', + accounts, + bankAccounts, + errorMsg, + onConfirm: vi.fn(), + }); + } + const options = (testId: string) => + Array.from( + (document.querySelector(`[data-testid="${testId}"]`) as HTMLSelectElement).options, + ).map((o) => o.value); + + it('1100 (rôle Receivable) est absent du menu « Compte interne » (mutation : filtre retiré)', async () => { + const { getByTestId } = monter([CAISSE, DEBITEURS], []); + await fireEvent.change(getByTestId('settle-type'), { target: { value: 'internal_account' } }); + await waitFor(() => expect(options('settle-account')).toContain('1000')); + expect(options('settle-account')).not.toContain('1100'); + }); + + it('un compte bancaire lié au 1100 est absent, et la présélection tombe sur un autre', async () => { + monter([CAISSE, DEBITEURS], [compte(1, 1100, true), compte(2, 1020)]); + await waitFor(() => expect(options('settle-bank')).toEqual(['2'])); + expect((document.getElementById('settle-bank') as HTMLSelectElement).value).toBe('2'); + }); + + it('`accounts` arrivé APRÈS l’ouverture ne réinitialise pas la saisie (mutation : présélection dans l’effet de réinitialisation)', async () => { + // Hôte qui passe les props une à une (cf. son en-tête : `rerender` les changerait toutes). + const { getByTestId } = render(SettleInvoiceDialogHost, { + lateAccounts: [CAISSE, DEBITEURS], + bankAccounts: [compte(1, 1100, true), compte(2, 1020)], + }); + const input = document.getElementById('settle-amount') as HTMLInputElement; + await waitFor(() => expect(input.value).toBe('100.00')); + await waitFor(() => + expect((document.getElementById('settle-bank') as HTMLSelectElement).value).toBe('1'), + ); + await fireEvent.input(input, { target: { value: '40.00' } }); + await fireEvent.click(getByTestId('host-load-accounts')); + await waitFor(() => expect(options('settle-bank')).toEqual(['2'])); + expect((document.getElementById('settle-bank') as HTMLSelectElement).value).toBe('2'); + expect((document.getElementById('settle-amount') as HTMLInputElement).value).toBe('40.00'); + }); + + it('le seul compte bancaire lié au 1100 → le message de liste vide', async () => { + const { findByText } = monter([CAISSE, DEBITEURS], [compte(1, 1100, true)]); + expect(await findByText(EMPTY)).toBeTruthy(); + }); + + it('aucun compte bancaire → PAS de message de liste vide (rien n’a été écarté)', async () => { + const { queryByTestId } = monter([CAISSE, DEBITEURS], []); + await waitFor(() => expect(document.getElementById('settle-bank')).not.toBeNull()); + expect(queryByTestId('settle-no-eligible-bank')).toBeNull(); + }); + + it('la prop `errorMsg` s’affiche (AC9 — rendu seulement)', async () => { + const refus = 'Le compte 1100 est le compte débiteurs de cette facture'; + const { findByText, getByTestId } = monter([CAISSE], [compte(2, 1020, true)], refus); + await waitFor(() => + expect((getByTestId('settle-bank') as HTMLSelectElement).value).toBe('2'), + ); + expect(await findByText(refus)).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/features/invoices/SettleInvoiceDialogHost.test.svelte b/frontend/src/lib/features/invoices/SettleInvoiceDialogHost.test.svelte new file mode 100644 index 00000000..11b2d2f1 --- /dev/null +++ b/frontend/src/lib/features/invoices/SettleInvoiceDialogHost.test.svelte @@ -0,0 +1,34 @@ + + + + + {}} + invoiceDate="2026-01-01" + amountDue="100.00" + {accounts} + {bankAccounts} + onConfirm={() => {}} +/> diff --git a/frontend/src/lib/features/payment-batches/payment-batch-helpers.test.ts b/frontend/src/lib/features/payment-batches/payment-batch-helpers.test.ts index d986af7f..e12c9558 100644 --- a/frontend/src/lib/features/payment-batches/payment-batch-helpers.test.ts +++ b/frontend/src/lib/features/payment-batches/payment-batch-helpers.test.ts @@ -56,9 +56,10 @@ describe('paymentBatchStatusLabel', () => { describe('failedItemLabel', () => { // ⚠️ L'ancienne rédaction assertait `toContain('coordonnées')` et `toContain('lot')` — plus // insidieux qu'un `toBe` : un `toContain` survit à une traduction PARTIELLE, donc il serait - // resté vert sur un correctif à moitié fait. Les six codes sont désormais assertés en - // entier, et le chemin réel est éprouvé par le cas suivant. - it('mappe les six codes sur leur repli français', () => { + // resté vert sur un correctif à moitié fait. Les huit codes sont désormais assertés en + // entier (les deux derniers par la Story 15-6b), et le chemin réel est éprouvé par le cas + // suivant. + it('mappe les huit codes sur leur repli français', () => { expect(failedItemLabel('SUPPLIER_INVOICE_NOT_FOUND')).toBe('Facture introuvable'); expect(failedItemLabel('SUPPLIER_INVOICE_NOT_OPEN')).toBe('Facture non ouverte'); expect(failedItemLabel('NO_PAYMENT_COORDINATES')).toBe( @@ -67,6 +68,12 @@ describe('failedItemLabel', () => { expect(failedItemLabel('ALREADY_IN_GENERATED_BATCH')).toBe('Déjà dans un lot créé'); expect(failedItemLabel('INVALID_IBAN')).toBe('IBAN invalide'); expect(failedItemLabel('INVALID_QR_IBAN')).toBe('QR-IBAN invalide'); + expect(failedItemLabel('SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT')).toBe( + 'Le compte bancaire du lot est lié au compte créanciers de cette facture' + ); + expect(failedItemLabel('SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED')).toBe( + "Écriture d'achat sans ligne de crédit : facture à vérifier" + ); }); it('un code inconnu retombe sur sa valeur brute', () => { diff --git a/frontend/src/lib/features/payment-batches/payment-batch-helpers.ts b/frontend/src/lib/features/payment-batches/payment-batch-helpers.ts index d13e27c5..55d98c9b 100644 --- a/frontend/src/lib/features/payment-batches/payment-batch-helpers.ts +++ b/frontend/src/lib/features/payment-batches/payment-batch-helpers.ts @@ -56,7 +56,7 @@ export function paymentBatchStatusLabel(status: PaymentBatchStatus): string { * § *Pattern batch — FailedProposal per-proposal* de `CLAUDE.md`. Seul leur affichage est * traduit ; un code inconnu retombe sur sa valeur brute, ce qui vaut mieux qu'une case vide. * - * ⚠️ **Les six libellés sont RELEVÉS, pas inventés** : `SUPPLIER_INVOICE_NOT_FOUND` sur + * ⚠️ **Huit codes. Les six premiers libellés sont RELEVÉS, pas inventés** : `SUPPLIER_INVOICE_NOT_FOUND` sur * `reminders-error-invoice-not-found`, les deux IBAN sur * `imported-supplier-invoices-error-invalid-iban` — **sans** « du créancier », qui ne * s'applique pas ici —, et *ouverte* est en partie A du glossaire. `NO_PAYMENT_COORDINATES` @@ -70,7 +70,15 @@ export function paymentBatchStatusLabel(status: PaymentBatchStatus): string { * désormais le mot exact de la colonne Statut. Laisser cette ligne de côté aurait rendu le * correctif incohérent avec lui-même. * - * ⚠️ **Les six clés sont écrites en toutes lettres, jamais construites par gabarit.** Une + * ⚠️ **Les deux derniers libellés sont ÉCRITS par la Story 15-6b (#474), non relevés du + * glossaire** : `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT` — le compte bancaire du lot est lié au + * compte créanciers de la facture, dont le règlement écrirait `D 2000 / C 2000` (la facture n'entre + * pas dans le lot, aucun fichier pain.001 ne la porte) — et `SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED` + * — l'écriture d'achat n'a pas de ligne de crédit, la facture est à vérifier. Le libellé ne nomme + * pas le compte : le `failed[]` du lot n'affiche que `#id — libellé`, et le compte en cause est + * celui du compte bancaire choisi à l'écran. + * + * ⚠️ **Les huit clés sont écrites en toutes lettres, jamais construites par gabarit.** Une * clé statique est vue par `i18n-keys.test.ts` dès qu'elle manque d'un catalogue ; une clé * dynamique demande un motif déclaré, et une carte peut grandir sans qu'aucune garde ne * rougisse — c'est exactement ce qui a été mesuré en passe 4 de la story 23-3. @@ -92,6 +100,16 @@ export function failedItemLabel(code: string): string { return i18nMsg('payment-batches-failed-invalid-iban', 'IBAN invalide'); case 'INVALID_QR_IBAN': return i18nMsg('payment-batches-failed-invalid-qr-iban', 'QR-IBAN invalide'); + case 'SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT': + return i18nMsg( + 'payment-batches-failed-counterparty-is-claim-account', + 'Le compte bancaire du lot est lié au compte créanciers de cette facture' + ); + case 'SUPPLIER_INVOICE_PURCHASE_ENTRY_MALFORMED': + return i18nMsg( + 'payment-batches-failed-purchase-entry-malformed', + "Écriture d'achat sans ligne de crédit : facture à vérifier" + ); default: return code; } diff --git a/frontend/src/lib/features/reconciliation/failed-proposal-label.test.ts b/frontend/src/lib/features/reconciliation/failed-proposal-label.test.ts index 6dfce32b..e3913bed 100644 --- a/frontend/src/lib/features/reconciliation/failed-proposal-label.test.ts +++ b/frontend/src/lib/features/reconciliation/failed-proposal-label.test.ts @@ -3,7 +3,8 @@ // ⚠️ La liste des codes est ÉCRITE EN DUR, et non lue depuis le module : un test qui // itérerait sur la table de production serait vert par construction (fiche, § « Tests — ce qui // rendrait un test vert sans rien prouver »). Relevé : les 25 littéraux de -// `crates/kesh-api/src/routes/reconciliation.rs` + `ACCOUNT_NOT_POSTABLE` (DbError::error_code). +// `crates/kesh-api/src/routes/reconciliation.rs` + les deux codes de `DbError::error_code()` : +// `ACCOUNT_NOT_POSTABLE` et `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT` (Story 15-6b). import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -50,6 +51,7 @@ const EXPECTED_KEYS: Record = { RECONCILIATION_SPLIT_IMBALANCE: 'reconciliation-split-error-imbalance', RECONCILIATION_TRANSACTION_NOT_PENDING: 'reconciliation-failed-transaction-not-pending', ROUNDING_ACCOUNT_NOT_CONFIGURED: 'error-rounding-account-not-configured', + SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT: 'reconciliation-failed-counterparty-is-claim-account', VALIDATION_ERROR: 'error-validation', }; @@ -58,8 +60,8 @@ describe('failedProposalLabel', () => { calls.length = 0; }); - it('couvre les 26 codes relevés', () => { - expect(Object.keys(EXPECTED_KEYS)).toHaveLength(26); + it('couvre les 27 codes relevés', () => { + expect(Object.keys(EXPECTED_KEYS)).toHaveLength(27); }); for (const [code, key] of Object.entries(EXPECTED_KEYS)) { @@ -124,6 +126,38 @@ describe('failedProposalLabel', () => { expect(label).toContain('plus d’un jour avant la facture'); }); + // Story 15-6b (#474, AC7) — deux remèdes selon `details.role`. + it('SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT, role counterparty : relier le compte bancaire', () => { + const label = failedProposalLabel('SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT', { + bankAccountId: 3, + accountId: 7, + accountNumber: '1100', + claim: 'receivable', + role: 'counterparty', + }); + expect(calls).toEqual(['reconciliation-failed-counterparty-is-claim-account']); + expect(label).toContain('propre compte de banque'); + }); + + it('SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT, role rounding : renvoi aux réglages (mutation : rôle ignoré)', () => { + const label = failedProposalLabel('SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT', { + accountId: 7, + accountNumber: '1100', + claim: 'receivable', + role: 'rounding', + }); + expect(calls).toEqual(['reconciliation-failed-rounding-account-is-claim-account']); + expect(label).toContain('Paramètres → Facturation'); + }); + + it.each([ + ['role non chaîne', { role: 1 }], + ['details absent', null], + ])('SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT (%s) → libellé du compte bancaire', (_cas, details) => { + failedProposalLabel('SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT', details); + expect(calls).toEqual(['reconciliation-failed-counterparty-is-claim-account']); + }); + it.each([ ['invoice_already_paid', { reason: 'invoice_already_paid' }], ['payment_date_outside_window', { reason: 'payment_date_outside_window', window_days: 30 }], diff --git a/frontend/src/lib/features/reconciliation/failed-proposal-label.ts b/frontend/src/lib/features/reconciliation/failed-proposal-label.ts index 4c4ccfb0..478a1abc 100644 --- a/frontend/src/lib/features/reconciliation/failed-proposal-label.ts +++ b/frontend/src/lib/features/reconciliation/failed-proposal-label.ts @@ -6,11 +6,12 @@ * FailedProposal per-proposal* du `CLAUDE.md` ; seul leur affichage est traduit. Patron : * `failedItemLabel` (`payment-batches/payment-batch-helpers.ts`). * - * **Les 26 codes** sont ceux que `crates/kesh-api/src/routes/reconciliation.rs` peut poser dans + * **Les 27 codes** sont ceux que `crates/kesh-api/src/routes/reconciliation.rs` peut poser dans * `failed[]` : les 25 littéraux relevés par * `grep -ohE 'error_code: "[A-Z_]+"' crates/kesh-api/src/routes/reconciliation.rs | sort -u`, - * plus `ACCOUNT_NOT_POSTABLE`, posé par `DbError::error_code()` (seule forme non littérale du - * fichier). Un code apparu depuis retombe sur le repli, qui **montre le code brut** : mieux vaut + * plus les deux codes posés par `DbError::error_code()` (seules formes non littérales du + * fichier) : `ACCOUNT_NOT_POSTABLE` et, depuis la Story 15-6b (#474), + * `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`. Un code apparu depuis retombe sur le repli, qui **montre le code brut** : mieux vaut * un code qu'une case vide. * * ⚠️ **Clés réutilisées** (choix C37, étendu par C-15-5c-1) : quand un message existant convient @@ -21,8 +22,10 @@ * ⚠️ **Les clés sont écrites en toutes lettres, jamais construites par gabarit** : une clé * statique est vue par `i18n-keys.test.ts` dès qu'elle manque d'un catalogue. * - * ⚠️ **Deux codes seulement lisent leur `details`** : `ACCOUNT_NOT_POSTABLE` (AC2), pour - * nommer les comptes, et `RECONCILIATION_INVOICE_NOT_ELIGIBLE`, pour la seule raison + * ⚠️ **Trois codes seulement lisent leur `details`** : `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT` + * (Story 15-6b), pour son `role` — le compte de banque lié au compte débiteurs et le compte + * d'arrondi désigné qui l'est n'appellent pas le même remède ; `ACCOUNT_NOT_POSTABLE` (AC2), pour + * nommer les comptes ; et `RECONCILIATION_INVOICE_NOT_ELIGIBLE`, pour la seule raison * `payment_date_before_invoice_date` (revue de code P1, E1 — choix C-15-5c-3). Cette raison * est la seule qu'un utilisateur rencontre sans avoir rien fait de travers : la proposition * retient les factures datées de 30 jours avant à 30 jours après la transaction, alors que @@ -74,10 +77,20 @@ export function failureReason(details: unknown): string | null { return typeof reason === 'string' ? reason : null; } +/** + * Rôle d'un `details` de la forme `{ role: "" }` (Story 15-6b), ou `null` pour toute autre + * forme. Même prudence que `failureReason`. + */ +export function failureRole(details: unknown): string | null { + if (typeof details !== 'object' || details === null) return null; + const role = (details as { role?: unknown }).role; + return typeof role === 'string' ? role : null; +} + /** * Libellé traduit d'un `errorCode` de `failed[]`. `details` n'est lu que pour - * `ACCOUNT_NOT_POSTABLE` et `RECONCILIATION_INVOICE_NOT_ELIGIBLE` ; un code inconnu rend - * un repli qui cite le code. + * `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, `ACCOUNT_NOT_POSTABLE` et + * `RECONCILIATION_INVOICE_NOT_ELIGIBLE` ; un code inconnu rend un repli qui cite le code. */ export function failedProposalLabel(code: string, details?: unknown): string { switch (code) { @@ -198,6 +211,20 @@ export function failedProposalLabel(code: string, details?: unknown): string { 'error-rounding-account-not-configured', "Ce paiement solde la facture au centime, mais aucun compte de différences d'arrondi utilisable n'est désigné : choisissez-en un dans Paramètres → Facturation." ); + case 'SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT': + // Story 15-6b (#474) : l'écriture serait `D 1100 / C 1100`. Deux remèdes selon le + // compte en cause — le seul `rounding` renvoie aux réglages ; tout autre rôle est le + // compte de banque (`counterparty`), seul autre rôle que pose l'acceptation. + if (failureRole(details) === 'rounding') { + return i18nMsg( + 'reconciliation-failed-rounding-account-is-claim-account', + 'Le compte d’arrondi désigné est le compte débiteurs de la facture : un administrateur doit en désigner un autre dans Paramètres → Facturation.' + ); + } + return i18nMsg( + 'reconciliation-failed-counterparty-is-claim-account', + 'Le compte bancaire est lié au compte débiteurs de la facture : reliez-le à son propre compte de banque.' + ); case 'VALIDATION_ERROR': return i18nMsg('error-validation', 'Erreur de validation'); default: diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index 2460c0f8..7f60c763 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -498,7 +498,15 @@ const ATTENDU = { // dans `routes/(app)/settings/fiscal-years/+page.svelte` (56 → 57, recompté par // `grep -oE "\b(msg|i18nMsg)\("` aux deux bornes, `5e4bec50` et la branche). // `sitesNonResolus`, `relais`, `sitesGabarit` inchangés (un littéral). - sitesTotal: 1916, + // Story 15-6b (#474) : **1916 → 1922** (+6 ; 1915 → 1921 avant le rebase sur la 15-12a), recompté par `grep -o "i18nMsg("` aux deux + // bornes, fichier par fichier : `payment-batches/payment-batch-helpers.ts` (9 → 11 : les deux + // codes neufs de `failedItemLabel`) ; `reconciliation/failed-proposal-label.ts` (28 → 30 : + // `SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`, deux cas selon `details.role`) ; + // `invoices/SettleInvoiceDialog.svelte` (19 → 20) et + // `routes/(app)/supplier-invoices/[id]/+page.svelte` (39 → 40) : le message de liste vide + // des comptes bancaires. `sitesNonResolus`, `relais`, `sitesGabarit` inchangés (aucun site + // dynamique ajouté) ; la doublure `SettleInvoiceDialogHost.test.svelte` est hors collecte. + sitesTotal: 1922, sitesNonResolus: 31, relais: 6, sitesGabarit: 10, diff --git a/frontend/src/lib/shared/i18n-un-repli-par-cle.test.ts b/frontend/src/lib/shared/i18n-un-repli-par-cle.test.ts index f483d0ad..305b0d09 100644 --- a/frontend/src/lib/shared/i18n-un-repli-par-cle.test.ts +++ b/frontend/src/lib/shared/i18n-un-repli-par-cle.test.ts @@ -115,8 +115,14 @@ const PREFIXES = [ * `in-payment-batch`) — recompté : `git diff main` du FTL fr-CH, onze lignes dont * deux clés existantes réécrites (`-cancel-confirm`, * `journal-entries-reverse-blocked-supplier-invoice`). + * + * ⚠️ **210 → 213, +3 nommées** (Story 15-6b, #474) — la contrepartie qui serait le compte + * soldé : `supplier-invoices-pay-no-eligible-bank-account` (liste des comptes bancaires vidée par + * le filtre du compte créanciers), `payment-batches-failed-counterparty-is-claim-account` et + * `payment-batches-failed-purchase-entry-malformed` (refus par facture à la création d'un lot) — + * recompté : `git diff` du FTL fr-CH, préfixes du domaine. */ -const CLES_RELEVEES = 210; +const CLES_RELEVEES = 213; /** Relève, pour chaque clé du domaine, l'ensemble de ses replis littéraux distincts. */ diff --git a/frontend/src/routes/(app)/supplier-invoices/[id]/+page.svelte b/frontend/src/routes/(app)/supplier-invoices/[id]/+page.svelte index ea063423..3761e688 100644 --- a/frontend/src/routes/(app)/supplier-invoices/[id]/+page.svelte +++ b/frontend/src/routes/(app)/supplier-invoices/[id]/+page.svelte @@ -20,6 +20,11 @@ import type { BankAccountSummary } from '$lib/features/bank-accounts/bank-accounts.api'; import { fetchAccounts } from '$lib/features/accounts/accounts.api'; import type { AccountResponse } from '$lib/features/accounts/accounts.types'; + import { + accountIdsWithRole, + bankAccountsNotLinkedTo, + withoutAccountIds, + } from '$lib/features/accounts/account-options'; import { listProjects } from '$lib/features/projects/projects.api'; import type { ProjectResponse } from '$lib/features/projects/projects.types'; import { isApiError } from '$lib/shared/utils/api-client'; @@ -35,6 +40,8 @@ let bankAccounts = $state([]); let accounts = $state([]); + /** Story 15-6b : tous les comptes bancaires liés ont été écartés (liés au compte créanciers). */ + let noEligibleBankAccount = $state(false); let projects = $state([]); /** Libellé du projet analytique affecté (Story 19-3), ou `null`. */ @@ -80,8 +87,21 @@ fetchAccounts(), listProjects(true), // inclut les archivés pour résoudre le nom d'un projet tagué puis archivé ]); - bankAccounts = banks.filter((b) => b.journalAccountId !== null); - accounts = accts.filter((a) => a.active && a.postable); // 14-3b : compte posté + // Story 15-6b (#474, AC8) — le compte créanciers n'est jamais une + // contrepartie : `D 2000 / C 2000` s'équilibre et la facture passerait + // « payée » sans paiement. Les ids se calculent sur la liste REÇUE, + // avant le filtre `active && postable` ; la garde serveur reste + // l'autorité. + const payableIds = accountIdsWithRole(accts, 'Payable'); + const linked = banks.filter((b) => b.journalAccountId !== null); + bankAccounts = bankAccountsNotLinkedTo(linked, payableIds); + // Le message ne vaut que si le filtre a ÉCARTÉ un compte : une société + // sans compte bancaire lié garde son comportement. + noEligibleBankAccount = bankAccounts.length === 0 && linked.length > bankAccounts.length; + accounts = withoutAccountIds( + accts.filter((a) => a.active && a.postable), // 14-3b : compte posté + payableIds, + ); projects = projs; } catch (err) { if (isApiError(err)) errorMsg = err.message; @@ -324,6 +344,14 @@ {/each} + {#if noEligibleBankAccount} +

+ {i18nMsg( + 'supplier-invoices-pay-no-eligible-bank-account', + 'Aucun compte bancaire utilisable : le seul compte lié est le compte créanciers de cette facture. Reliez un compte bancaire à son propre compte de banque, ou réglez par un compte interne.', + )} +

+ {/if} {:else}