fix(commercial) : validation onglet compta LCR + controle croise BIC/IBAN (ERP-118) #78

Merged
tristan merged 2 commits from feature/ERP-118-fix-validation-onglet-compta-lcr into develop 2026-06-09 08:44:12 +00:00
5 changed files with 94 additions and 25 deletions
Showing only changes of commit 36b5e84053 - Show all commits
@@ -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;
@@ -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;
@@ -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<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> 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<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
}
@@ -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
@@ -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();