← Retour aux cas d'études
Architecture FinTech & Domaine Symfony

Refactoriser la gestion bancaire hébergeurs sous Symfony : Architecture Multi-RIB, Workflow et routage des virements

Les points clés de ce retour d'expérience :

  • Découplage Multi-RIB du Domaine : Évolution d'un RIB unique rattaché à l'utilisateur vers une gestion multi-comptes bancaires, autorisant le ciblage par annonce (ex: compte personnel vs SCI).
  • Validation et vérification d'IBAN : Isolation du contrôle de format et de checksum via IbanChecking et CannotValidateIbanException.
  • State Machine & Workflow de validation : Sécurisation des reversements par une machine à états (CREATED, EDITING, VALIDATED).
  • Routage dynamique des versements (PayoutBusiness) : Algorithme de sélection priorisant le compte bancaire spécifique de l'annonce avant repli automatique sur le compte par défaut de l'hôte.
  • Pont FinTech & API Wise : Intégration du formulaire Symfony Form et synchronisation asynchrone des identifiants bancaires internationaux.

1. Exigences produit et limites du modèle monobanque

Sur le plan produit et métier, la plateforme gérait historiquement un unique RIB global associé au profil de chaque utilisateur. Avec le développement de l'activité des hôtes professionnels et des propriétaires multi-biens, cette limitation est rapidement devenue un frein critique :

  • Les bailleurs gérant plusieurs logements souhaitaient percevoir les loyers sur des comptes bancaires distincts (ex : un compte personnel pour la résidence secondaire, un compte de SCI pour les appartements en location).
  • Toute modification de RIB par l'hôte impactait l'intégralité de ses annonces sans possibilité de sectoriser les versements.
  • Côté backend, les identifiants bancaires étaient couplés directement au contrôleur de profil, rendant impossible le routage des versements par annonce.

Pour lever ces verrous et sécuriser la conformité FinTech, j'ai architecturé le module Multi-RIB. Cette refonte s'articule autour d'un domaine étanche, d'un routage dynamique des transactions et de deux composants spécialisés.

Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister dans les exemples : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.

2. Modélisation du Domaine Multi-RIB : Association par Annonce & Schéma BDD

L'évolution vers une architecture Multi-RIB a nécessité de repenser le couplage entre l'hôte, ses annonces et ses coordonnées bancaires. Dans le modèle monobanque legacy, un RIB unique était rattaché au profil de l'utilisateur. Pour permettre la sectorisation des loyers par bien (ex : compte personnel vs SCI), j'ai fait évoluer l'entité Listing (Annonce) pour y associer une relation directe vers PaymentAccount :

<?php

declare(strict_types=1);

namespace App\Domain\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'LISTING')]
class Listing
{
    // ...

    #[ORM\ManyToOne(targetEntity: PaymentAccount::class)]
    #[ORM\JoinColumn(name: 'PAYMENT_ACCOUNT_ID', referencedColumnName: 'ID', nullable: true)]
    private ?PaymentAccount $paymentAccount = null;

    public function getPaymentAccount(): ?PaymentAccount
    {
        return $this->paymentAccount;
    }

    public function setPaymentAccount(?PaymentAccount $paymentAccount): self
    {
        $this->paymentAccount = $paymentAccount;
        return $this;
    }
}

En parallèle, pour formaliser le compte bancaire utilisé comme fallback général sur le profil de l'hôte, l'entité User intègre une relation directe vers son compte par défaut :

<?php

declare(strict_types=1);

namespace App\Domain\Entity;

use Doctrine\ORM\Mapping as ORM;

class User
{
    // ...

    #[ORM\OneToOne(targetEntity: PaymentAccount::class)]
    #[ORM\JoinColumn(name: 'DEFAULT_RIB', referencedColumnName: 'ID', nullable: true, onDelete: 'SET NULL')]
    private ?PaymentAccount $defaultRib = null;

    public function getDefaultRib(): ?PaymentAccount
    {
        return $this->defaultRib;
    }

    public function setDefaultRib(?PaymentAccount $defaultRib): self
    {
        $this->defaultRib = $defaultRib;
        return $this;
    }
}

Sur le plan de la base de données, cette transition a également exigé la modification de la table PAYMENT_ACCOUNT. La contrainte 1-à-1 stricte (unique_userid) qui empêchait un utilisateur de posséder plusieurs RIBs a été remplacée par un index non-unique (idx_payment_account_userid), autorisant ainsi un hôte à enregistrer et valider plusieurs comptes bancaires distincts :

<?php

declare(strict_types=1);

namespace App\Domain\Entity;

use App\Domain\Enum\RibStatus;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Table(name: 'PAYMENT_ACCOUNT')]
#[ORM\Index(columns: ['USER_ID'], name: 'idx_payment_account_userid')]
#[ORM\Entity]
class PaymentAccount
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'IDENTITY')]
    #[ORM\Column(name: 'ID', type: Types::BIGINT)]
    private ?string $id = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(name: 'USER_ID', referencedColumnName: 'ID', nullable: false)]
    private User $user;

    #[ORM\Column(name: 'STATUS', type: Types::STRING, length: 16, nullable: false)]
    private string $status = RibStatus::CREATED;

    public function getStatus(): string
    {
        return $this->status;
    }

    public function isValidated(): bool
    {
        return RibStatus::VALIDATED === $this->status;
    }
}

Pour interroger efficacement ce nouveau domaine et gérer le cycle de versement, ListingRepository s'est enrichi de méthodes de recherche ciblant les annonces associées à un RIB. La méthode findAllByRibOrNullForUser() gère spécifiquement la stratégie de repli (fallback) : elle englobe à la fois les annonces explicitement rattachées à un RIB particulier et les annonces sans RIB dédié (paymentAccount IS NULL), destinées à basculer automatiquement sur le compte bancaire par défaut configuré au niveau de l'utilisateur :

<?php

declare(strict_types=1);

namespace App\Infrastructure\Repository\Doctrine;

use App\Domain\Entity\Listing;
use App\Domain\Entity\PaymentAccount;
use App\Domain\Entity\User;

class ListingRepository extends BaseRepository
{
    public function findAllByRib(PaymentAccount $paymentAccount): ListingCollection
    {
        return $this->createQueryBuilder('listing')
            ->andWhere('listing.paymentAccount = :paymentAccount')
            ->setParameter('paymentAccount', $paymentAccount)
            ->getQuery()
            ->getResult();
    }

    public function findAllByRibOrNullForUser(User $user, ?PaymentAccount $paymentAccount = null): ListingCollection
    {
        $qb = $this->createQueryBuilder('listing')
            ->andWhere('listing.user = :user')
            ->setParameter('user', $user);

        if ($paymentAccount) {
            $qb->andWhere(
                $qb->expr()->orX(
                    $qb->expr()->eq('listing.paymentAccount', ':paymentAccount'),
                    $qb->expr()->isNull('listing.paymentAccount')
                )
            )->setParameter('paymentAccount', $paymentAccount);
        } else {
            $qb->andWhere('listing.paymentAccount IS NULL');
        }

        return $qb->getQuery()->getResult();
    }
}

Bénéfice d'architecture : En rendant la relation optionnelle au niveau de l'annonce (nullable: true), la couche Domaine gère la sectorisation sans rendre l'association obligatoire. La méthode findAllByRibOrNullForUser() dans le repository garantit ainsi que chaque annonce peut cibler un RIB dédié tout en s'appuyant de manière fluide sur le compte par défaut de l'hôte.

3. Orchestration du cycle de vie par State Machine (Composant Symfony Workflow)

Pour interdire l'émission de virements vers des comptes bancaires non vérifiés ou en cours d'édition, j'ai introduit une State Machine stricte gérée par le composant Symfony Workflow. Chaque compte bancaire traverse les états CREATED, EDITING et VALIDATED.

La validation déclenche automatiquement la réassignation des annonces sans RIB vers ce nouveau compte, tandis qu'une modification fait retomber le compte à l'état EDITING avec révocation temporaire des autorisations de virement.

Focus Technique - Deep Dive Workflow :
L'implémentation détaillée du fichier YAML workflow_payment_account.yaml, de la tâche ApplyPaymentAccountTransitionTask et de l'écouteur événementiel PaymentAccountTransitionSubscriber fait l'objet d'un article dédié :
👉 Lire l'article spécialisé : Gérer le cycle de vie des RIB avec Symfony Workflow →

4. Routage dynamique des reversements par Annonce (PayoutBusiness)

Au moment d'exécuter un virement de loyer suite à une réservation, le service de domaine PayoutBusiness détermine dynamiquement le compte bancaire destinataire. L'algorithme cherche d'abord si un compte spécifique est rattaché à l'annonce concernée ($listing->getPaymentAccount()), à défaut, il utilise le compte par défaut configuré sur le profil de l'hôte :

<?php

declare(strict_types=1);

namespace App\Domain\Business;

use App\Domain\Entity\DepositLtTransaction;
use App\Domain\Entity\PaymentAccount;
use App\Domain\Entity\User;
use App\Domain\Enum\RibStatus;
use Exception;

final class PayoutBusiness
{
    public static function retrievePayoutRib(DepositLtTransaction $transactionLt): ?PaymentAccount
    {
        return match (self::retrieveTransactionUser($transactionLt)) {
            $transactionLt->getLandlord() => self::retrieveLandlordRib($transactionLt),
            $transactionLt->getTenant() => self::retrieveTenantRib($transactionLt),
            default => null,
        };
    }

    public static function retrieveLandlordRib(DepositLtTransaction $transactionLt): ?PaymentAccount
    {
        $paymentAccount = $transactionLt->getDepositLt()->getListing()->getPaymentAccount();

        if (!$paymentAccount) {
            $paymentAccount = $transactionLt->getLandlord()->getDefaultRib();
        }

        return $paymentAccount;
    }

    public static function isRibValid(?PaymentAccount $paymentAccount): bool
    {
        if (!$paymentAccount
            || null === $paymentAccount->getPartnerRibId()
            || empty($paymentAccount->getPartnerRibId())
            || !$paymentAccount->isValidated()
        ) {
            return false;
        }

        return true;
    }
}

5. Synergie de la refactorisation en amont : Formulaire & API Wise

L'un des enseignements majeurs de ce chantier réside dans la séquence des refactorisations : la refonte du formulaire bancaire international (PaymentAccountType) et son couplage à l'API Wise avaient été réalisés en amont du chantier Multi-RIB, sans savoir à l'époque que la gestion multi-comptes allait être exigée par le produit.

Cette isolation préalable des règles par pays (CountryPolicy) et l'assainissement de la couche de saisie ont agi comme un véritable levier d'architecture : lorsque le besoin du Multi-RIB est arrivé, la couche de présentation et la validation des formats bancaires internationaux étaient déjà solides, étanches et prêtes à être instanciées sur plusieurs comptes (PaymentAccount) sans réécrire la logique de formulaire.

Focus Technique - La refactorisation en amont :
Pour découvrir comment la refonte initiale du formulaire et des Country Policies Wise a été conçue en amont avant l'arrivée du Multi-RIB, consultez le cas d'étude dédié :
👉 Lire le cas d'étude : Refactoriser le formulaire bancaire avec Symfony Form & Wise API →

6. Tableau comparatif : Avant / Après Architecture Multi-RIB

Axe d'architecture Avant (Modèle Monobanque Legacy) Après (Architecture Multi-RIB Refactorisée)
Rattachement bancaire RIB unique global lié à l'utilisateur, aucune sectorisation possible. Association flexible par annonce avec repli automatique sur le RIB hôte par défaut.
Gestion du cycle de vie & Sécurité Suppression du RIB au clic sur modification, puis recréation uniquement après validation complète (vérification API Wise). Sauvegarde AJAX des modifications en BDD sous l'état EDITING (Symfony Workflow), permettant d'éditer et valider les coordonnées progressivement sans supprimer le compte.

7. Enseignements et arbitrages d'ingénierie

Sur le plan personnel et technique, la conception de ce système FinTech m'a permis de consolider plusieurs acquis d'architecture majeurs :

  • Isolation du Domaine d'une API tierce : En plaçant PayoutBusiness au centre du domaine, les contrôleurs et processeurs de paiement n'interagissent plus directement avec les SDKs bancaires.
  • Flexibilité pour les utilisateurs multi-biens : La possibilité d'affecter un RIB spécifique par logement a apporté une réelle réponse aux besoins des bailleurs professionnels.