Frédéric Barthelet
Frédéric est architecte-développeur Web chez Theodo. C'est un amoureux de PHP et d'IoT.
Cet article fait suite à celui écrit la veille par Louis Pinsard, Tirer profit des bundles Symfony. Je vous conseille vivement d'aller y jeter un oeil avant de continuer votre lecture. Louis traite des fondamentaux de l'écriture d'un bundle Symfony. Cet article vise à vous partager mon retour d'expérience concernant les éléments d'archictetures mise en place lors de la conception d'un bundle, et plus particulièrement comment assurer la séparation de votre code métier de celui de votre bundle.
Je suis envoyé en mission éclair chez AssoConnect, un SaaS dédié aux associations. AssoConnect offre des fonctionnalités de paiement en ligne pour que les associations puissent utiliser la plateforme pour des campagnes de dons, de la gestion d'adhésion, des boutiques et de l'événementiel.
Objectif de la mission ? Les aider à changer leur PSP historique, trop peu adapté à leur croissance.
Un PSP ? Quezako ?
Un PSP, Payment Service Provider, ou Prestataire de Services de Paiement, est un service permettant à des applications d'accepter des paiements en ligne. Paypal, Stripe ou encore WePay sont des PSP.
Sortir de l'implémentation du PSP actuellement utilisé est un développement douloureux : il n'existe aucune séparation de code entre le paiement, le catalogue produit, et les intéractions avec le PSP. Remplacer au fur et à mesure les utilisations du service de paiement dans le code source est long.
De plus, pour faciliter le tout, AssoConnect n'est pas un compte virtuel auprès de son PSP. La plateforme utilise les fonctionnalités marketplace des PSP : chaque association a son propre porte-monnaie virtuel pour y percevoir les dons et adhésions et gère ce dernier de façon autonome.
Avant de plonger dans le code source d'AssoConnect, on s'assoit tous autour d'une table et on établit notre objectif :
Il s'agit donc d'effectuer les tâches suivantes :
Je commence donc par créer un service destiné à s'occuper du paiement dans mon bundle fraîchement créé et je me retrouve confronté au premier problème : comment typer mon contrat d'interaction avec le bundle ?
Si je souhaite payer une adhésion, j'aimerai envoyer mon modèle d'adhésion, Membership
, comme argument au bundle et le laisser gérer les étapes nécessaires au paiement de celui-ci.
Cependant, un tel choix apporterait deux problèmes en contradiction avec mes objectifs :
Membership
// Code métier - namespace App\Entity
class Membership {
/**
* Cet attribut est spécifique au paiement, il doit être visible par le bundle de paiement
* @ORM\Column()
*/
private $price;
/**
* Cet attribut est spécifique au context de mon application, il ne fait pas parti du bundle
* @ORM\Column()
*/
private $year;
public function getPrice(): int {
return $this->price;
}
public function getYear(): int {
return $this->year;
}
}
// Code paiement - namespace PaymentBundle\Payment
class PaymentService {
/**
* Membership est un objet spécifique au context de mon application.
* Je pollue le bundle de paiement en utilisant ce dernier comme typage.
*/
public function processPayment(Membership $membership) {
$psp->processPaymentAmount($membership->getPrice());
}
}
Je ne peux donc pas typer les méthodes de l'interface du bundle de paiement comme utilisant directement cette entité Membership
.
Deux solutions s'offrent alors à moi :
Membership
et une entité Payment
.Typer les arguments des méthodes publiques de l'API de mon bundle avec une interface propre à ce dernier. J'aurai alors une interface PaymentTransactionInterface
imposant par exemple une méthode getPrice
. Je n'ai plus qu'à mettre à jour mon entité Membership
pour qu'elle implémente cette interface en développant le code qui me permet de récupérer le montant du paiement à effectuer avec le PSP.
C'est la deuxième et dernière solution qui est retenue. Je simplifie ainsi mon modèle de données et je n'impose pas l'utilisation d'un ORM.
J'ai donc un code agnostique du métier propre à AssoConnect dans mon bundle :
// Code métier - namespace App\Entity
class Membership implements PaymentTransactionInterface {
/**
* @ORM\Column()
*/
private $price;
public function getPrice(): int {
return $this->price;
}
}
// Code paiement - namespace PaymentBundle\Payment
interface PaymentTransactionInterface {
public function getPrice(): int;
}
class PaymentService {
public function processPayment(PaymentTransactionInterface $paymentTransaction) {
$psp->processPaymentAmount($paymentTransaction->getPrice());
}
}
Je peux alors injecter le service de paiement PaymentService
dans l'un des services de mon choix et appeller la méthode processPayment
avec une Membership
.
Cette entitée supporte l'exécution d'un paiement car elle implémente PaymentTransactionInterface
et satisfait donc le contrat de PaymentService
.
Certaines méthodes du bundle de paiement ont également besoin d'instancier de nouveaux supports et de demander à les sauvegarder en l'état.
Par exemple, certains PSP me notifient d'évolution du statut d'un paiement de manière asynchrone en utilisant des webhooks. Ces notifications nécessitent un traitement par paiement, que je n'ai pas besoin d'implémenter code métier. Je dois alors enrichir mon interface pour qu'elle m'offre la possibilité d'affecter des valeurs à certains attributs. J'ai également besoin d'un service pour récupérer et persister ces dernières, quelque soit la méthode d'implémentation de la couche de persistence côté métier.
Ajoutons donc une méthode à l'interface PaymentTransactionInterface
pour me permettre d'hydrater un statut, correspondant au statut du paiement.
// Code paiement - namespace PaymentBundle\Payment
interface PaymentTransactionInterface {
public const STATUS_PAID = 1;
public function getPrice(): int;
public function setStatus(int $status): self;
}
J'ajoute également un méthode processPaymentNotification
au PaymentService
du bundle pour traiter le contenu des webhook me notifiant de mises à jour du statut d'un paiement.
Pour simplifier le code, je considère que l'objet Notification
que j'utilise symbolise une Notification de paiement confirmé.
// Code paiement - namespace PaymentBundle\Payment
class PaymentService {
public function processPaymentNotification(Notification $notification) {
// Ici je dois récupérer de l'instance implémentant PaymentTransactionInterface
// correspondant à la notification reçue. Par exemple, l'une des Membership
// précédement évoquée.
$paymentTransaction->setStatus(PaymentTransactionInterface::STATUS_PAID);
// Ici je persiste la modification de statut de la Membership.
}
}
Pour remplir le texte à trou de la fonction processPaymentNotification
, j'ai besoin d'un service qui va me servir à 2 choses :
PaymentTransactionInterface
grâce à un référence unique, $ref
PaymentTransactionDataHandlerInterface
dans le context du bundle, qui définit deux méthodes répondants à mon besoin de récupération et de persistence.
// Code paiement - namespace PaymentBundle\Payment
interface PaymentTransactionDataHandlerInterface {
public function findByRef(string $ref): ?PaymentTransactionInterface;
public function save(PaymentTransactionInterface $paymentTransaction): void;
}
J'ai ensuite la possibilité d'implémenter cette interface dans le context de mon application, le service MembershipDataHandler
par exemple pour les adhésions, et de spécifier ce service comme un alias de l'interface.
// Code métier - namespace App\DataHandler
class MembershipDataHandler implements PaymentTransactionDataHandlerInterface {
// J'injecte le membershipRepository, celui de mon ORM, dans ce service par injection de dépendance
// J'implémente la méthode de récupération de Membership
public function findByRef(string $ref): ?PaymentTransactionInterface {
return $this->membershipRepository->findOneBy(['myApplicationReference' => $ref]);
}
// J'implémente la méthode de sauvegarde de Membership
public function save(PaymentTransactionInterface $paymentTransaction): void {
$this->membershipRepository->save($paymentTransaction);
}
}
Pour finir, je peux enfin compléter la méthode processPaymentNotification
en injectant la PaymentTransactionDataHandlerInterface
comme dépendance du service PaymentService
.
class PaymentService {
public function __construct(PaymentTransactionDataHandlerInterface $paymentTransactionDataHandler) {
$this->paymentTransactionDataHandler = $paymentTransactionDataHandler;
}
public function processPaymentNotification(Notification $notification) {
$paymentTransaction = $this->paymentTransactionDataHandler->findByRef($notification->ref);
$paymentTransaction->setStatus(PaymentTransactionInterface::STATUS_PAID);
$this->paymentTransactionDataHandler->save($paymentTransaction);
}
}
Et j'alias l'interface PaymentTransactionDataHandlerInterface
par son implémentation : MembershipDataHandler
.
services:
PaymentBundle\Payment\PaymentTransactionDataHandlerInterface: '@App\DataHandler\MembershipDataHandler'
Dernière problématique de cet article, la gestion de l'interface avec le PSP.
Pour rappel, l'objectif du projet inclut la mise en place d'un contrat d'interface commun aux différents PSP supportés par le bundle.
Ce contrat me permet d'interagir avec n'importe quel PSP sans connaître ce dernier.
La problématique qui apparaît alors, c'est comment choisir le PSP pertinent pour ce paiement et quel architecture mettre en place pour aisément implémenter de nouveaux PSP.
Si on reprend le service PaymentService
et sa méthode processPayment
de la première partie, je cherche à développer la fonctionnalité permettant d'hydrater l'attribut $psp
du service.
// Code paiement - namespace PaymentBundle\Payment
class PaymentService {
public function processPayment(PaymentTransactionInterface $paymentTransaction) {
$psp->processPaymentAmount($paymentTransaction->getPrice());
}
}
Pour ce faire je met en place un contrat d'interface, PspInterface
, qui impose une méthode processPaymentAmount
.
// Code paiement - namespace PaymentBundle\Psp
interface PspInterface {
public function processPaymentAmount(int $amount): void;
}
Dans le bundle, j'implémente cette interface sur deux PSPs distincts :
// Code paiement - namespace PaymentBundle\Psp
class StripePsp implements PspInterface {
public function processPaymentAmount(int $amout): void {
// Code spécifique à Stripe pour déclencher le paiement d'un montant donné
}
}
class PaypalPsp implements PspInterface {
public function processPaymentAmount(int $amout): void {
// Code spécifique à Paypal pour déclencher le paiement d'un montant donné
}
}
Pour que le PaymentService
puisse choisir un PSP pertinent, j'ai besoin d'une méthode sur chaque implémentation de PspInterface
qui me retourne un booléen me signifiant sa pertinence à traiter telle transaction :
// Code paiement - namespace PaymentBundle\Psp
interface PspInterface {
public function processPaymentAmount(int $amount): void;
public function supportTransaction(PaymentTransactionInterface $paymentTransaction): bool;
}
Si Stripe est pertinent pour des paiements de moins de 20€, et Paypal se charge du reste, je n'aurai alors qu'à implémenter cette nouvelle méthode supportTransaction
sur les deux services suivant cette règle.
// Code paiement - namespace PaymentBundle\Psp
class StripePsp implements PspInterface {
public function supportTransaction(PaymentTransactionInterface $paymentTransaction): bool {
return $paymentTransaction->getPrice() < 20;
}
}
class PaypalPsp implements PspInterface {
public function supportTransaction(PaymentTransactionInterface $paymentTransaction): bool {
return $paymentTransaction->getPrice() >= 20;
}
}
Je n'ai plus qu'à itérer sur tous les services représentants des PSP dans le PaymentService
et choisir le premier qui supporte la transaction à exécuter.
Pour ce faire, j'utilise l'injection de dépendance de Symfony pour injecter dans le constructeur de ce service tous les services implémentant PspInterface
.
services:
_instanceof:
PaymentBundle\Psp\PspInterface:
tags: ['payment.psp']
PaymentBundle\Payment\PaymentService:
arguments:
$psps: !tagged payment.psp
// Code paiement - namespace PaymentBundle\Payment
class PaymentService {
public function __construct(iterable $psps) {
$this->psps = $psps;
}
private function getPsp(PaymentTransactionInterface $paymentTransaction): PspInterface {
foreach ($this->psps as $psp) {
if ($psp->supportTransaction($paymentTransaction)) {
return $psp;
}
}
}
public function processPayment(PaymentTransactionInterface $paymentTransaction) {
$psp = $this->getPsp($paymentTransaction);
$psp->processPaymentAmount($paymentTransaction->getPrice());
}
}
Et voilà, le processus de sélection du PSP pertinent est automatique, et le code l'utilisant est agnostique du choix de ce dernier !
J'espère que ce retour d'expérience vous sera utile pour l'écriture de votre propre bundle.
Je remercie toute l'équipe d'AssoConnect qui m'a permis de partager cette expérience avec vous.
Je vous conseille d'aller voir de plus près l'architecture de thephpleague/oauth2-server, avec notament l'implémentation de DataHandler comme dans League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface
, qui a largement inspiré le bundle de paiment.