giftcard-feasibility (POC Shopify)
À quoi ça sert
Section intitulée « À quoi ça sert »giftcard-feasibility/ répond à une seule question, en quelques minutes, en local : peut-on construire le « Gift Card Mirror » sur Shopify sans dépendre du backend Titreo E-Pay ?
Le problème de fond : une carte cadeau externe (type Wedoogift, multi-enseigne) n’existe pas dans Shopify. Pour la faire payer une commande Shopify, l’idée est de la mirrorer — débiter la carte externe, puis émettre une gift card Shopify de même montant et l’appliquer au panier. Le split natif de Shopify impute la gift card et laisse un reliquat que Shop Pay encaisse. Tout repose sur des inconnues plateforme qui ne se tranchent que par du code qui tape la vraie API Shopify.
Ce harnais lève ces inconnues sous forme de tests Go/No-Go, puis va plus loin : il esquisse la mécanique d’orchestration réelle (la saga miroir) et fournit une surface utilisateur runnable (la chaîne « carte externe → store credit »). C’est un brouillon de faisabilité, mais un brouillon dont les patterns ont directement nourri l’app Shopify de production, dans epay-shopify-app/.
flowchart LR A["Carte cadeau externe<br/>(Wedoogift…)"] -->|"réserve / débit"| B["Émetteur (IssuerAdapter)"] B -->|"émission miroir<br/>giftCardCreate"| C["Gift card Shopify"] C -->|"application au panier<br/>cartGiftCardCodesUpdate"| D["Split natif Shopify"] D -->|"reliquat"| E["Shop Pay encaisse le reste"]
Stack et exécution
Section intitulée « Stack et exécution »Brique TypeScript minimale, exécutée par tsx (pas de build), type: module. Seule dépendance runtime : dotenv. Node 18+ (utilise fetch global). Source : giftcard-feasibility/package.json.
Les scripts npm réels :
| Script | Commande | Rôle |
|---|---|---|
npm run test:create |
tsx src/test-1-create-giftcard.ts |
Test 1 — émission gift card par API |
npm run test:flow |
tsx src/test-2-split-flow.ts |
Test 2 — split natif / reliquat |
npm run test:mirror |
tsx src/test-3-mirror-saga.ts |
Test 3 — saga d’orchestration |
npm run test:credit |
tsx src/test-4-store-credit.ts |
Test 4 — crédit store credit par API |
npm test |
tsx src/run-all.ts |
Tests 1 + 2 à la suite (réseau) |
npm run consumer |
tsx consumer/server.ts |
Surface conso store credit (UI runnable) |
npm run typecheck |
tsc --noEmit |
Vérification de types |
La config réseau (SHOPIFY_STORE_DOMAIN, SHOPIFY_ADMIN_ACCESS_TOKEN, SHOPIFY_STOREFRONT_ACCESS_TOKEN, SHOPIFY_API_VERSION…) est lue et validée dans giftcard-feasibility/src/config.ts (fail-fast si une variable manque). Le client GraphQL Admin/Storefront vit dans giftcard-feasibility/src/shopify.ts. Modèle de variables : giftcard-feasibility/.env.example.
La matrice de tests 1 à 4
Section intitulée « La matrice de tests 1 à 4 »Chaque test isole une inconnue. Les tests 1, 2 et 4 tapent le réseau (.env requis, boutique de dev uniquement). Le test 3 tourne offline par défaut.
| Test | Fichier | Ce qu’il vérifie | Commande |
|---|---|---|---|
| 1 — Création | src/test-1-create-giftcard.ts |
Go/No-Go : ce plan/scope autorise-t-il giftCardCreate par Admin API ? |
npm run test:create |
| 2 — Split | src/test-2-split-flow.ts |
La gift card appliquée laisse-t-elle un reliquat (0 < reliquat < total) pour Shop Pay ? | npm run test:flow |
| 3 — Saga | src/test-3-mirror-saga.ts |
La couche orchestration (IssuerAdapter + saga + outbox) tient-elle de bout en bout, retries et compensation compris ? | npm run test:mirror |
| 4 — Store credit | src/test-4-store-credit.ts |
Go/No-Go : peut-on créditer l’avoir client par API (storeCreditAccountCredit) et relire le delta ? |
npm run test:credit |
Le vrai inconnu de la solution est le test 1 : s’il passe (mutation giftCardCreate acceptée), la faisabilité du miroir est acquise. Un échec sur refus d’accès est la réponse : le plan de la boutique cible ne permet pas l’émission par API.
Détails utiles à lire dans le code :
- Test 1 (
src/test-1-create-giftcard.ts) exportecreateGiftCard()(mutationgiftCardCreate) etrunCreateTest(). - Test 2 (
src/test-2-split-flow.ts) exportepickVariant(),createCart(),runSplitFlowTest()— réutilisés par le mode live du test 3. - Test 4 (
src/test-4-store-credit.ts) crée/retrouve un client, lit le solde store credit, crédite, relit, vérifie le delta. Demande un email réel pour valider l’auto-apply au checkout (npm run test:credit -- toi@gmail.com). src/run-all.tsenchaînerunCreateTest()puisrunSplitFlowTest().
La saga miroir — préfiguration du design réel
Section intitulée « La saga miroir — préfiguration du design réel »Au-dessus des probes brutes, src/mirror/ pose la mécanique réelle de la solution, dans le même style ports / adapters / outbox que l’API Titreo E-Pay. C’est là que le POC cesse d’être un test et devient une esquisse d’architecture.
Les trois ports
Section intitulée « Les trois ports »| Port | Fichier | Méthodes | Impl réelle / factice |
|---|---|---|---|
IssuerAdapter |
src/mirror/issuer-adapter.ts |
checkBalance / reserve / capture / release / refund |
FakeIssuer (fake-issuer.ts) — pas d’impl réelle ici |
MirrorGateway |
src/mirror/mirror-gateway.ts |
emit / apply / disable |
ShopifyMirrorGateway (shopify-mirror-gateway.ts) ou FakeMirrorGateway (fake-mirror-gateway.ts) |
StoreCreditGateway |
src/mirror/store-credit-gateway.ts |
resolveCustomer / credit / debit / balance |
ShopifyStoreCreditGateway (shopify-store-credit-gateway.ts) |
Tous suivent les mêmes conventions que les ports de l’API : résultats en unions discriminées (status), montants en cents entiers, idempotencyKey explicite et obligatoire. Le modèle émetteur retenu est débit immédiat + reversal (pour les émetteurs sans pré-autorisation) : reserve débite tout de suite, capture confirme et recrédite le résidu, release / refund recréditent.
Le StoreCreditGateway a volontairement une forme différente du MirrorGateway : le store credit est lié à un compte client, pas à un panier — d’où resolveCustomer + credit/debit/balance au lieu de emit/apply/disable.
L’orchestrateur et l’outbox
Section intitulée « L’orchestrateur et l’outbox »MirrorOrchestrator (src/mirror/mirror-orchestrator.ts) est la saga proprement dite. Elle ne fait rien de synchrone : chaque étape s’enfile dans un outbox et un worker la draine.
sequenceDiagram participant O as "MirrorOrchestrator" participant Q as "InMemoryOutbox" participant I as "IssuerAdapter (carte externe)" participant G as "MirrorGateway (Shopify)" O->>I: "start() → checkBalance" O->>Q: "enqueue RESERVE_ISSUER" Q->>I: "reserve (débit externe)" Q->>G: "EMIT_MIRROR → emit (giftCardCreate)" Q->>G: "APPLY_MIRROR → apply (split, reliquat connu)" Note over O,G: "attend le paiement" O->>Q: "onOrderPaid() → CAPTURE_ISSUER" O->>Q: "onRefund() → REFUND_ISSUER + DISABLE_MIRROR"
Points de design transposés tels quels dans le code de prod :
- Invariant : on réserve l’externe AVANT d’émettre le miroir. On ne crée jamais de gift card Shopify sans avoir d’abord sécurisé les fonds côté carte externe.
- Outbox at-least-once (
src/mirror/outbox.ts) :InMemoryOutbox+ workerdrainOutbox. Claim atomique, backoff exponentiel, idempotence paridempotencyKey(une clé déjà en file n’est jamais ré-enfilée). En test, uneLogicalClock« saute » au prochain retry → backoff fidèle sanssleep, exécution déterministe et instantanée. - Compensation ordre-inverse, best-effort : si
emit/applyépuise sesmaxAttempts, la saga déclencheDISABLE_MIRRORpuisRELEASE_ISSUER. Aucun fonds bloqué : la réserve externe est annulée et la gift card miroir neutralisée. - Gardes de statut idempotentes : chaque action ne s’exécute que dans l’état attendu du leg (
RESERVING→RESERVED→MIRRORED→APPLIED→COMPLETED, avecCOMPENSATING/CANCELED/REVERSEDen sortie), sinon elle renvoieCOMPLETEDsans rien faire. État du leg/session danssrc/mirror/mirror-state.ts(MirrorLeg,MirrorSession,InMemorySessionStore).
Le test 3 exerce tout ça : happy path (reserve → emit → apply → capture → refund), puis un scénario de compensation forcée (apply échoue 5×). En --live, il pilote le vrai Shopify pour l’émission et le split, l’émetteur restant factice. En prod, on remplace InMemoryOutbox par le repo Firebird et FakeIssuer par l’adapter émetteur réel.
La surface consommateur — store credit
Section intitulée « La surface consommateur — store credit »Au-delà des probes, la vraie cible est le parcours utilisateur. consumer/ est une surface fonctionnelle runnable : le buyer saisit le code de sa carte cadeau externe, on le verse sur son avoir Shopify (store credit), qui s’auto-applique à son prochain checkout (client identifié).
consumer/server.ts— serveur HTTP natif (zéro framework). CâbleFakeIssuer(carte externe ; codes démoDEMO-50/DEMO-25/WEDOO-TEST, à swapper par l’adapter Wedoogift) +ShopifyStoreCreditGateway(avoir réel sur le sandbox). Sert la page et l’endpointPOST /api/redeem. Variables :PORT(def. 3000),STORE_CREDIT_CURRENCY(def. USD).consumer/redeem-service.ts— la chaîneRedeemService:checkBalance→resolveCustomer→reserve(débit carte externe) →credit(store credit). Idempotent (cache parcardCode+ identité, pas de double-crédit) et compensé (releasesi le crédit échoue). Deux chemins :redeem()(par email, maquette standalone) etredeemForCustomer()(client déjà identifié, le vrai parcours).consumer/page.ts—renderPage(), la page de saisie (HTML/CSS/JS inline).
npm run consumer # http://localhost:3000 — email + code démo, puis checkout connectéPORT=3010 npm run consumer # autre portPour aller plus loin
Section intitulée « Pour aller plus loin »- Vue d’ensemble de l’écosystème — où se situe cette étude
- App Shopify (epay-shopify-app) — l’app Shopify de production, héritière de ces patterns
- Contrats inter-briques — les conventions de ports reprises ici
- Environnement de dev local — lancer la brique
README de la brique : giftcard-feasibility/README.md. Notes de la surface native : giftcard-feasibility/native/README.md.