Aller au contenu

Plugin PrestaShop

Le module titreoepay est le pont entre une boutique PrestaShop 8 et l’orchestrateur Titreo E-Pay. Il ne contient aucune logique de paiement : il délègue tout au backend (création de la session, choix des instruments, débit cartes cadeaux + CB, remboursements). C’est un thin connector qui se contente de trois responsabilités :

  1. Créer une session de paiement côté API quand le client choisit Titreo E-Pay au checkout, puis le rediriger vers la page de paiement hébergée.
  2. Recevoir le webhook signé renvoyé par le backend et mettre à jour le statut de la commande PrestaShop en conséquence.
  3. Déclencher les remboursements (depuis le back-office ou depuis un changement d’état de commande).

Tout ce qui est apparence (logo, couleurs, polices), gestion d’équipe et suivi des transactions a migré dans la Console marchand : le plugin ne sert plus qu’à connecter la boutique à l’API. Pour situer cette brique dans l’ensemble, voir la vue d’ensemble de l’écosystème.

Le code vit dans plugins/prestashop/titreoepay/ (dépôt plugins/prestashop). Le module se déclare en version 0.6.0, dans titreoepay.php et config.xml — tep plugin build presta refuse de construire si les deux divergent. Il accepte PrestaShop à partir de 1.7.6 (ps_versions_compliancy).

plugins/prestashop/titreoepay/
├── titreoepay.php classe module (install, hooks, callApi)
├── config.xml manifest module
├── defaults.php adresses de la plateforme, réécrit à la construction du paquet
├── upgrade/upgrade-0.6.0.php mise à niveau depuis une version antérieure
├── CHANGELOG.md journal des versions
├── controllers/front/redirect.php crée la session + redirige vers le checkout
├── controllers/front/webhook.php reçoit les events signés, maj commande
├── controllers/front/returnpayment.php retour client : état lu sur l'API, redirection 302
├── controllers/admin/AdminTitreoEpayRefundController.php refund depuis le BO
├── cron/cleanup-pending.php purge des tables techniques
├── views/js/return-poll.js polling JS de la page d'attente (cas dégradé)
├── views/templates/ panel BO, formulaire refund, page retour
└── translations/error-codes.php libellés des codes d'erreur

TitreoEpay (dans titreoepay.php) étend PaymentModule. Elle déclare trois contrôleurs front (redirect, returnpayment, webhook) et active le mode multi-devises par cases à cocher.

La méthode install() commence par deux contrôles : PHP 7.1 au moins (MIN_PHP) et extension cURL présente. S’il en manque un, l’installation est refusée avec un message explicite. Elle crée ensuite deux tables techniques, enregistre les hooks, crée un état de commande personnalisé et initialise les clés de configuration.

Table Rôle
*_titreoepay_pending Mapping id_cart → titreo_session_id → id_order. Pivot central : le webhook s’en sert pour retrouver le panier puis la commande. Clé primaire id_cart, contrainte unique sur titreo_session_id.
*_titreoepay_event_seen Déduplication des événements webhook : un event_id déjà vu n’est pas rejoué.

Hooks enregistrés :

Hook Usage
paymentOptions Propose « Carte bancaire » (sous-titre « Cartes cadeaux acceptées », logos CB, Visa, Mastercard). Le nom de la plateforme n’apparaît pas au client.
displayPaymentReturn Retour de paiement côté thème.
actionOrderStatusPostUpdate Si la commande passe à l’état « Remboursé » dans le BO, déclenche un refund total via l’API.
displayAdminOrderMainBottom / displayAdminOrder Affiche le panneau Titreo dans la page commande du BO (compatibilité 1.7.7+ et versions antérieures).

install() crée aussi un état de commande dédié « litige ouvert » (installDisputedState(), clé TITREOEPAY_OS_DISPUTED) utilisé quand un événement de litige arrive, et initialise cinq clés de configuration (voir Configuration). Elle ne pose une valeur que si rien n’a jamais été saisi pour cette clé (setIfMissing) : une réinstallation ne remplace rien.

isConfigured() : pas de moyen de paiement à moitié configuré

Section intitulée « isConfigured() : pas de moyen de paiement à moitié configuré »

isConfigured() exige quatre valeurs non vides : l’URL de l’API, l’URL de la page de paiement, la clé API et le secret du webhook. Tant qu’il en manque une :

  • hookPaymentOptions() ne propose pas Titreo E-Pay au checkout — l’acheteur tomberait sur une erreur au moment de payer ;
  • la page de configuration affiche un avertissement.

Le secret du webhook en fait partie : sans lui les notifications seraient refusées, et la commande resterait « en attente » après un paiement réussi.

La méthode publique callApi($method, $path, $body, $extraHeaders) centralise tous les appels HTTP sortants (cURL) vers le backend : elle ajoute l’en-tête Authorization: Bearer <API key>, un X-Correlation-Id s’il n’est pas fourni, et renvoie ['ok', 'status', 'body']. Le contrôleur redirect.php a son propre cURL inline (création de session et client-token) ; les autres passent par callApi.

Chaque appel à l’API porte X-Correlation-Id, construit par TitreoEpay::correlationId($kind, $id) :

ps-<8 caractères du SHA-1 du domaine de la boutique>-<cart|order|bo>-<identifiant>

Tous les appels faits pour un même panier, puis pour une même commande, portent la même référence. C’est elle qu’on donne au support. L’empreinte de la boutique évite que deux boutiques partagent une référence. Sans identifiant (appel du back-office), le suffixe est tiré au hasard.

À la réception d’un webhook, incomingCorrelationId() relit l’en-tête envoyé par la plateforme ; le module le réutilise pour le PATCH du numéro de commande, à défaut celui de la commande. Voir Traçabilité.

sequenceDiagram
    participant C as "Client (navigateur)"
    participant PS as "PrestaShop (module)"
    participant API as "Backend Titreo E-Pay"
    participant CO as "Checkout hébergé"
    C->>PS: "Choisit « Carte bancaire » (hookPaymentOptions)"
    C->>PS: "controller=redirect"
    PS->>API: "POST /v1/sessions (Idempotency-Key)"
    API-->>PS: "{ id: sessionId }"
    PS->>PS: "INSERT titreoepay_pending (cart→session)"
    PS->>API: "POST /v1/sessions/:id/client-token"
    API-->>PS: "{ token }"
    PS-->>C: "Redirect vers CHECKOUT_URL/:locale/c/:id?t=...&success=...&cancel=..."
    C->>CO: "Paie (cartes cadeaux + CB)"
    API->>PS: "POST controller=webhook (X-Titreo-Signature)"
    PS->>PS: "validateOrder + UPDATE id_order"
    CO-->>C: "Redirect vers returnpayment"
    C->>PS: "controller=returnpayment"
    PS-->>C: "302 order-confirmation (commande créée ici si le webhook n'est pas passé)"

hookPaymentOptions() propose un bouton dont l’action pointe vers le contrôleur redirect. TitreoEpayRedirectModuleFrontController::postProcess() :

  • vérifie que le panier est complet (client, adresses) ;
  • calcule le montant total en centimes ;
  • appelle POST /v1/sessions avec reference, totalAmount, currency et un en-tête Idempotency-Key aléatoire ;
  • enregistre le mapping cart → session dans titreoepay_pending (en supprimant d’abord toute entrée existante pour le même panier ou la même session, afin qu’une nouvelle tentative écrase la précédente) ;
  • demande un client-token court (POST /v1/sessions/:id/client-token) destiné au navigateur ;
  • redirige vers la page de checkout hébergée :
{CHECKOUT_URL}/{locale}/c/{sessionId}?t={clientToken}&success={returnUrl}&cancel={returnUrl}

La locale est forcée à fr ou en. Les URLs success/cancel repointent vers le contrôleur returnpayment de la boutique.

TitreoEpayWebhookModuleFrontController est le cœur de la synchronisation. C’est un endpoint serveur-à-serveur : il neutralise la redirection canonique de PrestaShop (canonicalRedirection() surchargée en no-op) pour éviter qu’un 302 ne casse la livraison.

Séquence de postProcess() :

  1. Vérification de signature (verifySignature) : l’en-tête X-Titreo-Signature contient t=<timestamp>,v1=<hmac>. Le HMAC-SHA256 est calculé sur timestamp.payload avec le webhook secret, comparé en temps constant (hash_equals). Les signatures de plus d’une heure sont rejetées (stale_signature).
  2. Déduplication : si l’event_id est déjà dans titreoepay_event_seen, réponse 200 { duplicate: true } sans retraitement.
  3. Résolution du panier : findPending() retrouve la ligne pending par session_id. Si absente (course possible avec redirect.php), une attente de 1,5 s puis un retry. Toujours absente → 202 unknown_session (le PSP re-livrera selon son outbox).
  4. Dispatch par type d’événement (dispatchEvent) :
Événement / statut Action sur la commande
session.completed / COMPLETED validateOrder() (état PS_OS_PAYMENT), puis UPDATE id_order dans pending. Idempotent si la commande existe déjà.
session.failed / session.expired / CANCELED Passe la commande en PS_OS_CANCELED + note.
session.refunded / FULLY_REFUNDED Passe en PS_OS_REFUND + note.
session.partially_refunded / PARTIALLY_REFUNDED Note de remboursement partiel ; bascule en PS_OS_REFUND si le cumulé atteint le total.
session.disputed Passe à l’état custom « litige ouvert » + note.
*.action_required Note demandant une intervention manuelle.
  1. Marquage vu : insertion de l’event_id dans event_seen (INSERT IGNORE), puis réponse 200 { ok: true }.

returnpayment.php gère le retour du navigateur après le checkout. Depuis la 0.6.3, il ne dépend plus du webhook et n’affiche aucune page dans le cas nominal : il répond par une redirection 302.

  1. La ligne pending est retrouvée par session_id. Si elle porte déjà un id_order, redirection vers order-confirmation.
  2. Sinon, l’état de la session est lu sur l’API avec la clé de la boutique (TitreoEpay::fetchSession). Les paramètres de l’URL ne font jamais foi.
    • COMPLETED : TitreoEpay::ensureOrderForSession() crée la commande, puis redirection vers order-confirmation.
    • FAILED, EXPIRED, ROLLING_BACK : redirection vers le tunnel de commande, avec le motif en notification d’erreur.
    • status=canceled dans l’URL et session non payée : redirection vers le tunnel de commande, avec un avertissement.
  3. Tout autre état est relu toutes les 500 ms pendant 8 s. Passé ce délai seulement, return.tpl s’affiche et return-poll.js interroge GET /v1/sessions/:id toutes les 2 s (60 s au plus) ; dès qu’un état final apparaît, il recharge le contrôleur. Le paramètre retry borne ces rechargements à trois, puis la page affiche la référence de la session à donner à la boutique.

ensureOrderForSession() est commune au webhook et au retour. Un verrou MySQL nommé par panier (GET_LOCK) les met en file : le second trouve la commande du premier. Le moyen de paiement enregistré est « Carte bancaire » (TitreoEpay::paymentLabel()), visible sur la confirmation, l’historique, la facture et les e-mails. Le montant passé à validateOrder est celui de la session (pending.amount_cents) ; un panier modifié entre-temps donne une commande en « Erreur de paiement ».

Deux chemins déclenchent un refund :

  • Panneau commande (AdminTitreoEpayRefundController, gabarit views/templates/admin/admin_titreo_epay_refund/refund.tpl) : le formulaire liste les moyens de paiement de la session, une case à cocher par moyen. Chaque moyen coché est remboursé en entier, sur son support d’origine ; les autres restent encaissés. Il envoie POST /v1/sessions/:id/refund avec { legIds, reason? }.
  • Changement d’état : passer la commande à l’état « Remboursé » déclenche hookActionOrderStatusPostUpdate, qui lance un refund total (strategy: all, clé ps-order-refund-<id_order>).

Règles du panneau commande :

  • sans case cochée, rien n’est envoyé : l’API rembourserait tout ;
  • la clé d’idempotence est déterministe : ps-bo-refund-<id_order>-<empreinte des legs cochés>. La même sélection sur la même commande est la même demande ; un double clic ou un rechargement ne déclenche pas un second remboursement ;
  • le contrôleur n’envoie ni montant ni stratégie. L’API refuse gift_first et cb_first en 400, comme toute clé inconnue ;
  • la réponse (refundedLegIds, refundedAmount, currency) est reportée dans la confirmation et en note de commande.

Configurée dans la page module du BO (getContent() / renderForm()). Cinq clés stockées via Configuration :

Clé Rôle Valeur par défaut à l’install
TITREOEPAY_API_BASE_URL Base de l’API backend. api_base_url de defaults.php
TITREOEPAY_CHECKOUT_URL Base de la page de paiement hébergée. checkout_url de defaults.php
TITREOEPAY_CONSOLE_URL Console marchand, pour les liens de la page de configuration. console_url de defaults.php
TITREOEPAY_API_KEY Clé API marchand (tep_…), en-tête Authorization. vide (à renseigner)
TITREOEPAY_WEBHOOK_SECRET Secret HMAC de vérification des webhooks. vide (à renseigner)

Le formulaire exige l’URL de l’API et la clé API pour enregistrer. Le module ne se propose au paiement que si les quatre valeurs de isConfigured() sont présentes. Le webhook ne traite aucun événement si le secret est absent (réponse 500 webhook_secret_missing).

defaults.php rend trois adresses : api_base_url, checkout_url, console_url. Dans le dépôt elles sont vides. tep plugin build presta --domain <domaine> réécrit le fichier dans le paquet avec https://api.<domaine>, https://pay.<domaine> et https://merchant.<domaine>.

Un zip construit pour une plateforme s’installe donc avec ses adresses déjà renseignées : il ne reste au marchand que sa clé API et son secret de webhook. Un zip construit sans domaine s’installe aussi, et demande ces adresses dans sa page de configuration. Aucun secret n’est livré dans le paquet.

upgrade/upgrade-0.6.0.php (upgrade_module_0_6_0) :

  • remplace les adresses de développement des versions précédentes (http://api:3000, http://localhost:3100) par celles du paquet — ou par rien, et le module cesse alors de se proposer tant qu’il n’est pas configuré ;
  • ne touche jamais une adresse saisie par le marchand ;
  • enregistre les hooks manquants.
Aspect État
Flux checkout hébergé Fonctionnel (redirect + retour + polling).
Webhook signé + idempotence Fonctionnel (HMAC, double déduplication).
Refund BO + par état de commande Fonctionnel. Depuis la 0.6.0, remboursement par moyen de paiement (legIds) : une case par moyen, chacun remboursé en entier. Pas de montant partiel.
Paquet installable Fonctionnel (tep plugin build / publish presta, adresses livrées, contrôle PHP 7.1 et cURL, script de mise à niveau).
États de commande couverts completed, failed/expired/canceled, refund total/partiel, litige.

Restes connus :

  • Aucun test automatisé ; validation manuelle uniquement.
  • Traductions partielles : seul error-codes.php est traduit ; les chaînes du module et des templates ne sont pas internationalisées de façon exhaustive.
  • Cron non sécurisé en HTTP : prévu pour exécution CLI ; aucune protection par token si exposé via web.
  • Pas de validation Marketplace : le zip existe (tep plugin build presta, contrôlé après coup), la soumission au Marketplace n’a pas été tentée.

Deux commandes de la CLI de déploiement (deploy/cli/commands/plugin.mjs) :

Fenêtre de terminal
tep plugin build presta --domain <domaine> # construit et contrôle le zip
tep plugin publish presta --domain <domaine> # construit, puis dépose dans cdn/downloads/

--domain vaut par défaut le DOMAIN de .env.prod. Sans domaine, build construit un paquet sans adresses et le signale ; publish refuse : un module publié sans adresses obligerait chaque marchand à les saisir.

Ce que fait la construction :

  1. vérifie que les versions de titreoepay.php et de config.xml concordent ;
  2. lit les sources par git archive HEAD — des modifications non commitées ne partent pas dans le zip ;
  3. retire .gitignore, .gitattributes, .editorconfig, .gitlab-ci.yml ;
  4. réécrit defaults.php pour le domaine ;
  5. dépose un index.php de protection dans chaque dossier ;
  6. produit deploy/dist-plugins/titreoepay-<version>-<domaine>.zip, sans métadonnées macOS ;
  7. contrôle le zip produit : dossier racine unique titreoepay/, présence de titreoepay.php, config.xml, logo.png, defaults.php, upgrade/upgrade-0.6.0.php.

Ce que fait la publication :

  • copie le zip dans deploy/cdn/downloads/prestashop/ ;
  • écrit son empreinte SHA-256 à côté (<fichier>.sha256) ;
  • inscrit le paquet dans deploy/cdn/downloads/index.json (version, fichier, empreinte, taille, prérequis, domaine, commit source, date) ;
  • refuse d’écraser une version déjà publiée dont le contenu diffère. --force passe outre, pour la préproduction seulement.

Le dossier cdn/downloads/ s’envoie ensuite sur le serveur. Il est servi par le CDN sous /downloads/ (location ^~ /downloads/ dans deploy/config/nginx-cdn.conf) : archives en téléchargement, immuables ; index.json en cache d’une minute.

Les consoles lisent cet index : page Téléchargements de la console d’administration, carte de téléchargement à l’étape Installation de la console marchand.

Pour le détail des contrats (signature webhook, format de session, en-têtes d’idempotence) partagés avec le backend, voir Contrats inter-briques et Sécurité. Le module est l’analogue PrestaShop des autres connecteurs décrits dans la vue d’ensemble de l’écosystème.

À la création de la session, PrestaShop n’a qu’un panier : la référence envoyée est ps-cart-<id>-<horodatage> et metadata.cart.id porte l’identifiant du panier. La commande naît plus tard, dans TitreoEpay::ensureOrderForSession(), appelée par le webhook ou par le retour du client. Le module appelle alors PATCH /v1/sessions/:id/metadata avec { order: { id, number } } (number = la référence PrestaShop, XKBKKNMLA) — non bloquant, journalisé en cas d’échec. C’est ce que la console marchand et l’export CSV affichent en « N° de commande ».