diff --git a/config/version.yaml b/config/version.yaml index bebcb41..cedaeb5 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.99' + app.version: '0.1.100' diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 1352a3e..33bfa13 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -956,35 +956,21 @@ function askRemoveRib(index: number): void { } /** - * Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting, - * exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la - * sous-ressource. Aucun champ main/information dans le payload (mode strict - * RG-1.28 : sinon 403 sur tout le payload). + * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS + * PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote + * back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13 + * (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en + * dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte + * LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon + * 403 sur tout le payload). */ async function submitAccounting(): Promise { if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return tabSubmitting.value = true accountingErrors.clearErrors() - // Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut - // echouer et `return` avant submitRows (qui porte sinon le reset), laissant - // des erreurs de RIB obsoletes affichees sous les blocs. - ribErrors.value = [] try { - // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). - try { - await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false }) - } - catch (error) { - accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') }) - return - } - - for (const id of removedRibIds.value) { - await api.delete(`/client_ribs/${id}`, {}, { toast: false }) - } - removedRibIds.value = [] - - // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes). + // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs + // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. const ribHasError = await submitRows( @@ -1011,6 +997,23 @@ async function submitAccounting(): Promise { rib => rib.id === null && isRibBlank(rib), ) if (ribHasError) return + + // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). + try { + await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false }) + } + catch (error) { + accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') }) + return + } + + // 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le + // guard back n'autorise la suppression du dernier RIB qu'une fois le type change). + for (const id of removedRibIds.value) { + await api.delete(`/client_ribs/${id}`, {}, { toast: false }) + } + removedRibIds.value = [] + toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } catch (e) { diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index b2e9083..ba646e0 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -938,37 +938,20 @@ function askRemoveRib(index: number): void { } /** - * Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting) - * PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict - * RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back). + * Valide l'onglet Comptabilite : POST/PATCH des RIB sur /clients/{id}/ribs PUIS + * PATCH des scalaires (groupe client:write:accounting). Les RIB d'abord : le back + * valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB + * doivent donc exister en base AVANT (sinon 422 « Au moins un RIB est obligatoire + * pour le type de reglement LCR »). Deux appels distincts (mode strict RG-1.28 : + * il n'existe pas d'endpoint /accounting, cf. recon back). */ async function submitAccounting(): Promise { if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return tabSubmitting.value = true accountingErrors.clearErrors() - // Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut - // echouer et `return` avant submitRows (qui porte sinon le reset), laissant - // des erreurs de RIB obsoletes affichees sous les blocs. - ribErrors.value = [] try { - // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). - try { - await api.patch(`/clients/${clientId.value}`, { - siren: accounting.siren || null, - accountNumber: accounting.accountNumber || null, - tvaMode: accounting.tvaModeIri, - nTva: accounting.nTva || null, - paymentDelay: accounting.paymentDelayIri, - paymentType: accounting.paymentTypeIri, - bank: isBankRequired.value ? accounting.bankIri : null, - }, { toast: false }) - } - catch (error) { - accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') }) - return - } - - // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes). + // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs + // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. const ribHasError = await submitRows( @@ -996,6 +979,23 @@ async function submitAccounting(): Promise { ) if (ribHasError) return + // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). + try { + await api.patch(`/clients/${clientId.value}`, { + siren: accounting.siren || null, + accountNumber: accounting.accountNumber || null, + tvaMode: accounting.tvaModeIri, + nTva: accounting.nTva || null, + paymentDelay: accounting.paymentDelayIri, + paymentType: accounting.paymentTypeIri, + bank: isBankRequired.value ? accounting.bankIri : null, + }, { toast: false }) + } + catch (error) { + accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') }) + return + } + completeTab('accounting') toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } diff --git a/src/Module/Commercial/Domain/Entity/ClientRib.php b/src/Module/Commercial/Domain/Entity/ClientRib.php index c2fb408..faa6a0f 100644 --- a/src/Module/Commercial/Domain/Entity/ClientRib.php +++ b/src/Module/Commercial/Domain/Entity/ClientRib.php @@ -31,8 +31,8 @@ use Symfony\Component\Validator\Constraints as Assert; * comptable et la conformite, cf. spec § 2.5 / § 6.1). * * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1 - * (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable - * standard. + * (HP-M2-14 : pas de controle externe banque reelle), avec controle croise pays + * BIC/IBAN (ibanPropertyPath). Timestampable/Blamable standard. * * Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce : * - POST /api/clients/{clientId}/ribs : creation rattachee au client parent @@ -109,9 +109,15 @@ class ClientRib implements TimestampableInterface, BlamableInterface // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length // redondant calee sur la colonne (whitelist du garde-fou ERP-107). + // ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit + // correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`. #[ORM\Column(length: 20)] #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] - #[Assert\Bic(message: 'Le BIC n\'est pas valide.')] + #[Assert\Bic( + message: 'Le BIC n\'est pas valide.', + ibanPropertyPath: 'iban', + ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.', + )] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] private ?string $bic = null; diff --git a/src/Module/Commercial/Domain/Entity/SupplierRib.php b/src/Module/Commercial/Domain/Entity/SupplierRib.php index 7ef1a2f..bb4de1b 100644 --- a/src/Module/Commercial/Domain/Entity/SupplierRib.php +++ b/src/Module/Commercial/Domain/Entity/SupplierRib.php @@ -44,7 +44,8 @@ use Symfony\Component\Validator\Constraints as Assert; * Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE). * * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle - * banque reelle). Audite (#[Auditable]) + Timestampable / Blamable. + * banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite + * (#[Auditable]) + Timestampable / Blamable. */ #[ApiResource( operations: [ @@ -105,9 +106,15 @@ class SupplierRib implements TimestampableInterface, BlamableInterface // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length // redondant calee sur la colonne (auto-exempte du miroir ERP-107). + // ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit + // correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`. #[ORM\Column(length: 20)] #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] - #[Assert\Bic(message: 'Le BIC n\'est pas valide.')] + #[Assert\Bic( + message: 'Le BIC n\'est pas valide.', + ibanPropertyPath: 'iban', + ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.', + )] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?string $bic = null; diff --git a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php index c2c5f9e..4cad97a 100644 --- a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php @@ -51,6 +51,9 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase /** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */ protected const string VALID_IBAN = 'FR1420041010050500013M02606'; protected const string VALID_BIC = 'BNPAFRPPXXX'; + // BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle + // croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath). + protected const string FOREIGN_BIC = 'DEUTDEFFXXX'; protected function tearDown(): void { @@ -295,6 +298,26 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase return $this->referential(Bank::class, $code); } + /** + * Indexe les violations d'un corps de reponse 422 par propertyPath. Permet + * d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422 + * orthogonal) : un test qui se contente du code 422 passerait meme si la RG + * visee etait cassee pour une autre raison. + * + * @param array $body corps decode de la reponse (toArray(false)) + * + * @return array propertyPath => message + */ + protected function violationsByPath(array $body): array + { + $byPath = []; + foreach ($body['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + + return $byPath; + } + /** * Recupere un referentiel comptable seede (CommercialReferentialFixtures) par * code. Echoue explicitement si absent (fixtures non chargees). @@ -316,24 +339,4 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase return $entity; } - - /** - * Indexe les violations d'un corps de reponse 422 par propertyPath. Permet - * d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422 - * orthogonal) : un test qui se contente du code 422 passerait meme si la RG - * visee etait cassee pour une autre raison. - * - * @param array $body corps decode de la reponse (toArray(false)) - * - * @return array propertyPath => message - */ - protected function violationsByPath(array $body): array - { - $byPath = []; - foreach ($body['violations'] ?? [] as $v) { - $byPath[$v['propertyPath']] = $v['message']; - } - - return $byPath; - } } diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index 88f6921..5b4ecca 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -27,6 +27,9 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase private const string MERGE = 'application/merge-patch+json'; private const string VALID_IBAN = 'FR1420041010050500013M02606'; private const string VALID_BIC = 'BNPAFRPPXXX'; + // BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle + // croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath). + private const string FOREIGN_BIC = 'DEUTDEFFXXX'; // === Contacts === @@ -359,6 +362,35 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(422); } + /** + * Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et + * un IBAN (FR) valides isolement mais de pays differents -> 422. La violation + * porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front). + */ + public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib Pays Mismatch'); + + $response = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'label' => 'Compte incoherent', + 'bic' => self::FOREIGN_BIC, + 'iban' => self::VALID_IBAN, + ], + ]); + + self::assertResponseStatusCodeSame(422); + $byPath = []; + foreach ($response->toArray(false)['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + + self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).'); + self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']); + } + /** * Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit * pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin diff --git a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php index 77df0a9..3ded3be 100644 --- a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php @@ -294,6 +294,27 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase self::assertResponseStatusCodeSame(422); } + /** + * Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et + * un IBAN (FR) valides isolement mais de pays differents -> 422. La violation + * porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front). + */ + public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Rib Pays Mismatch'); + + $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN], + ]); + + self::assertResponseStatusCodeSame(422); + $byPath = $this->violationsByPath($response->toArray(false)); + self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).'); + self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']); + } + public function testDeleteRibNonLcrReturns204(): void { $client = $this->createAdminClient();