Aller au contenu

App Shopify (epay-shopify-app)

epay-shopify-app est l’app Shopify qui branche une boutique Shopify sur l’orchestrateur Titreo E-Pay. Elle répond à une contrainte de plateforme : sur Shopify, on ne peut pas simplement injecter un nouveau moyen de paiement « carte cadeau multi-enseigne » dans le checkout. La solution retenue est le miroir : la carte cadeau externe (Wedoogift, Illicado, EdenRed, Glady…) est débitée via E-Pay, puis le montant est matérialisé sous une forme que Shopify sait nativement consommer.

Deux surfaces, deux matérialisations :

  • Profil client (Customer Account) → le montant débité est versé en store credit (avoir) du client, réutilisable à sa prochaine commande.
  • Checkout (Shopify Plus) → le montant débité émet une gift card Shopify miroir à usage unique, appliquée immédiatement au panier (applyGiftCardChange).

Dans les deux cas, le pattern est le même : débiter une fois la carte externe (côté E-Pay, idempotent) et créditer un instrument Shopify natif, avec compensation (recrédit) si l’étape Shopify échoue. C’est ce pattern qui a d’abord été prouvé dans le POC, avant d’être industrialisé ici. Voir l’étude de faisabilité gift card.

Cette app est une brique cliente de l’API Titreo E-Pay : elle ne contient aucune logique d’orchestration de paiement « cœur », elle consomme les endpoints publics gift-cards d’E-Pay (cf. Contrats inter-briques et Backend).

flowchart LR
  Buyer["Acheteur (profil ou checkout)"] --> Ext["Extension UI (Preact)"]
  Ext -->|"fetch + sessionToken"| App["epay-shopify-app (React Router 7)"]
  App -->|"REST /v1/gift-cards/*"| Epay["API Titreo E-Pay"]
  App -->|"Admin GraphQL"| Shopify["Shopify (store credit / gift card)"]
  Epay --> Titreo["Gateway Titreo (Wedoogift, Illicado, EdenRed...)"]

Le projet part du template officiel Shopify app React Router (anciennement Remix), converti en React Router 7. Détails dans epay-shopify-app/package.json :

Élément Valeur
Framework React Router 7 (@react-router/* 7.12)
SDK Shopify @shopify/shopify-app-react-router 1.1
Session storage @shopify/shopify-app-session-storage-prisma (Prisma + SQLite)
Admin API GraphQL, ApiVersion.October25
Extensions Preact + composants web Polaris <s-...>, API 2026-04
Build / dev Vite 6, shopify app dev, react-router build

Scripts npm réels (package.json) : dev (shopify app dev), build (react-router build), start (react-router-serve), setup (prisma generate && prisma migrate deploy), docker-start, deploy (shopify app deploy), generate, typecheck, lint.

epay-shopify-app/
app/
shopify.server.ts # config shopifyApp() : scopes, session storage, authenticate
db.server.ts # client Prisma (singleton)
root.tsx, entry.server.tsx
routes.ts # flatRoutes() : routing par convention de nommage de fichier
routes/
app.tsx, app._index.tsx, app.additional.tsx # admin embarqué (Polaris)
auth.$.tsx, auth.login/ # OAuth Shopify
webhooks.app.uninstalled.tsx
webhooks.app.scopes_update.tsx
api.gift-card-balance.tsx # POST public : lecture solde carte externe
api.gift-card-mirror.tsx # POST public : débit + gift card miroir (checkout)
api.redeem.tsx # POST public : débit + store credit (profil)
services/
config.server.ts # seule lecture process.env (EPAY_API_BASE / EPAY_API_KEY)
epay-issuer.server.ts # client REST de l'API E-Pay (check-balance/debit/refund)
session-token.server.ts # vérif JWT session token (HS256, node:crypto)
redeem.server.ts # saga store credit (profil)
checkout-mirror.server.ts # saga gift card miroir (checkout)
mirror.server.ts # mutations Admin giftCardCreate / giftCardDeactivate
extensions/
profile-block/ # Customer Account UI Extension (profil) → AddGiftCard.jsx
checkout-gift/ # Checkout UI Extension (Plus) → Checkout.jsx
prisma/schema.prisma # modèle Session (OAuth)
shopify.app.toml # scopes, webhooks, config app

Le routing suit la convention de nommage à plat de @react-router/fs-routes (app/routes.ts n’appelle que flatRoutes()). Les . dans les noms de fichiers deviennent des / dans l’URL : api.gift-card-balance.tsx/api/gift-card-balance. Les routes app.* sont l’admin embarqué (protégé par authenticate.admin), les routes api.* sont publiques (appelées par les extensions), les routes webhooks.* reçoivent les webhooks Shopify.

Les scopes demandés sont déclarés dans shopify.app.toml :

write_products, write_metaobjects, write_metaobject_definitions,
read_customers, read_store_credit_account_transactions,
write_store_credit_account_transactions, write_gift_cards

Les deux scopes load-bearing pour le miroir sont write_store_credit_account_transactions (versement d’avoir) et write_gift_cards (émission de gift card). À noter : le code lit volontairement balanceAfterTransaction sur la transaction et pas account { balance }, pour éviter d’exiger le scope read_store_credit_accounts que l’app n’a pas (cf. commentaire dans redeem.server.ts).

config.server.ts est le seul endroit qui lit process.env (règle d’isolation des secrets). Deux variables :

Variable Rôle
EPAY_API_BASE URL de base de l’API Titreo E-Pay (ex. http://localhost:3100 en dev)
EPAY_API_KEY clé API marchand tep_live_… (scopes sessions:read + sessions:write)

getEpayConfig() throw clairement si l’une manque (échec au boot ou au premier appel). Pour lancer E-Pay en local, voir Environnement de dev local et la vue d’ensemble de l’écosystème.

Les trois routes api.* sont publiques et partagent le même contrat : requête POST JSON, réponse Response.json(...) avec en-têtes CORS sur toutes les réponses (y compris OPTIONS → 204 et les erreurs). Le code de statut suit l’issue : 200 si ok:true, 422 si erreur métier, 401 si token invalide, 500 en filet.

Aucune de ces routes n’utilise l’auth admin embarquée. À la place, l’extension récupère un session token JWT signé par Shopify (shopify.sessionToken.get()) et l’envoie dans le body (pas en header Authorization → requête « simple », pas de preflight CORS). Le backend le vérifie avec le client secret de l’app (SHOPIFY_API_SECRET) via session-token.server.ts :

  • verifySessionToken() : vérifie la signature HS256 + exp/nbf (zéro dépendance, node:crypto), retourne les claims.
  • shopFromPayload() : boutique depuis le claim dest.
  • customerIdFromPayload() : GID client depuis le claim sub (normalisé en gid://shopify/Customer/...).

Étape 1 du parcours (lecture seule, pas d’admin Shopify, pas de débit). Body { sessionToken, cardCode, cvv }. Vérifie le token (boutique uniquement, pas de client requis), puis appelle checkCardBalance() (redeem.server.ts) → E-Pay /v1/gift-cards/check-balance. Renvoie le solde disponible, la devise et les 4 derniers chiffres. Sert au client à choisir le montant à verser.

POST /api/redeem — débit + store credit (profil)

Section intitulée « POST /api/redeem — débit + store credit (profil) »

Body { sessionToken, cardCode, cvv, amount, requestId }. Vérifie le token (boutique et client), obtient un admin offline via unauthenticated.admin(shop), puis exécute la saga redeemForCustomer(). Versement en store credit du client.

POST /api/gift-card-mirror — débit + gift card miroir (checkout)

Section intitulée « POST /api/gift-card-mirror — débit + gift card miroir (checkout) »

Body { sessionToken, cardCode, cvv, amount, requestId }. Vérifie le token (boutique uniquement, checkout possiblement invité), admin offline via unauthenticated.admin(shop), puis saga mirrorForCheckout(). Émet une gift card Shopify dont le code est renvoyé à l’extension, qui l’applique in-checkout.

Route Surface Étape E-Pay Crédit Shopify Client requis
api.gift-card-balance profil + checkout check-balance aucun (lecture) non
api.redeem profil check + debit store credit oui (sub)
api.gift-card-mirror checkout check + debit gift card miroir non

La saga checkout-mirror (réserve → émet → applique, avec compensation)

Section intitulée « La saga checkout-mirror (réserve → émet → applique, avec compensation) »

checkout-mirror.server.ts orchestre le parcours checkout. C’est une saga à compensation : on débite d’abord la carte externe (idempotent), puis on émet la gift card Shopify ; si l’émission échoue après le débit, on recrédite la carte externe pour ne rien perdre.

sequenceDiagram
  participant Ext as "Extension checkout (Checkout.jsx)"
  participant API as "api.gift-card-mirror"
  participant Saga as "mirrorForCheckout()"
  participant Epay as "EpayIssuerAdapter (E-Pay)"
  participant Shop as "mirror.server (Admin API)"

  Ext->>API: POST { sessionToken, cardCode, cvv, amount, requestId }
  API->>Saga: mirrorForCheckout(...)
  Saga->>Epay: checkBalance(cardCode, cvv)
  Saga->>Epay: debit(amount, idempotencyKey=mirror-debit:requestId)
  Saga->>Shop: emitMirrorGiftCard(amount) -> code, last4
  alt émission OK
    Saga-->>API: { ok, code, last4, applied }
    API-->>Ext: 200
    Ext->>Ext: applyGiftCardChange({ addGiftCard, code })
  else émission KO
    Saga->>Epay: refund(amount, idempotencyKey=mirror-refund:requestId)
    Saga-->>API: { ok:false, EMIT_FAILED }
  end

Points clés :

  • Idempotence par requestId. Le débit E-Pay est idempotent (clé mirror-debit:<requestId>), mais giftCardCreate ne l’est pas (chaque appel crée une carte). La saga met donc l’issue réussie en cache par requestId : un retry renvoie le même code sans re-débiter ni ré-émettre. La saga store credit (redeem.server.ts) applique exactement le même principe avec redeemForCustomer() (clé debit:<opKey> / compensation refund:<opKey>, opKey = requestId ou fallback carte:client:montant).
  • Retry court sur erreur transitoire. Un débit qui échoue avec transient:true (HTTP 502 E-Pay = erreur de transport émetteur) est retenté jusqu’à 2 fois avec la même clé d’idempotence → pas de double débit.
  • Garde-fou si l’application échoue côté checkout. Si applyGiftCardChange échoue alors que la carte est déjà émise, l’extension affiche le code à l’acheteur (rien n’est perdu). mirror.server.ts expose aussi disableMirrorGiftCard() (compensation best-effort).

epay-issuer.server.ts est le client REST de l’API Titreo E-Pay. Il expose checkBalance, debit, refund et appelle les endpoints :

POST /v1/gift-cards/check-balance → solde disponible
POST /v1/gift-cards/debit → débit immédiat (idempotent par Idempotency-Key)
POST /v1/gift-cards/refund → recrédit (compensation)

Chaque requête porte Authorization: Bearer <EPAY_API_KEY> et, pour débit/refund, un header idempotency-key déterministe. Le corps de requête (numéro de carte, CVV) n’est jamais loggé (cf. Sécurité). Une erreur de transport (502 E-Pay) est remontée avec transient:true pour permettre le retry. Le constructeur accepte un fetchImpl injectable (testabilité). Côté E-Pay, ces routes correspondent à api/src/http/routes/gift-cards.routes.ts.

Deux extensions, même technologie (Preact + web components Polaris <s-...>, API 2026-04, capabilities api_access + network_access), même parcours en 2 temps : vérifier la carte (numéro + CVV → solde), puis choisir le montant à verser.

Extension Handle Cible (target) Fichier source Plan Shopify
Customer Account profile-block customer-account.profile.block.render extensions/profile-block/src/AddGiftCard.jsx standard
Checkout checkout-gift purchase.checkout.reductions.render-after extensions/checkout-gift/src/Checkout.jsx Plus

L’extension checkout plafonne le montant applicable à min(solde carte, total commande) côté client (capCents()), génère un requestId stable par envoi (neuf si le montant change), et appelle applyGiftCardChange({ type: "addGiftCard", code }) une fois le miroir émis. L’extension profil verse en avoir et affiche le nouveau solde.

Les routes app._index.tsx et app.additional.tsx sont, à ce stade, le template Shopify d’origine (génération d’un produit de démo, exemples metafields/metaobjects via Admin GraphQL). Elles ne portent pas encore de configuration métier E-Pay (mapping émetteurs, clé API marchand par boutique…). C’est un chantier ouvert de la brique.

  • Origine du pattern : le miroir gift card a été prouvé dans le POC — voir l’étude de faisabilité gift card. Cette app industrialise ce POC en surfaces natives (profil + checkout) plus chaîne backend.
  • Contrat consommé : l’app est un client de l’API E-Pay publique. Le contrat des endpoints /v1/gift-cards/* (check-balance, debit, refund), leurs statuts (OK/CAPTURED/REFUNDED/FAILED) et la sémantique d’idempotence sont décrits dans Contrats inter-briques, Idempotence et Backend.
  • Sécurité : vérification HS256 du session token, non-logging des numéros de carte, clés d’idempotence déterministes — cf. Sécurité.