diff --git a/CHANGELOG.md b/CHANGELOG.md index 404b5b49c..4504a6229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **La configuration d'une installation de production s'inscrit au journal d'audit ([#434](https://github.com/guycorbaz/kesh/issues/434)).** Chaque étape de l'assistant — langue, mode d'utilisation, passage en production, type d'organisation, langue comptable, coordonnées, compte bancaire (ou son omission) et finalisation — laisse une entrée *Étape d'installation franchie*, précédée de ce qu'elle a réellement changé : la société créée ou modifiée, le compte bancaire (sans jamais l'IBAN en clair), les réglages de facturation et les taux de TVA effectivement insérés. Le plan comptable livré s'inscrit en **une seule** entrée, *Plan comptable chargé*, qui liste les comptes créés. Quatre actions nouvelles sont libellées dans les quatre langues. Une étape ne laisse plus la société modifiée à moitié : la modification, la progression de l'étape et leur trace sont désormais **une seule transaction**, et l'étape est revérifiée sous verrou — deux demandes simultanées ne chargent plus le plan comptable deux fois. *Le perdant d'une telle course reçoit désormais « étape déjà franchie » au lieu d'un conflit de version.* *La version de la société (`version`, lue par le verrou optimiste de `PUT /company`) n'avance plus lorsqu'une étape ne change rien ; sur une société provisoire dont les coordonnées changent, elle avance désormais de deux.* Le peuplement de démonstration et la remise à zéro restent à tracer (même issue). +- **Un règlement ne peut plus viser le compte qu'il solde ([#474](https://github.com/guycorbaz/kesh/issues/474)).** Régler une facture client par le compte débiteurs lui-même — en compte interne, ou par un compte bancaire lié à ce compte — écrivait `D 1100 / C 1100` : l'écriture s'équilibrait, le reste dû baissait, la facture pouvait passer « payée », et le grand livre ne bougeait pas. Il en allait de même d'une facture fournisseur réglée par le compte créanciers (`D 2000 / C 2000`), y compris par un lot de paiement, et d'un encaissement par rapprochement quand le compte bancaire était lié au compte débiteurs. Ces règlements sont désormais **refusés sans rien écrire** (`SETTLEMENT_COUNTERPARTY_IS_CLAIM_ACCOUNT`), avec un message qui dit le remède. Les écrans de règlement ne proposent plus ces comptes, ni les comptes bancaires qui y sont liés. Une facture dont le compte créanciers est le compte de banque du lot n'entre plus dans un lot de paiement (elle apparaît parmi les refus, et le fichier pain.001 ne la porte pas) ; si le compte bancaire a été relié depuis la création du lot, la confirmation est refusée et le message dit les deux issues — relier de nouveau le compte bancaire puis confirmer, ou annuler le lot. Les comptes d'écart du même geste sont gardés de même : le compte de différences d'arrondi d'un règlement ou d'un rapprochement, et les comptes de nature, de reste d'arrondi et de TVA due d'un solde du reste ne peuvent pas être le compte débiteurs de la facture (cas d'un compte débiteurs changé de type, puis désigné dans les réglages) — un administrateur doit alors en désigner un autre dans *Paramètres → Facturation*. Les règlements de cette forme déjà enregistrés restent tels quels. + - **Un compte non imputable est refusé partout où un client le désigne pour recevoir des écritures ([#427](https://github.com/guycorbaz/kesh/issues/427), [#429](https://github.com/guycorbaz/kesh/issues/429)).** Les écrans filtraient ces comptes, mais le serveur ne les contrôlait pas : un appel direct — par clé d'API ou par une intégration — pouvait rapprocher une transaction bancaire sur **9000 Bilan d'ouverture**, et fausser le résultat de l'exercice et le report à nouveau. Sont désormais refusés en `400 ACCOUNT_NOT_POSTABLE`, qui nomme le compte : le **rapprochement** manuel et ventilé, l'acceptation d'une proposition ventilée ou par règle (dans `failed[]`, sans bloquer le reste du lot) ; les **règles d'affectation** — création, changement de compte, et **réactivation** d'une règle dont le compte n'est plus imputable ; le **compte comptable d'un compte bancaire**, à la création, ou quand on le change. Une règle dont le compte est devenu non imputable — par exemple scindé en sous-comptes — **n'est plus proposée** ; la règle suivante qui correspond l'est à sa place. Les **réglages de facturation** refusent un compte non imputable **au moment où on le désigne** (créance, produit, TVA due, TVA récupérable, décompte TVA, créanciers), avec un message qui nomme le champ. ⚠️ **Limites** : un compte déjà désigné ou déjà lié, devenu non imputable après coup, reste accepté — on peut enregistrer les réglages, modifier le compte bancaire ou renommer la règle sans le changer. La validation d'une facture et la saisie (ou la complétion) d'une facture fournisseur refusent désormais un compte de réglage devenu non imputable (entrée ci-dessous) ; **restent** hors de cette garde l'avoir, le compte de produit par défaut et le compte comptable d'un compte bancaire déjà lié. L'écran des règles affichait « [object Object] » au lieu du message d'un refus : il affiche le message. Sur l'écran des comptes bancaires, un compte lié devenu non imputable s'affichait vide dans les formulaires de modification et de lien : il reste affiché. - **Un compte de réglage devenu non imputable n'est plus utilisé par les écritures automatiques ([#429](https://github.com/guycorbaz/kesh/issues/429)).** La créance et la TVA due, désignées dans *Paramètres → Facturation*, recevaient l'écriture de chaque facture validée même devenues non imputables — après la création de sous-comptes, la case *imputable* décochée ou le rôle « résultat de l'exercice » attribué —, comme les créanciers et la TVA récupérable pour chaque facture fournisseur. La **validation d'une facture** et la **saisie (ou la complétion) d'une facture fournisseur** les contrôlent désormais au moment d'écrire, et refusent avec un message qui nomme le compte, renvoie à *Paramètres → Facturation* et dit qu'un **administrateur** doit y désigner un compte imputable — la page des réglages lui est réservée. La TVA due (ou récupérable) n'est contrôlée que si la pièce en porte. L'**avoir** n'est pas soumis à ce contrôle, délibérément — une facture émise reste annulable : il crédite la créance que la facture a débitée (entrée ci-dessous, [#473](https://github.com/guycorbaz/kesh/issues/473)), et relit encore la TVA due dans les réglages du moment, ce que corrigera [#525](https://github.com/guycorbaz/kesh/issues/525). ⚠️ **Après la mise à jour, vérifiez dans *Paramètres → Facturation* que les comptes désignés sont imputables** : un compte devenu non imputable (sous-comptes créés, case *imputable* décochée…) doit y être remplacé, faute de quoi la validation des factures (ou la saisie des factures fournisseurs) est refusée. *(API : `400 ACCOUNT_NOT_POSTABLE`, `details.rejected[{accountId, accountNumber}]`, sur `POST /invoices/{id}/validate`, `POST /supplier-invoices` et `POST /imported-supplier-invoices/{id}/complete` — après tous les autres refus, exercice compris.)* diff --git a/README.md b/README.md index 9115702a4..23aa3e683 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ - **Coordonnées de l'émetteur sur la facture** — téléphone, e-mail et site web figurent sous votre adresse sur le PDF, saisis une fois dans les réglages ; chaque champ est facultatif et n'occupe aucune place s'il est vide ✓ - **Carnet de contacts structuré** — contacts **Personne** (prénom + nom) ou **Entreprise** (raison sociale), **adresse structurée** (QR-facture), et **personnes de contact** rattachées à une entreprise (interlocuteurs, à titre informatif) ✓ - **Prévention des doublons à la saisie** — pendant que vous tapez le nom d'un nouveau contact, Kesh signale ceux qui lui ressemblent déjà (casse, accents, traits d'union et espaces indifférents), et avertit si l'**IDE** saisi appartient à une fiche existante — y compris **archivée**, cas où l'enregistrement serait refusé sans que la fiche soit visible dans le carnet. Kesh **signale, il ne bloque jamais** : deux clients peuvent légitimement porter des noms très proches ✓ -- **Règlements clients** — enregistrement d'un règlement depuis la fiche (virement, espèces ou tout autre compte), partiel ou total, avec son écriture ; liste des règlements de la facture et **annulation par contre-passation** ✓ +- **Règlements clients** — enregistrement d'un règlement depuis la fiche (virement, espèces ou un autre compte de contrepartie — jamais le compte débiteurs de la facture), partiel ou total, avec son écriture ; liste des règlements de la facture et **annulation par contre-passation** ✓ - **Avoirs (notes de crédit)** — annulation d'une facture validée par création d'un avoir lié (séquence séparée `AV-…`), contre-passation comptable automatique (TVA comprise), PDF « Avoir » ✓ - **Envoi de factures par e-mail** — bouton sur la facture validée : QR-facture PDF jointe, objet/message pré-remplis dans la **langue du contact** avec civilité personnalisée (modèles multilingues FR/DE/IT/EN configurables par l'admin), destinataire verrouillé sur la fiche contact, Reply-To = e-mail de la société, traçage « Envoyée le … à … » et renvoi possible ✓ - **Import bancaire CAMT.053 + CSV multi-encodage** — parser + persistance + UI ✓, profils banque réutilisables ✓, réconciliation automatique avec score ✓, réconciliation manuelle ✓, éclatement de transaction agrégée ✓, règles d'affectation automatique ✓ et **annulation d'un rapprochement** par contre-passation ✓ 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 1c08e661d..a6c4d7307 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 @@ -2,7 +2,7 @@ ## Status -ready-for-dev +done + + + + + {}} + 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 d986af7fa..e12c95585 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 d13e27c55..55d98c9b9 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 6dfce32be..e3913bedb 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 4c4ccfb08..478a1abcf 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 2460c0f89..7f60c763a 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 f483d0ada..305b0d09b 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 ea0634237..3761e6882 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}